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", remainingAfter: "Remaining After", } as const; export interface AuditEntry { code: string; people: number; // positive = checked in, negative = undo name: 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 summary = `${entry.code} ${sign}${entry.people} (${entry.action})`; 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.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}`); } } /** 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] ?? "", 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", })); } }