Add check-in audit logging + in-app history view

Every successful check-in/undo writes a row to the "2026 Ticket Audit Logs"
NocoDB table (timestamp, people, code, action, name, remaining-after).
Non-fatal: audit failures never block a gate check-in. New GET /api/audit
endpoint (global or per-code). Admin panel gains a global "Recent check-ins"
panel and per-ticket history. Audit table is optional via NOCODB_AUDIT_TABLE_ID.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-08 03:53:30 +00:00
parent 3397e3e3ec
commit b36d63a6a4
11 changed files with 342 additions and 4 deletions

View file

@ -7,6 +7,8 @@ const schema = z.object({
NOCODB_BASE_URL: z.string().url(),
NOCODB_API_TOKEN: z.string().min(1),
NOCODB_TABLE_ID: z.string().min(1),
// Optional "2026 Ticket Audit Logs" table. If unset, audit logging is skipped.
NOCODB_AUDIT_TABLE_ID: z.string().optional(),
MAILERSEND_API_TOKEN: z.string().min(1),
MAIL_FROM_EMAIL: z.string().email(),

View file

@ -2,6 +2,7 @@ import type { Config } from "./config.js";
import { NocoDBClient } from "./services/nocodb.js";
import { Mailer } from "./services/mailer.js";
import { RedeemQueue } from "./services/redeemQueue.js";
import { AuditLogger } from "./services/audit.js";
/** Shared services wired once at startup and hung off the Fastify instance. */
export interface AppContext {
@ -9,6 +10,7 @@ export interface AppContext {
nocodb: NocoDBClient;
mailer: Mailer;
queue: RedeemQueue;
audit: AuditLogger;
}
export function buildContext(config: Config): AppContext {
@ -17,6 +19,7 @@ export function buildContext(config: Config): AppContext {
nocodb: new NocoDBClient(config),
mailer: new Mailer(config),
queue: new RedeemQueue(),
audit: new AuditLogger(config),
};
}

View file

@ -42,6 +42,13 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
},
);
// Recent check-in audit log (all, or filtered to one code via ?code=).
app.get("/api/audit", { preHandler: requireStaff }, async (req) => {
const code = (req.query as any)?.code ? normalizeCode(String((req.query as any).code)) : undefined;
const limit = Number((req.query as any)?.limit) || 50;
return { enabled: app.ctx.audit.enabled, entries: await app.ctx.audit.recent({ code, limit }) };
});
// Search by name/email or exact code for the admin panel.
app.get(
"/api/tickets",

View file

@ -0,0 +1,102 @@
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";
}
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<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_AUDIT_TABLE_ID">) {
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<void> {
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<AuditRow[]> {
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",
}));
}
}

View file

@ -66,11 +66,20 @@ export class FakeNocoDB {
}
export function fakeContext(db: FakeNocoDB): AppContext {
const auditEntries: any[] = [];
return {
config: {} as any,
nocodb: db as any,
mailer: { isBlockedRecipient: () => false, sendTicket: async () => {} } as any,
queue: new RedeemQueue(2000),
audit: {
enabled: true,
entries: auditEntries,
log: async (e: any) => {
auditEntries.push(e);
},
recent: async () => auditEntries,
} as any,
};
}

View file

@ -53,6 +53,26 @@ describe("redeem", () => {
if (r.ok) expect(r.ticket.redeemed).toBe(0);
});
it("writes an audit entry on each successful check-in and undo", async () => {
const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-AUDT-0001", ages: { "Ages 26-45": 4 } });
const ctx = fakeContext(db);
await redeem(ctx, "BC26-AUDT-0001", 2);
await redeem(ctx, "BC26-AUDT-0001", -1);
const log = (ctx.audit as any).entries;
expect(log).toHaveLength(2);
expect(log[0]).toMatchObject({ code: "BC26-AUDT-0001", people: 2, action: "check-in", remainingAfter: 2 });
expect(log[1]).toMatchObject({ people: -1, action: "undo", remainingAfter: 3 });
});
it("does not audit a no-op (undo when nothing redeemed)", async () => {
const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-AUDT-0002", ages: { "Ages 26-45": 3 }, redeemed: 0 });
const ctx = fakeContext(db);
await redeem(ctx, "BC26-AUDT-0002", -2); // clamps to 0, delta 0
expect((ctx.audit as any).entries).toHaveLength(0);
});
it("returns not_found for unknown codes", async () => {
const ctx = fakeContext(new FakeNocoDB());
const r = await redeem(ctx, "BC26-ZZZZ-9999", 1);

View file

@ -75,7 +75,20 @@ export async function redeem(ctx: AppContext, code: string, count: number): Prom
});
// Trust our computed `next` but prefer the DB's echoed value if present.
const confirmed = { ...rec, [COL.redeemed]: Number(updated?.[COL.redeemed] ?? next) };
return { ok: true, ticket: toView(confirmed), checkedIn: next - redeemed };
const view = toView(confirmed);
const delta = next - redeemed;
if (delta !== 0) {
// Non-fatal: audit failures never block a check-in.
await ctx.audit.log({
code: view.code,
people: delta,
name: view.name,
remainingAfter: view.remaining,
at: new Date().toISOString(),
action: delta >= 0 ? "check-in" : "undo",
});
}
return { ok: true, ticket: view, checkedIn: delta };
} catch (e: any) {
return { ok: false, reason: "db_error", ticket: toView(rec), detail: e?.message ?? "update failed" };
}