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>
121 lines
3.8 KiB
TypeScript
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();
|
|
}
|