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" };
}