CampgroundTickets/backend/src/services/audit.ts
Hank 43fddec286
All checks were successful
Build Android APK / build-apk (push) Successful in 56m36s
Add event report dashboard, slide-out drawer, in-app comp portal + creator tracking
- Reporting: GET /api/stats aggregates check-in progress, ice, ticket types,
  people breakdown, add-ons/donors, gate-crew leaderboard (from audit),
  comp tickets by creator, and a by-hour check-in timeline. New /stats screen.
- Slide-out drawer (custom RN Animated, no new native deps) replaces per-screen
  header links; available on every main screen via a hamburger.
- In-app comp portal (/comp), password-gated like /crush33, reusing the portal
  endpoints; records the issuing gate-staff name (Created By column) and reports
  comps per creator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 17:30:18 +00:00

144 lines
5 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", // 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<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 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<AuditRow[]> {
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<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] ?? "",
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",
}));
}
}