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

@ -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);