CampgroundTickets/backend/src/services/nocodb.ts
Hank 3397e3e3ec Initial Camp Scan ticketing system
Backend (Fastify + TS): FluentForms webhook -> NocoDB row + QR + MailerSend
email; PIN auth; scan/lookup/redeem with per-code serialization; reusable QR
codes with count-based check-in; admin search.

App (Expo, one codebase): Android APK + iPhone PWA. Login, camera scanner
(native + web barcode-detector split), green/red overlay with sound + haptics,
admin lookup/redeem. Session token persisted per device.

Ops: multi-stage Dockerfile serving API + PWA same-origin, compose bound to
127.0.0.1; Forgejo Actions runner + tag-triggered signed APK build for Obtainium.
Docs in README.md and INSTALL.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 03:47:59 +00:00

121 lines
3.8 KiB
TypeScript

import type { Config } from "../config.js";
import { COL, type NocoRecord } from "../fields.js";
/**
* Thin client over the NocoDB v2 records REST API.
* Docs: {baseUrl}/api/v2/tables/{tableId}/records (auth header: xc-token)
*/
export class NocoDBClient {
private readonly base: string;
private readonly token: string;
private readonly tableId: string;
constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_TABLE_ID">) {
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
this.token = cfg.NOCODB_API_TOKEN;
this.tableId = cfg.NOCODB_TABLE_ID;
}
private get recordsUrl(): string {
return `${this.base}/api/v2/tables/${this.tableId}/records`;
}
private async request(url: string, init: RequestInit = {}): Promise<any> {
const res = await fetch(url, {
...init,
headers: {
"xc-token": this.token,
"Content-Type": "application/json",
...(init.headers || {}),
},
});
const text = await res.text();
let body: any = undefined;
if (text) {
try {
body = JSON.parse(text);
} catch {
body = text;
}
}
if (!res.ok) {
const detail =
body && typeof body === "object" && body.msg
? body.msg
: typeof body === "string"
? body
: res.statusText;
throw new NocoDBError(`NocoDB ${res.status}: ${detail}`, res.status);
}
return body;
}
private async list(where: string, limit = 25): Promise<NocoRecord[]> {
const url = new URL(this.recordsUrl);
if (where) url.searchParams.set("where", where);
url.searchParams.set("limit", String(limit));
const body = await this.request(url.toString());
return (body?.list ?? []) as NocoRecord[];
}
/** Exact lookup by ticket code. Returns null if not found. */
async findByCode(code: string): Promise<NocoRecord | null> {
const rows = await this.list(`(${COL.code},eq,${escapeValue(code)})`, 1);
return rows[0] ?? null;
}
/** Lookup by idempotency key. Returns null if not found. */
async findBySubmissionKey(key: string): Promise<NocoRecord | null> {
const rows = await this.list(`(${COL.submissionKey},eq,${escapeValue(key)})`, 1);
return rows[0] ?? null;
}
/** Substring search across name and email. */
async search(query: string, limit = 25): Promise<NocoRecord[]> {
const q = escapeValue(query);
return this.list(`(${COL.name},like,%${q}%)~or(${COL.email},like,%${q}%)`, limit);
}
async create(fields: Record<string, unknown>): Promise<NocoRecord> {
const body = await this.request(this.recordsUrl, {
method: "POST",
body: JSON.stringify(fields),
});
return (Array.isArray(body) ? body[0] : body) as NocoRecord;
}
/** Patch fields on a record identified by its NocoDB Id. */
async update(id: number, fields: Record<string, unknown>): Promise<NocoRecord> {
const body = await this.request(this.recordsUrl, {
method: "PATCH",
body: JSON.stringify({ Id: id, ...fields }),
});
return (Array.isArray(body) ? body[0] : body) as NocoRecord;
}
/** Cheap connectivity probe for healthchecks. */
async ping(): Promise<boolean> {
const url = new URL(this.recordsUrl);
url.searchParams.set("limit", "1");
await this.request(url.toString());
return true;
}
}
export class NocoDBError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = "NocoDBError";
this.status = status;
}
}
/**
* Escape a value for use inside a NocoDB `where=(Field,op,VALUE)` clause.
* Parentheses and commas are structural in the filter grammar; strip them.
* Ticket codes/emails/names never legitimately contain them for filtering.
*/
function escapeValue(v: string): string {
return String(v).replace(/[(),]/g, " ").trim();
}