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:
parent
3397e3e3ec
commit
b36d63a6a4
11 changed files with 342 additions and 4 deletions
|
|
@ -9,6 +9,8 @@ NOCODB_BASE_URL=https://nocodb.beartariacampgrounds.com
|
||||||
NOCODB_API_TOKEN=
|
NOCODB_API_TOKEN=
|
||||||
# Table ID of "2026 Campground Tickets" (right-click table -> Copy Table ID, looks like m1a2b3c4d5e6f7)
|
# Table ID of "2026 Campground Tickets" (right-click table -> Copy Table ID, looks like m1a2b3c4d5e6f7)
|
||||||
NOCODB_TABLE_ID=
|
NOCODB_TABLE_ID=
|
||||||
|
# Optional: table ID of "2026 Ticket Audit Logs". If unset, audit logging is skipped.
|
||||||
|
NOCODB_AUDIT_TABLE_ID=
|
||||||
|
|
||||||
# MailerSend
|
# MailerSend
|
||||||
MAILERSEND_API_TOKEN=
|
MAILERSEND_API_TOKEN=
|
||||||
|
|
|
||||||
19
README.md
19
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`.
|
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`)
|
## 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/lookup` `{code}` | Read a ticket by code (no mutation) |
|
||||||
| `POST /api/redeem` `{code, count}` | Check in `count` people (negative undoes); serialized per code |
|
| `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/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 |
|
| `POST /api/tickets/{code}/resend-email` | Re-send the QR email |
|
||||||
| `GET /api/health` | Health + NocoDB probe |
|
| `GET /api/health` | Health + NocoDB probe |
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 { StyleSheet, View, Text, Pressable, TextInput, ScrollView, ActivityIndicator } from "react-native";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { SafeAreaView } from "react-native-safe-area-context";
|
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 { feedbackSuccess, feedbackError } from "../lib/feedback";
|
||||||
import { theme } from "../lib/theme";
|
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 <Text style={styles.auditEmpty}>No check-ins recorded yet.</Text>;
|
||||||
|
return (
|
||||||
|
<View style={styles.auditList}>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<View key={e.id} style={styles.auditRow}>
|
||||||
|
<Text style={[styles.auditPeople, e.people < 0 && styles.auditUndo]}>
|
||||||
|
{e.people > 0 ? `+${e.people}` : e.people}
|
||||||
|
</Text>
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.auditName} numberOfLines={1}>
|
||||||
|
{e.name || e.code}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.auditMeta}>
|
||||||
|
{fmtTime(e.at)} · {e.action} · {e.remainingAfter} left
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminScreen() {
|
export default function AdminScreen() {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [results, setResults] = useState<TicketView[]>([]);
|
const [results, setResults] = useState<TicketView[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [note, setNote] = useState("");
|
const [note, setNote] = useState("");
|
||||||
const [searched, setSearched] = useState(false);
|
const [searched, setSearched] = useState(false);
|
||||||
|
const [showRecent, setShowRecent] = useState(false);
|
||||||
|
const [recent, setRecent] = useState<AuditEntry[]>([]);
|
||||||
|
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 () => {
|
const doSearch = useCallback(async () => {
|
||||||
if (!q.trim()) return;
|
if (!q.trim()) return;
|
||||||
|
|
@ -85,6 +142,22 @@ export default function AdminScreen() {
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Pressable style={styles.recentToggle} onPress={toggleRecent}>
|
||||||
|
<Text style={styles.recentToggleText}>
|
||||||
|
{showRecent ? "▾ Recent check-ins" : "▸ Recent check-ins"}
|
||||||
|
</Text>
|
||||||
|
{showRecent && (
|
||||||
|
<Pressable onPress={loadRecent} hitSlop={8}>
|
||||||
|
<Text style={styles.refresh}>↻ Refresh</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
{showRecent && (
|
||||||
|
<View style={styles.recentBox}>
|
||||||
|
{recentLoading ? <ActivityIndicator color={theme.successBright} /> : <AuditList entries={recent} />}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{!!note && <Text style={styles.note}>{note}</Text>}
|
{!!note && <Text style={styles.note}>{note}</Text>}
|
||||||
|
|
||||||
{busy ? (
|
{busy ? (
|
||||||
|
|
@ -102,6 +175,28 @@ export default function AdminScreen() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: TicketView, d: number) => void }) {
|
function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: TicketView, d: number) => void }) {
|
||||||
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
const [history, setHistory] = useState<AuditEntry[]>([]);
|
||||||
|
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[] = [];
|
const tags: string[] = [];
|
||||||
if (ticket.extras.carParking) tags.push("🚗 Car");
|
if (ticket.extras.carParking) tags.push("🚗 Car");
|
||||||
if (ticket.extras.rvParking) tags.push("🚐 RV");
|
if (ticket.extras.rvParking) tags.push("🚐 RV");
|
||||||
|
|
@ -148,6 +243,18 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti
|
||||||
</Pressable>
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Pressable style={styles.historyToggle} onPress={() => setShowHistory((v) => !v)}>
|
||||||
|
<Text style={styles.historyToggleText}>
|
||||||
|
{showHistory ? "▾ Hide check-in history" : "▸ Check-in history"}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
{showHistory &&
|
||||||
|
(historyLoading ? (
|
||||||
|
<ActivityIndicator color={theme.successBright} style={{ marginTop: 8 }} />
|
||||||
|
) : (
|
||||||
|
<AuditList entries={history} />
|
||||||
|
))}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -209,4 +316,39 @@ const styles = StyleSheet.create({
|
||||||
actUndo: { backgroundColor: theme.warn },
|
actUndo: { backgroundColor: theme.warn },
|
||||||
actDisabled: { opacity: 0.35 },
|
actDisabled: { opacity: 0.35 },
|
||||||
actText: { color: "#fff", fontSize: 14, fontWeight: "700" },
|
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" },
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -118,3 +118,24 @@ export function redeem(code: string, count: number): Promise<RedeemResult> {
|
||||||
export function searchTickets(q: string): Promise<{ results: TicketView[] }> {
|
export function searchTickets(q: string): Promise<{ results: TicketView[] }> {
|
||||||
return authed<{ results: TicketView[] }>(`/api/tickets?q=${encodeURIComponent(q)}`);
|
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}` : ""}`);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ const schema = z.object({
|
||||||
NOCODB_BASE_URL: z.string().url(),
|
NOCODB_BASE_URL: z.string().url(),
|
||||||
NOCODB_API_TOKEN: z.string().min(1),
|
NOCODB_API_TOKEN: z.string().min(1),
|
||||||
NOCODB_TABLE_ID: 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),
|
MAILERSEND_API_TOKEN: z.string().min(1),
|
||||||
MAIL_FROM_EMAIL: z.string().email(),
|
MAIL_FROM_EMAIL: z.string().email(),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { Config } from "./config.js";
|
||||||
import { NocoDBClient } from "./services/nocodb.js";
|
import { NocoDBClient } from "./services/nocodb.js";
|
||||||
import { Mailer } from "./services/mailer.js";
|
import { Mailer } from "./services/mailer.js";
|
||||||
import { RedeemQueue } from "./services/redeemQueue.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. */
|
/** Shared services wired once at startup and hung off the Fastify instance. */
|
||||||
export interface AppContext {
|
export interface AppContext {
|
||||||
|
|
@ -9,6 +10,7 @@ export interface AppContext {
|
||||||
nocodb: NocoDBClient;
|
nocodb: NocoDBClient;
|
||||||
mailer: Mailer;
|
mailer: Mailer;
|
||||||
queue: RedeemQueue;
|
queue: RedeemQueue;
|
||||||
|
audit: AuditLogger;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildContext(config: Config): AppContext {
|
export function buildContext(config: Config): AppContext {
|
||||||
|
|
@ -17,6 +19,7 @@ export function buildContext(config: Config): AppContext {
|
||||||
nocodb: new NocoDBClient(config),
|
nocodb: new NocoDBClient(config),
|
||||||
mailer: new Mailer(config),
|
mailer: new Mailer(config),
|
||||||
queue: new RedeemQueue(),
|
queue: new RedeemQueue(),
|
||||||
|
audit: new AuditLogger(config),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,13 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 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.
|
// Search by name/email or exact code for the admin panel.
|
||||||
app.get(
|
app.get(
|
||||||
"/api/tickets",
|
"/api/tickets",
|
||||||
|
|
|
||||||
102
backend/src/services/audit.ts
Normal file
102
backend/src/services/audit.ts
Normal file
|
|
@ -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<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",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -66,11 +66,20 @@ export class FakeNocoDB {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fakeContext(db: FakeNocoDB): AppContext {
|
export function fakeContext(db: FakeNocoDB): AppContext {
|
||||||
|
const auditEntries: any[] = [];
|
||||||
return {
|
return {
|
||||||
config: {} as any,
|
config: {} as any,
|
||||||
nocodb: db as any,
|
nocodb: db as any,
|
||||||
mailer: { isBlockedRecipient: () => false, sendTicket: async () => {} } as any,
|
mailer: { isBlockedRecipient: () => false, sendTicket: async () => {} } as any,
|
||||||
queue: new RedeemQueue(2000),
|
queue: new RedeemQueue(2000),
|
||||||
|
audit: {
|
||||||
|
enabled: true,
|
||||||
|
entries: auditEntries,
|
||||||
|
log: async (e: any) => {
|
||||||
|
auditEntries.push(e);
|
||||||
|
},
|
||||||
|
recent: async () => auditEntries,
|
||||||
|
} as any,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,26 @@ describe("redeem", () => {
|
||||||
if (r.ok) expect(r.ticket.redeemed).toBe(0);
|
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 () => {
|
it("returns not_found for unknown codes", async () => {
|
||||||
const ctx = fakeContext(new FakeNocoDB());
|
const ctx = fakeContext(new FakeNocoDB());
|
||||||
const r = await redeem(ctx, "BC26-ZZZZ-9999", 1);
|
const r = await redeem(ctx, "BC26-ZZZZ-9999", 1);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// Trust our computed `next` but prefer the DB's echoed value if present.
|
||||||
const confirmed = { ...rec, [COL.redeemed]: Number(updated?.[COL.redeemed] ?? next) };
|
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) {
|
} catch (e: any) {
|
||||||
return { ok: false, reason: "db_error", ticket: toView(rec), detail: e?.message ?? "update failed" };
|
return { ok: false, reason: "db_error", ticket: toView(rec), detail: e?.message ?? "update failed" };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue