import type { Config } from "../config.js"; // Column titles in the "2026 Ticket Audit Logs" table. Change here if needed. export const AUDIT_COL = { summary: "Summary", // primary value column, human-readable at: "At", code: "Ticket Code", people: "People", action: "Action", name: "Name", // the ticket holder's name operator: "Operator", // the gate staff member who performed the action remainingAfter: "Remaining After", } as const; export interface AuditEntry { code: string; people: number; // positive = checked in, negative = undo name: string; operator: string; remainingAfter: number; at: string; // ISO action: "check-in" | "undo" | "ice" | "ice-undo"; } export interface AuditRow extends AuditEntry { id: number; } /** * Writes/reads check-in audit rows to a separate NocoDB table. Optional: if no * audit table is configured, log() is a no-op and recent() returns []. * Logging failures are swallowed (they must never block a gate check-in). */ export class AuditLogger { private readonly base: string; private readonly token: string; private readonly tableId: string | null; constructor(cfg: Pick) { this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, ""); this.token = cfg.NOCODB_API_TOKEN; this.tableId = cfg.NOCODB_AUDIT_TABLE_ID ?? null; } get enabled(): boolean { return this.tableId !== null; } private get url(): string { return `${this.base}/api/v2/tables/${this.tableId}/records`; } async log(entry: AuditEntry): Promise { if (!this.tableId) return; const sign = entry.people >= 0 ? "+" : ""; const who = entry.operator ? ` by ${entry.operator}` : ""; const summary = `${entry.code} ${sign}${entry.people} (${entry.action})${who}`; try { const res = await fetch(this.url, { method: "POST", headers: { "xc-token": this.token, "Content-Type": "application/json" }, body: JSON.stringify({ [AUDIT_COL.summary]: summary, [AUDIT_COL.at]: entry.at, [AUDIT_COL.code]: entry.code, [AUDIT_COL.people]: entry.people, [AUDIT_COL.action]: entry.action, [AUDIT_COL.name]: entry.name, [AUDIT_COL.operator]: entry.operator, [AUDIT_COL.remainingAfter]: entry.remainingAfter, }), }); if (!res.ok) { const t = await res.text().catch(() => ""); console.error(`audit log write failed: ${res.status} ${t}`); } } catch (e: any) { console.error(`audit log write error: ${e?.message ?? e}`); } } private mapRow(r: any): AuditRow { return { id: r.Id, code: r[AUDIT_COL.code] ?? "", people: Number(r[AUDIT_COL.people]) || 0, name: r[AUDIT_COL.name] ?? "", operator: r[AUDIT_COL.operator] ?? "", remainingAfter: Number(r[AUDIT_COL.remainingAfter]) || 0, at: r[AUDIT_COL.at] ?? r.CreatedAt ?? "", action: (r[AUDIT_COL.action] ?? "check-in") as AuditEntry["action"], }; } /** Every audit row, paginated (for reporting/aggregation). */ async all(): Promise { if (!this.tableId) return []; const out: AuditRow[] = []; const pageSize = 1000; let offset = 0; for (;;) { const url = new URL(this.url); url.searchParams.set("limit", String(pageSize)); url.searchParams.set("offset", String(offset)); const res = await fetch(url.toString(), { headers: { "xc-token": this.token, "Content-Type": "application/json" }, }); if (!res.ok) break; const body: any = await res.json().catch(() => ({})); const list = body?.list ?? []; out.push(...list.map((r: any) => this.mapRow(r))); if (!list.length || body?.pageInfo?.isLastPage || list.length < pageSize) break; offset += pageSize; if (offset > 200000) break; } return out; } /** Recent entries, newest first, optionally filtered to one code. */ async recent(opts: { code?: string; limit?: number } = {}): Promise { if (!this.tableId) return []; const url = new URL(this.url); url.searchParams.set("limit", String(Math.min(opts.limit ?? 50, 200))); url.searchParams.set("sort", `-${AUDIT_COL.at}`); if (opts.code) { url.searchParams.set("where", `(${AUDIT_COL.code},eq,${opts.code.replace(/[(),]/g, " ")})`); } const res = await fetch(url.toString(), { headers: { "xc-token": this.token, "Content-Type": "application/json" }, }); if (!res.ok) return []; const body: any = await res.json().catch(() => ({})); return (body?.list ?? []).map((r: any) => ({ id: r.Id, code: r[AUDIT_COL.code] ?? "", people: Number(r[AUDIT_COL.people]) || 0, name: r[AUDIT_COL.name] ?? "", operator: r[AUDIT_COL.operator] ?? "", remainingAfter: Number(r[AUDIT_COL.remainingAfter]) || 0, at: r[AUDIT_COL.at] ?? r.CreatedAt ?? "", action: (r[AUDIT_COL.action] ?? "check-in") as "check-in" | "undo" | "ice" | "ice-undo", })); } }