- Ice mode: prepaid ice bags (Ice Total / Ice Redeemed columns) redeemed independently of ticket check-ins; grab all bags at once or some now. - Banquet mode: donor total (online + offline) looked up by the ticket's email via the Donors Master List, with a manual email override. New DonorService + POST /api/banquet. - Redeem generalized over a resource (tickets|ice); audit records ice actions. - App gains a mode selector; webhook maps ice_bags (defaults to ICE_BAGS_DEFAULT when only a boolean ice option is present). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
102 lines
3.4 KiB
TypeScript
102 lines
3.4 KiB
TypeScript
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<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",
|
|
}));
|
|
}
|
|
}
|