diff --git a/.env.example b/.env.example index 7dd22fc..3b51ff4 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,8 @@ NOCODB_BASE_URL=https://nocodb.beartariacampgrounds.com NOCODB_API_TOKEN= # Table ID of "2026 Campground Tickets" (right-click table -> Copy Table ID, looks like m1a2b3c4d5e6f7) NOCODB_TABLE_ID= +# Optional: table ID of "2026 Ticket Audit Logs". If unset, audit logging is skipped. +NOCODB_AUDIT_TABLE_ID= # MailerSend MAILERSEND_API_TOKEN= diff --git a/README.md b/README.md index 99f51ed..b918b65 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,23 @@ The app expects the **2026 Campground Tickets** table to be a clone of the 2025 Total redeemable tickets = sum of the age-bracket columns **excluding `Ages 0-3`** (free). Column names are mapped in [`backend/src/fields.ts`](./backend/src/fields.ts) — change them there if the real titles differ. Put the table's ID (right-click table → *Copy Table ID*) in `NOCODB_TABLE_ID`. -> A `CampTickets TEST` table already exists in NocoDB for testing. Point `NOCODB_TABLE_ID` at it for dry runs, then switch to the real 2026 table for production. +### Audit log table — "2026 Ticket Audit Logs" + +Every check-in and undo is recorded to a separate table so the crew can review what happened. Columns (titles mapped in [`backend/src/services/audit.ts`](./backend/src/services/audit.ts)): + +| Column | Type | +|---|---| +| `Summary` | SingleLineText (primary — e.g. `BC26-XXXX +2 (check-in)`) | +| `At` | DateTime | +| `Ticket Code` | SingleLineText | +| `People` | Number (negative for an undo) | +| `Action` | SingleLineText (`check-in` / `undo`) | +| `Name` | SingleLineText | +| `Remaining After` | Number | + +Put its table ID in `NOCODB_AUDIT_TABLE_ID`. Leave the var empty to disable audit logging (check-ins still work). The admin panel shows global recent activity and per-ticket history from this table. + +> `CampTickets TEST` and `CampTickets Audit TEST` tables already exist in NocoDB for testing. Point `NOCODB_TABLE_ID` / `NOCODB_AUDIT_TABLE_ID` at them for dry runs, then switch to the real 2026 tables for production. ## Configuration (`.env`) @@ -161,6 +177,7 @@ The runner runs jobs in a `node:22-bookworm` container and installs the Android | `POST /api/lookup` `{code}` | Read a ticket by code (no mutation) | | `POST /api/redeem` `{code, count}` | Check in `count` people (negative undoes); serialized per code | | `GET /api/tickets?q=` | Search by name/email or exact code | +| `GET /api/audit?code=&limit=` | Recent check-in log (all, or one code) | | `POST /api/tickets/{code}/resend-email` | Re-send the QR email | | `GET /api/health` | Health + NocoDB probe | diff --git a/app/app/admin.tsx b/app/app/admin.tsx index ad40e15..e769981 100644 --- a/app/app/admin.tsx +++ b/app/app/admin.tsx @@ -1,17 +1,74 @@ -import { useCallback, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { StyleSheet, View, Text, Pressable, TextInput, ScrollView, ActivityIndicator } from "react-native"; import { router } from "expo-router"; import { SafeAreaView } from "react-native-safe-area-context"; -import { searchTickets, redeem, type TicketView } from "../lib/api"; +import { searchTickets, redeem, getAudit, type TicketView, type AuditEntry } from "../lib/api"; import { feedbackSuccess, feedbackError } from "../lib/feedback"; import { theme } from "../lib/theme"; +function fmtTime(iso: string): string { + if (!iso) return ""; + const d = new Date(iso); + if (isNaN(d.getTime())) return iso.slice(0, 16).replace("T", " "); + return d.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +function AuditList({ entries }: { entries: AuditEntry[] }) { + if (!entries.length) return No check-ins recorded yet.; + return ( + + {entries.map((e) => ( + + + {e.people > 0 ? `+${e.people}` : e.people} + + + + {e.name || e.code} + + + {fmtTime(e.at)} · {e.action} · {e.remainingAfter} left + + + + ))} + + ); +} + export default function AdminScreen() { const [q, setQ] = useState(""); const [results, setResults] = useState([]); const [busy, setBusy] = useState(false); const [note, setNote] = useState(""); const [searched, setSearched] = useState(false); + const [showRecent, setShowRecent] = useState(false); + const [recent, setRecent] = useState([]); + const [recentLoading, setRecentLoading] = useState(false); + + const loadRecent = useCallback(async () => { + setRecentLoading(true); + try { + const { entries } = await getAudit({ limit: 30 }); + setRecent(entries); + } catch (e: any) { + if (e?.name === "AuthError") return router.replace("/login"); + } finally { + setRecentLoading(false); + } + }, []); + + const toggleRecent = useCallback(() => { + setShowRecent((v) => { + if (!v) loadRecent(); + return !v; + }); + }, [loadRecent]); const doSearch = useCallback(async () => { if (!q.trim()) return; @@ -85,6 +142,22 @@ export default function AdminScreen() { + + + {showRecent ? "▾ Recent check-ins" : "▸ Recent check-ins"} + + {showRecent && ( + + ↻ Refresh + + )} + + {showRecent && ( + + {recentLoading ? : } + + )} + {!!note && {note}} {busy ? ( @@ -102,6 +175,28 @@ export default function AdminScreen() { } function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: TicketView, d: number) => void }) { + const [showHistory, setShowHistory] = useState(false); + const [history, setHistory] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + + const loadHistory = useCallback(async () => { + setHistoryLoading(true); + try { + const { entries } = await getAudit({ code: ticket.code, limit: 25 }); + setHistory(entries); + } catch { + /* ignore */ + } finally { + setHistoryLoading(false); + } + }, [ticket.code]); + + // Refresh history after a check-in/undo changes the count while it's open. + useEffect(() => { + if (showHistory) loadHistory(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ticket.redeemed, showHistory]); + const tags: string[] = []; if (ticket.extras.carParking) tags.push("🚗 Car"); if (ticket.extras.rvParking) tags.push("🚐 RV"); @@ -148,6 +243,18 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti ))} + + setShowHistory((v) => !v)}> + + {showHistory ? "▾ Hide check-in history" : "▸ Check-in history"} + + + {showHistory && + (historyLoading ? ( + + ) : ( + + ))} ); } @@ -209,4 +316,39 @@ const styles = StyleSheet.create({ actUndo: { backgroundColor: theme.warn }, actDisabled: { opacity: 0.35 }, actText: { color: "#fff", fontSize: 14, fontWeight: "700" }, + + recentToggle: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + paddingHorizontal: 16, + marginTop: 14, + }, + recentToggleText: { color: theme.text, fontSize: 15, fontWeight: "700" }, + refresh: { color: theme.textDim, fontSize: 14 }, + recentBox: { + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + borderRadius: 12, + marginHorizontal: 16, + marginTop: 8, + padding: 12, + }, + + historyToggle: { marginTop: 14, paddingVertical: 4 }, + historyToggleText: { color: theme.textDim, fontSize: 14, fontWeight: "600" }, + auditList: { marginTop: 8, gap: 8 }, + auditRow: { flexDirection: "row", alignItems: "center", gap: 12 }, + auditPeople: { + color: theme.successBright, + fontSize: 18, + fontWeight: "800", + minWidth: 34, + textAlign: "center", + }, + auditUndo: { color: theme.warn }, + auditName: { color: theme.text, fontSize: 15, fontWeight: "600" }, + auditMeta: { color: theme.textDim, fontSize: 12, marginTop: 1 }, + auditEmpty: { color: theme.textDim, fontSize: 14, marginTop: 8, fontStyle: "italic" }, }); diff --git a/app/lib/api.ts b/app/lib/api.ts index 1e90334..57fe6b6 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -118,3 +118,24 @@ export function redeem(code: string, count: number): Promise { export function searchTickets(q: string): Promise<{ results: TicketView[] }> { return authed<{ results: TicketView[] }>(`/api/tickets?q=${encodeURIComponent(q)}`); } + +export interface AuditEntry { + id: number; + code: string; + people: number; + name: string; + remainingAfter: number; + at: string; + action: "check-in" | "undo"; +} + +export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{ + enabled: boolean; + entries: AuditEntry[]; +}> { + const params = new URLSearchParams(); + if (opts.code) params.set("code", opts.code); + if (opts.limit) params.set("limit", String(opts.limit)); + const qs = params.toString(); + return authed<{ enabled: boolean; entries: AuditEntry[] }>(`/api/audit${qs ? `?${qs}` : ""}`); +} diff --git a/backend/src/config.ts b/backend/src/config.ts index 6566dac..5831f35 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -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(), diff --git a/backend/src/context.ts b/backend/src/context.ts index 94ec211..310442a 100644 --- a/backend/src/context.ts +++ b/backend/src/context.ts @@ -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), }; } diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 3b98dd5..5c76f19 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -42,6 +42,13 @@ export async function ticketRoutes(app: FastifyInstance): Promise { }, ); + // 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", diff --git a/backend/src/services/audit.ts b/backend/src/services/audit.ts new file mode 100644 index 0000000..14e6b0e --- /dev/null +++ b/backend/src/services/audit.ts @@ -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) { + 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", + })); + } +} diff --git a/backend/src/test/fakeNocodb.ts b/backend/src/test/fakeNocodb.ts index 0a5064c..65ed0fe 100644 --- a/backend/src/test/fakeNocodb.ts +++ b/backend/src/test/fakeNocodb.ts @@ -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, }; } diff --git a/backend/src/test/redeem.test.ts b/backend/src/test/redeem.test.ts index 59ed668..51e71fe 100644 --- a/backend/src/test/redeem.test.ts +++ b/backend/src/test/redeem.test.ts @@ -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); diff --git a/backend/src/ticketService.ts b/backend/src/ticketService.ts index eeab055..76dce8e 100644 --- a/backend/src/ticketService.ts +++ b/backend/src/ticketService.ts @@ -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" }; }