diff --git a/Dockerfile b/Dockerfile index 845477a..e5ce281 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,8 +32,11 @@ ENV WEB_DIR=/srv/web ENV PORT=8080 ENV HOST=0.0.0.0 -# Run as the non-root node user shipped in the base image. -RUN chown -R node:node /srv +# Run as the non-root node user shipped in the base image. /data is a mount +# point for the runtime state volume — create it owned by node so a fresh named +# volume inherits writable ownership. +RUN chown -R node:node /srv && mkdir -p /data && chown node:node /data +ENV STATE_DIR=/data USER node EXPOSE 8080 diff --git a/app/app/comp.tsx b/app/app/comp.tsx index 364aac0..648af1e 100644 --- a/app/app/comp.tsx +++ b/app/app/comp.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useCallback, useEffect } from "react"; import { StyleSheet, View, @@ -9,10 +9,21 @@ import { Image, KeyboardAvoidingView, Platform, + ActivityIndicator, } from "react-native"; -import { router } from "expo-router"; import { SafeAreaView } from "react-native-safe-area-context"; -import { portalVerify, portalCreate, AuthError, type PortalTicket } from "../lib/api"; +import { + portalVerify, + portalCreate, + adminStatus, + adminWipe, + adminSwitchTable, + adminDonorSearch, + AuthError, + type PortalTicket, + type AdminStatus, + type DonorSearchResult, +} from "../lib/api"; import { useAuth } from "../lib/auth"; import { useMenu } from "../lib/menu"; import { theme } from "../lib/theme"; @@ -26,18 +37,26 @@ const TYPE_ICON: Record = { Speaker: "🎤", }; -export default function CompScreen() { +type Section = "comp" | "donors" | "actions"; +const NAV: { key: Section; icon: string; label: string }[] = [ + { key: "comp", icon: "🎟️", label: "Comp\ntickets" }, + { key: "donors", icon: "🔎", label: "Donor\nlookup" }, + { key: "actions", icon: "⚠️", label: "Actions" }, +]; + +export default function AdminHub() { const { operator } = useAuth(); const { open: openMenu } = useMenu(); const [password, setPassword] = useState(""); const [unlocked, setUnlocked] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const [section, setSection] = useState
("comp"); - const [type, setType] = useState("Guest"); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [result, setResult] = useState(null); + const relock = useCallback(() => { + setUnlocked(false); + setError("Password changed — unlock again."); + }, []); async function unlock() { if (!password || busy) return; @@ -47,28 +66,7 @@ export default function CompScreen() { await portalVerify(password); setUnlocked(true); } catch (e: any) { - setError(e instanceof AuthError ? "Wrong password" : (e?.message ?? "Failed")); - } finally { - setBusy(false); - } - } - - async function create() { - if (!name.trim() || !email.trim() || busy) return; - setBusy(true); - setError(""); - try { - const r = await portalCreate({ password, name: name.trim(), email: email.trim(), type, createdBy: operator }); - setResult(r); - setName(""); - setEmail(""); - } catch (e: any) { - if (e instanceof AuthError) { - setUnlocked(false); // password rotated — re-gate - setError("Password changed — unlock again."); - } else { - setError(e?.message ?? "Failed to create ticket"); - } + setError(e instanceof AuthError ? "Wrong password" : e?.message ?? "Failed"); } finally { setBusy(false); } @@ -80,160 +78,468 @@ export default function CompScreen() { - Comp Tickets + Admin · crush33 - - - {!unlocked ? ( - - Entry-only tickets for workers & guests. Enter the shared portal password. - Portal password - - {!!error && {error}} - - {busy ? "Checking…" : "Unlock"} - - - ) : ( - - Ticket type - - {TYPES.map((t) => ( - setType(t)} - > - - {(TYPE_ICON[t] ?? "🎫") + " " + t} - - - ))} - + {!unlocked ? ( + + + Admin-only area. Enter the shared portal password to unlock. + Portal password + + {!!error && {error}} + + {busy ? "Checking…" : "Unlock"} + + + + ) : ( + + + {NAV.map((n) => { + const active = section === n.key; + return ( + setSection(n.key)}> + {n.icon} + {n.label} + + ); + })} + - Full name - - - Email - - - {!!error && {error}} - - {busy ? "Creating…" : `Create ${type} ticket`} - - - {result && ( - - - {result.code} - - {result.type} · {result.name} - - - {result.emailSent ? "✓ Emailed the ticket" : "Email not sent — screenshot this QR"} - - - )} - - )} - - + + + {section === "comp" && } + {section === "donors" && } + {section === "actions" && } + + + + )} ); } +/* ---------------- Comp tickets ---------------- */ + +function CompSection({ password, operator, onRelock }: { password: string; operator: string | null; onRelock: () => void }) { + const [type, setType] = useState("Guest"); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [result, setResult] = useState(null); + + async function create() { + if (!name.trim() || !email.trim() || busy) return; + setBusy(true); + setError(""); + try { + const r = await portalCreate({ password, name: name.trim(), email: email.trim(), type, createdBy: operator ?? "" }); + setResult(r); + setName(""); + setEmail(""); + } catch (e: any) { + if (e instanceof AuthError) onRelock(); + else setError(e?.message ?? "Failed to create ticket"); + } finally { + setBusy(false); + } + } + + return ( + + Comp tickets + Entry-only tickets for guests & staff. + + Ticket type + + {TYPES.map((t) => ( + setType(t)}> + {(TYPE_ICON[t] ?? "🎫") + " " + t} + + ))} + + + Full name + + Email + + + {!!error && {error}} + + {busy ? "Creating…" : `Create ${type} ticket`} + + + {result && ( + + + {result.code} + {result.type} · {result.name} + {result.emailSent ? "✓ Emailed the ticket" : "Email not sent — screenshot this QR"} + + )} + + ); +} + +/* ---------------- Donor lookup ---------------- */ + +function DonorSection({ password, onRelock }: { password: string; onRelock: () => void }) { + const [query, setQuery] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [results, setResults] = useState(null); + + async function run() { + const q = query.trim(); + if (q.length < 2 || busy) return; + setBusy(true); + setError(""); + try { + const r = await adminDonorSearch(password, q); + setResults(r.results); + } catch (e: any) { + if (e instanceof AuthError) onRelock(); + else setError(e?.message ?? "Search failed"); + } finally { + setBusy(false); + } + } + + return ( + + Donor lookup + 🔒 Admin only · private donor info. Search by name, email, phone, address, bear name… + + + + + {busy ? "…" : "Search"} + + + + {!!error && {error}} + {results !== null && !busy && results.length === 0 && No donors match “{query.trim()}”.} + + {results?.map((d, i) => ( + + + {d.name || d.email || "(unnamed)"} + {d.lifetime != null && {money(d.lifetime)}} + + {!!d.bearName && 🐻 {d.bearName}} + {!!d.email && ✉️ {d.email}} + {!!d.altEmail && ✉️ {d.altEmail} (alt)} + {!!d.phone && 📞 {d.phone}} + {!!d.address && 🏠 {d.address}} + + {d.source === "master" ? "directory" : "transactions"} + {d.tags.map((t) => ( + {t} + ))} + + + ))} + + ); +} + +/* ---------------- Actions (danger zone) ---------------- */ + +function ActionsSection({ password, onRelock }: { password: string; onRelock: () => void }) { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [msg, setMsg] = useState(""); + const [confirm, setConfirm] = useState(null); + const [busy, setBusy] = useState(false); + const [newTickets, setNewTickets] = useState(""); + const [newAudit, setNewAudit] = useState(""); + + const refresh = useCallback(async () => { + setLoading(true); + try { + setStatus(await adminStatus(password)); + } catch (e: any) { + if (e instanceof AuthError) onRelock(); + } finally { + setLoading(false); + } + }, [password, onRelock]); + + // Load status the first time this section renders. + useEffect(() => { + refresh(); + }, [refresh]); + + async function doWipe() { + setBusy(true); + setMsg(""); + try { + const r = await adminWipe(password); + setMsg(`✓ Wiped ${r.ticketsDeleted} tickets and ${r.auditDeleted} audit rows.`); + setConfirm(null); + refresh(); + } catch (e: any) { + if (e instanceof AuthError) onRelock(); + else setMsg(e?.message ?? "Wipe failed"); + } finally { + setBusy(false); + } + } + + async function doSwitch() { + if (!newTickets.trim()) return; + setBusy(true); + setMsg(""); + try { + const r = await adminSwitchTable(password, newTickets.trim(), newAudit.trim() || undefined); + setMsg(`✓ Now using tickets table ${r.tickets.tableId}.`); + setConfirm(null); + setNewTickets(""); + setNewAudit(""); + refresh(); + } catch (e: any) { + if (e instanceof AuthError) onRelock(); + else setMsg(e?.message ?? "Switch failed"); + } finally { + setBusy(false); + } + } + + return ( + + Actions + Event-management tools. These change live data — read the warnings. + + {/* Current status */} + + + Active event table + + {loading ? "…" : "↻"} + + + {status ? ( + <> + tickets: {status.tickets.tableId} · {status.tickets.count} records + audit: {status.audit.tableId ?? "—"} · {status.audit.count} records + + ) : ( + {loading ? "loading…" : "—"} + )} + + + {!!msg && {msg}} + + {/* Wipe slate */} + + 🧹 Wipe the slate clean + + Permanently deletes every ticket and every check-in in the active event + table. Use this to reset before a run-through or a fresh event. + + • Does NOT affect donor data. + • Cannot be undone. + { setMsg(""); setConfirm("wipe"); }}> + Wipe slate… + + + + {/* Switch table */} + + 🔀 Switch event table + + Point the scanner at a different NocoDB table — e.g. to start a new event on + a fresh table while keeping the current one intact. + + • Create the new table first (duplicate the current one's structure in NocoDB — keep the Id column). + • The current event's data is NOT deleted, just no longer shown. + New tickets table ID + + New audit table ID (optional) + + { setMsg(""); setConfirm("switch"); }} disabled={!newTickets.trim()}> + Switch table… + + + + {confirm === "wipe" && ( + setConfirm(null)} + /> + )} + {confirm === "switch" && ( + setConfirm(null)} + /> + )} + + ); +} + +function ConfirmModal({ + title, + lines, + confirmLabel, + busy, + onConfirm, + onCancel, +}: { + title: string; + lines: string[]; + confirmLabel: string; + busy: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( + + + ⚠️ + {title} + {lines.map((l, i) => ( + {l} + ))} + + {busy ? : {confirmLabel}} + + + Cancel + + + + ); +} + +function money(n: number): string { + return "$" + Math.round(n).toLocaleString(); +} + const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: theme.bg }, - topbar: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingHorizontal: 16, - paddingVertical: 10, - }, + topbar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16, paddingVertical: 10 }, brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" }, - link: { color: theme.textDim, fontSize: 16, fontWeight: "600", width: 72 }, + + body: { flex: 1, flexDirection: "row" }, + sidebar: { width: 84, backgroundColor: theme.card, borderRightWidth: 1, borderRightColor: theme.cardBorder, paddingTop: 8 }, + navItem: { paddingVertical: 14, alignItems: "center", gap: 4, borderLeftWidth: 3, borderLeftColor: "transparent" }, + navItemOn: { backgroundColor: theme.bg, borderLeftColor: theme.primary }, + navIcon: { fontSize: 22 }, + navLabel: { color: theme.textDim, fontSize: 11, fontWeight: "700", textAlign: "center", lineHeight: 13 }, + navLabelOn: { color: theme.text }, + content: { flex: 1 }, + pad: { padding: 16, paddingBottom: 48 }, + + h1: { color: theme.text, fontSize: 22, fontWeight: "800", marginBottom: 2 }, + sub: { color: theme.textDim, fontSize: 13, lineHeight: 19, marginBottom: 8 }, lead: { color: theme.textDim, fontSize: 15, lineHeight: 21, marginBottom: 8 }, - label: { color: theme.textDim, fontSize: 13, marginTop: 16, marginBottom: 6 }, - input: { - backgroundColor: theme.card, - borderWidth: 1, - borderColor: theme.cardBorder, - borderRadius: 12, - paddingHorizontal: 14, - paddingVertical: 14, - color: theme.text, - fontSize: 16, - }, + label: { color: theme.textDim, fontSize: 13, marginTop: 14, marginBottom: 6 }, + input: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12, color: theme.text, fontSize: 16, marginBottom: 2 }, error: { color: theme.dangerBright, marginTop: 12, fontSize: 14, fontWeight: "600" }, - btn: { - backgroundColor: theme.successBright, - borderRadius: 13, - paddingVertical: 15, - alignItems: "center", - marginTop: 20, - }, + msg: { color: theme.successBright, marginTop: 10, fontSize: 14, fontWeight: "700" }, + bold: { fontWeight: "800", color: theme.text }, + + btn: { backgroundColor: theme.successBright, borderRadius: 13, paddingVertical: 15, alignItems: "center", marginTop: 18 }, btnOff: { opacity: 0.4 }, btnText: { color: "#06210f", fontSize: 18, fontWeight: "800" }, types: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, - typePill: { - backgroundColor: theme.card, - borderWidth: 1, - borderColor: theme.cardBorder, - borderRadius: 999, - paddingHorizontal: 14, - paddingVertical: 9, - }, + typePill: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 999, paddingHorizontal: 13, paddingVertical: 8 }, typePillOn: { backgroundColor: theme.primary, borderColor: theme.primary }, - typePillText: { color: theme.textDim, fontSize: 14, fontWeight: "700" }, + typePillText: { color: theme.textDim, fontSize: 13, fontWeight: "700" }, typePillTextOn: { color: "#fff" }, - result: { - marginTop: 22, - alignItems: "center", - backgroundColor: theme.card, - borderWidth: 1, - borderColor: theme.cardBorder, - borderRadius: 16, - padding: 20, - }, - qr: { width: 220, height: 220, backgroundColor: "#fff", borderRadius: 10 }, + result: { marginTop: 22, alignItems: "center", backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 16, padding: 20 }, + qr: { width: 200, height: 200, backgroundColor: "#fff", borderRadius: 10 }, rcode: { color: theme.successBright, fontSize: 22, fontWeight: "800", letterSpacing: 2, marginTop: 12 }, rwho: { color: theme.text, fontSize: 16, marginTop: 4 }, rmail: { color: theme.textDim, fontSize: 13, marginTop: 8 }, + + searchRow: { flexDirection: "row", gap: 8, alignItems: "center", marginTop: 8 }, + searchBtn: { backgroundColor: theme.primary, borderRadius: 12, paddingHorizontal: 16, paddingVertical: 13 }, + searchBtnText: { color: "#fff", fontWeight: "800", fontSize: 15 }, + donorCard: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 12, padding: 14, marginTop: 12 }, + donorHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, + donorName: { color: theme.text, fontSize: 17, fontWeight: "800", flex: 1 }, + donorAmt: { color: theme.successBright, fontSize: 16, fontWeight: "800", marginLeft: 8 }, + donorLine: { color: theme.textDim, fontSize: 14, marginTop: 3 }, + donorTags: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 8, alignItems: "center" }, + donorSource: { color: theme.textDim, fontSize: 11, fontWeight: "700", textTransform: "uppercase", backgroundColor: theme.bg, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 6, paddingHorizontal: 6, paddingVertical: 2 }, + donorTag: { color: theme.text, fontSize: 12, backgroundColor: theme.primaryDark, borderRadius: 6, paddingHorizontal: 7, paddingVertical: 2 }, + + statusBox: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 12, padding: 14, marginTop: 4 }, + statusRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, + statusLabel: { color: theme.textDim, fontSize: 12, fontWeight: "700", textTransform: "uppercase", letterSpacing: 0.5 }, + refresh: { color: theme.text, fontSize: 20 }, + statusVal: { color: theme.text, fontSize: 14, marginTop: 6, fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace" }, + + dangerCard: { backgroundColor: "#241717", borderWidth: 1, borderColor: theme.danger, borderRadius: 14, padding: 16, marginTop: 18 }, + dangerTitle: { color: "#ff9a9a", fontSize: 17, fontWeight: "800", marginBottom: 6 }, + dangerBody: { color: "#e9cfcf", fontSize: 14, lineHeight: 20 }, + dangerBullet: { color: "#d9b8b8", fontSize: 13, lineHeight: 19, marginTop: 4 }, + redBtn: { backgroundColor: theme.dangerBright, borderRadius: 12, paddingVertical: 14, alignItems: "center", marginTop: 16 }, + redBtnText: { color: "#fff", fontSize: 16, fontWeight: "800" }, + + modalScrim: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "rgba(0,0,0,0.72)", alignItems: "center", justifyContent: "center", padding: 24 }, + modalCard: { backgroundColor: "#1a1010", borderWidth: 2, borderColor: theme.dangerBright, borderRadius: 18, padding: 22, width: "100%", maxWidth: 380 }, + modalWarn: { fontSize: 40, textAlign: "center" }, + modalTitle: { color: "#fff", fontSize: 20, fontWeight: "900", textAlign: "center", marginTop: 4, marginBottom: 12 }, + modalLine: { color: "#f0d9d9", fontSize: 14, lineHeight: 20 }, + cancelBtn: { paddingVertical: 14, alignItems: "center", marginTop: 6 }, + cancelText: { color: theme.textDim, fontSize: 16, fontWeight: "700" }, }); diff --git a/app/components/SideMenu.tsx b/app/components/SideMenu.tsx index 522b2c3..890027b 100644 --- a/app/components/SideMenu.tsx +++ b/app/components/SideMenu.tsx @@ -7,8 +7,8 @@ import { theme } from "../lib/theme"; const ITEMS: { label: string; icon: string; route: string; seg: string }[] = [ { label: "Scanner", icon: "📷", route: "/", seg: "" }, { label: "Event report", icon: "📊", route: "/stats", seg: "stats" }, - { label: "Comp tickets", icon: "🎟️", route: "/comp", seg: "comp" }, - { label: "Admin lookup", icon: "🔎", route: "/admin", seg: "admin" }, + { label: "Admin (crush33)", icon: "🔐", route: "/comp", seg: "comp" }, + { label: "Banquet lookup", icon: "🍽️", route: "/admin", seg: "admin" }, ]; export default function SideMenu({ visible, onClose }: { visible: boolean; onClose: () => void }) { diff --git a/app/lib/api.ts b/app/lib/api.ts index 9f9d4f1..d574790 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -252,6 +252,56 @@ export async function portalCreate(input: { return body; } +// ---- Admin actions (all gated by the portal password) ---- + +async function adminPost(path: string, password: string, extra: Record = {}): Promise { + const res = await fetch(`${API_BASE}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password, ...extra }), + }); + if (res.status === 401) throw new AuthError("Wrong password"); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new ApiError(body?.detail ?? body?.error ?? `Request failed (${res.status})`); + return body as T; +} + +export interface AdminStatus { + tickets: { tableId: string; count: number }; + audit: { tableId: string | null; count: number; enabled: boolean }; + defaults: { ticketsTableId: string; auditTableId: string | null }; +} +export function adminStatus(password: string): Promise { + return adminPost("/api/admin/status", password); +} + +export function adminWipe(password: string): Promise<{ ok: boolean; ticketsDeleted: number; auditDeleted: number }> { + return adminPost("/api/admin/wipe", password); +} + +export function adminSwitchTable( + password: string, + ticketsTableId: string, + auditTableId?: string, +): Promise<{ ok: boolean; tickets: { tableId: string }; audit: { tableId: string | null } }> { + return adminPost("/api/admin/switch-table", password, { ticketsTableId, auditTableId }); +} + +export interface DonorSearchResult { + name: string; + bearName: string; + email: string; + altEmail: string; + phone: string; + address: string; + lifetime: number | null; + tags: string[]; + source: "master" | "transactions"; +} +export function adminDonorSearch(password: string, query: string): Promise<{ results: DonorSearchResult[]; query: string }> { + return adminPost("/api/admin/donor-search", password, { query }); +} + export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{ enabled: boolean; entries: AuditEntry[]; diff --git a/backend/src/config.ts b/backend/src/config.ts index 88954cf..944dd0f 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -10,6 +10,10 @@ const schema = z.object({ // Optional "2026 Ticket Audit Logs" table. If unset, audit logging is skipped. NOCODB_AUDIT_TABLE_ID: z.string().optional(), + // Writable dir (mounted volume) for small runtime state — e.g. the active + // event table override set from the admin area, so it survives redeploys. + STATE_DIR: z.string().default("/data"), + // Donor tables for Banquet mode. If the master-list id is unset, banquet is // disabled. Online/offline are used as a fallback when a donor is not in the // master list. diff --git a/backend/src/context.ts b/backend/src/context.ts index fcf2c43..9bf2e2c 100644 --- a/backend/src/context.ts +++ b/backend/src/context.ts @@ -4,6 +4,7 @@ import { Mailer } from "./services/mailer.js"; import { RedeemQueue } from "./services/redeemQueue.js"; import { AuditLogger } from "./services/audit.js"; import { DonorService } from "./services/donors.js"; +import { loadActiveTables } from "./services/state.js"; /** Shared services wired once at startup and hung off the Fastify instance. */ export interface AppContext { @@ -16,12 +17,23 @@ export interface AppContext { } export function buildContext(config: Config): AppContext { + const nocodb = new NocoDBClient(config); + const audit = new AuditLogger(config); + + // Apply a persisted "active event table" override (set from the admin area), + // so switching the event survives redeploys without editing .env. + const override = loadActiveTables(config.STATE_DIR); + if (override) { + nocodb.setTableId(override.ticketsTableId); + audit.setTableId(override.auditTableId ?? null); + } + return { config, - nocodb: new NocoDBClient(config), + nocodb, mailer: new Mailer(config), queue: new RedeemQueue(), - audit: new AuditLogger(config), + audit, donors: new DonorService(config), }; } diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..c2fc5a7 --- /dev/null +++ b/backend/src/routes/admin.ts @@ -0,0 +1,122 @@ +import { timingSafeEqual } from "node:crypto"; +import type { FastifyInstance } from "fastify"; +import { saveActiveTables } from "../services/state.js"; + +function safeEqual(a: string, b: string): boolean { + const ba = Buffer.from(a || ""); + const bb = Buffer.from(b || ""); + if (ba.length !== bb.length) return false; + return timingSafeEqual(ba, bb); +} + +/** + * Admin actions for the /crush33 area — all gated by the same PORTAL_PASSWORD + * that unlocks the portal. POST-only so the password never lands in a URL/log. + * + * POST /api/admin/status -> current event tables + record counts + * POST /api/admin/wipe -> delete all ticket + audit records + * POST /api/admin/switch-table -> point the app at different event table(s) + * POST /api/admin/donor-search -> admin-only donor directory search (PII) + */ +export async function adminRoutes(app: FastifyInstance): Promise { + const cfg = app.ctx.config; + + const gate = (req: any, reply: any): boolean => { + if (!cfg.PORTAL_PASSWORD) { + reply.code(404).send({ error: "admin_disabled" }); + return false; + } + const pw = (req.body ?? {}).password; + if (typeof pw !== "string" || !safeEqual(pw, cfg.PORTAL_PASSWORD)) { + reply.code(401).send({ error: "bad_password" }); + return false; + } + return true; + }; + + const rl = { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }; + + app.post("/api/admin/status", rl, async (req, reply) => { + if (!gate(req, reply)) return; + const [tickets, audit] = await Promise.all([ + app.ctx.nocodb.count().catch(() => -1), + app.ctx.audit.count().catch(() => -1), + ]); + return { + tickets: { tableId: app.ctx.nocodb.tableId, count: tickets }, + audit: { tableId: app.ctx.audit.currentTableId, count: audit, enabled: app.ctx.audit.enabled }, + // What .env would use if the override were cleared (for reference). + defaults: { ticketsTableId: cfg.NOCODB_TABLE_ID, auditTableId: cfg.NOCODB_AUDIT_TABLE_ID ?? null }, + }; + }); + + app.post("/api/admin/wipe", rl, async (req, reply) => { + if (!gate(req, reply)) return; + let ticketsDeleted = 0; + let auditDeleted = 0; + try { + ticketsDeleted = await app.ctx.nocodb.deleteAll(); + } catch (e: any) { + return reply.code(502).send({ error: "wipe_failed", detail: e?.message }); + } + try { + auditDeleted = await app.ctx.audit.deleteAll(); + } catch { + // Audit wipe is best-effort; tickets are the important part. + } + req.log.warn({ ticketsDeleted, auditDeleted }, "admin: wiped slate"); + return { ok: true, ticketsDeleted, auditDeleted }; + }); + + app.post("/api/admin/switch-table", rl, async (req, reply) => { + if (!gate(req, reply)) return; + const b = (req.body ?? {}) as { ticketsTableId?: string; auditTableId?: string }; + const ticketsTableId = String(b.ticketsTableId ?? "").trim(); + const auditTableId = String(b.auditTableId ?? "").trim(); + if (!ticketsTableId) { + return reply.code(400).send({ error: "missing_tickets_table" }); + } + + // Validate the new tickets table is reachable and has an Id primary key — + // switching to a PK-less table would make check-in updates hit every row. + const probe = await app.ctx.nocodb.probeTable(ticketsTableId); + if (!probe.ok) { + return reply.code(400).send({ error: "tickets_table_unreachable", status: probe.status }); + } + if (!probe.hasIdPk) { + return reply.code(400).send({ error: "tickets_table_no_id_pk" }); + } + if (auditTableId) { + const ap = await app.ctx.nocodb.probeTable(auditTableId); + if (!ap.ok) return reply.code(400).send({ error: "audit_table_unreachable", status: ap.status }); + } + + // Hot-swap the live clients, then persist so it survives a redeploy. + app.ctx.nocodb.setTableId(ticketsTableId); + app.ctx.audit.setTableId(auditTableId || app.ctx.audit.currentTableId); + saveActiveTables(cfg.STATE_DIR, { + ticketsTableId, + auditTableId: auditTableId || app.ctx.audit.currentTableId || undefined, + }); + req.log.warn({ ticketsTableId, auditTableId }, "admin: switched event table"); + return { + ok: true, + tickets: { tableId: app.ctx.nocodb.tableId }, + audit: { tableId: app.ctx.audit.currentTableId }, + }; + }); + + app.post("/api/admin/donor-search", rl, async (req, reply) => { + if (!gate(req, reply)) return; + if (!app.ctx.donors.enabled) return reply.code(404).send({ error: "donors_unavailable" }); + const q = String(((req.body ?? {}) as { query?: string }).query ?? "").trim(); + if (q.length < 2) return { results: [], query: q }; + try { + const results = await app.ctx.donors.search(q, 40); + return { results, query: q }; + } catch (e: any) { + req.log.error({ err: e }, "admin: donor search failed"); + return reply.code(502).send({ error: "search_failed", detail: e?.message }); + } + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 7895fe1..685e1a7 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -16,6 +16,7 @@ import { installRoutes } from "./routes/install.js"; import { webhookDocRoutes } from "./routes/webhookDoc.js"; import { publicLookupRoutes } from "./routes/publicLookup.js"; import { portalRoutes } from "./routes/portal.js"; +import { adminRoutes } from "./routes/admin.js"; export async function build() { const config = loadConfig(); @@ -40,6 +41,7 @@ export async function build() { await app.register(webhookDocRoutes); await app.register(publicLookupRoutes); await app.register(portalRoutes); + await app.register(adminRoutes); // Serve the exported Expo web build (if present) with SPA fallback. const webDir = config.WEB_DIR ?? join(process.cwd(), "web"); diff --git a/backend/src/services/audit.ts b/backend/src/services/audit.ts index 2782958..c6b3a29 100644 --- a/backend/src/services/audit.ts +++ b/backend/src/services/audit.ts @@ -34,7 +34,7 @@ export interface AuditRow extends AuditEntry { export class AuditLogger { private readonly base: string; private readonly token: string; - private readonly tableId: string | null; + private tableId: string | null; constructor(cfg: Pick) { this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, ""); @@ -46,10 +46,56 @@ export class AuditLogger { return this.tableId !== null; } + /** The audit table id (switchable at runtime by the admin action). */ + get currentTableId(): string | null { + return this.tableId; + } + setTableId(id: string | null): void { + this.tableId = id || null; + } + private get url(): string { return `${this.base}/api/v2/tables/${this.tableId}/records`; } + /** Total audit row count (cheap — reads pageInfo). */ + async count(): Promise { + if (!this.tableId) return 0; + const url = new URL(this.url); + url.searchParams.set("limit", "1"); + const res = await fetch(url.toString(), { + headers: { "xc-token": this.token, "Content-Type": "application/json" }, + }); + if (!res.ok) return 0; + const body: any = await res.json().catch(() => ({})); + return body?.pageInfo?.totalRows ?? (body?.list?.length ?? 0); + } + + /** Delete every audit row in the current table. Returns the count deleted. */ + async deleteAll(): Promise { + if (!this.tableId) return 0; + let total = 0; + for (;;) { + const url = new URL(this.url); + url.searchParams.set("limit", "1000"); + url.searchParams.set("fields", "Id"); + 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 ?? []; + if (!list.length) break; + await fetch(this.url, { + method: "DELETE", + headers: { "xc-token": this.token, "Content-Type": "application/json" }, + body: JSON.stringify(list.map((r: any) => ({ Id: r.Id }))), + }); + total += list.length; + } + return total; + } + async log(entry: AuditEntry): Promise { if (!this.tableId) return; const sign = entry.people >= 0 ? "+" : ""; diff --git a/backend/src/services/donors.ts b/backend/src/services/donors.ts index 15909f0..46e5e53 100644 --- a/backend/src/services/donors.ts +++ b/backend/src/services/donors.ts @@ -1,5 +1,17 @@ import type { Config } from "../config.js"; +export interface DonorSearchResult { + name: string; + bearName: string; + email: string; + altEmail: string; + phone: string; + address: string; + lifetime: number | null; + tags: string[]; + source: "master" | "transactions"; +} + export interface DonorLookup { found: boolean; email: string; @@ -157,6 +169,67 @@ export class DonorService { }; } + /** + * Admin-only free-text donor search across the master list + transaction + * tables. Matches the query (substring, case-insensitive) against any + * name / email / phone / address / bear-name column each table exposes — + * columns are discovered from a sample row so it adapts to the schema. + * Results are de-duped by email (then name). PRIVACY: gate this to admins. + */ + async search(rawQuery: string, limit = 40): Promise { + const q = rawQuery.trim(); + if (!q || !this.enabled) return []; + const tables: { id: string | null; source: "master" | "transactions" }[] = [ + { id: this.masterId, source: "master" }, + { id: this.onlineId, source: "transactions" }, + { id: this.offlineId, source: "transactions" }, + ]; + const out = new Map(); + for (const t of tables) { + if (!t.id || out.size >= limit) continue; + let rows: any[]; + try { + rows = await this.searchTable(t.id, q, limit); + } catch { + continue; // a table without matching columns / transient error — skip + } + for (const r of rows) { + const res = mapDonorRow(r, t.source); + const key = (res.email || res.name || JSON.stringify(r)).toLowerCase(); + const existing = out.get(key); + // Prefer the master-list record (richer) when the same donor appears twice. + if (!existing || (existing.source === "transactions" && res.source === "master")) { + out.set(key, existing ? { ...res, lifetime: res.lifetime ?? existing.lifetime } : res); + } + if (out.size >= limit) break; + } + } + return [...out.values()].slice(0, limit); + } + + private colCache = new Map(); + + /** Discover the text columns worth searching (name/contact) from a sample row. */ + private async searchableColumns(tableId: string): Promise { + const cached = this.colCache.get(tableId); + if (cached) return cached; + const sample = await this.list(tableId, "", 1); + const keys = sample.length ? Object.keys(sample[0]) : []; + const want = /name|email|phone|mobile|cell|address|street|city|state|zip|postal|province|country|bear/i; + const skip = /[(),]/; // field names with filter-grammar chars can't be queried + const cols = keys.filter((k) => want.test(k) && !skip.test(k)); + this.colCache.set(tableId, cols); + return cols; + } + + private async searchTable(tableId: string, q: string, limit: number): Promise { + const cols = await this.searchableColumns(tableId); + if (!cols.length) return []; + const esc = q.replace(/[(),]/g, " "); + const where = cols.map((c) => `(${c},like,%${esc}%)`).join("~or"); + return this.list(tableId, where, limit); + } + /** * Total Paid donations for an email on/after `cutoff`, summed from the * transaction tables (the only dated source). Used for ticket-voucher @@ -183,6 +256,40 @@ function num(v: unknown): number { return Number.isFinite(n) ? n : 0; } +/** First non-empty value whose column name matches `rx`. */ +function pick(row: any, rx: RegExp): string { + for (const k of Object.keys(row)) if (rx.test(k) && row[k] != null && row[k] !== "") return String(row[k]); + return ""; +} +/** Join all non-empty values whose column name matches `rx` (e.g. address parts). */ +function pickAll(row: any, rx: RegExp): string { + const parts: string[] = []; + for (const k of Object.keys(row)) if (rx.test(k) && row[k] != null && row[k] !== "") parts.push(String(row[k])); + return [...new Set(parts)].join(", "); +} + +function mapDonorRow(r: any, source: "master" | "transactions"): DonorSearchResult { + const name = + r["Display Name"] || + r["Name"] || + [r["First Name"], r["Last Name"]].filter(Boolean).join(" ") || + r["Bear Name"] || + pick(r, /name/i) || + ""; + const lifetimeRaw = r["Total Donations"]; + return { + name: String(name), + bearName: String(r["Bear Name"] ?? ""), + email: String(r["Email"] ?? pick(r, /email/i)), + altEmail: String(r["Alternate Email"] ?? ""), + phone: pick(r, /phone|mobile|cell/i), + address: pickAll(r, /address|street|city|state|zip|postal|province|country/i), + lifetime: lifetimeRaw !== undefined && lifetimeRaw !== null && lifetimeRaw !== "" ? num(lifetimeRaw) : null, + tags: splitTags(r["Tags"]), + source, + }; +} + // Count a transaction unless it's explicitly not paid (refunded/failed/pending). function isPaid(row: any): boolean { const s = String(row["Payment Status"] ?? "").trim(); diff --git a/backend/src/services/nocodb.ts b/backend/src/services/nocodb.ts index f26f452..9e3416e 100644 --- a/backend/src/services/nocodb.ts +++ b/backend/src/services/nocodb.ts @@ -8,16 +8,24 @@ import { COL, type NocoRecord } from "../fields.js"; export class NocoDBClient { private readonly base: string; private readonly token: string; - private readonly tableId: string; + private _tableId: string; constructor(cfg: Pick) { this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, ""); this.token = cfg.NOCODB_API_TOKEN; - this.tableId = cfg.NOCODB_TABLE_ID; + this._tableId = cfg.NOCODB_TABLE_ID; + } + + /** The table this client currently reads/writes (switchable at runtime). */ + get tableId(): string { + return this._tableId; + } + setTableId(id: string): void { + this._tableId = id; } private get recordsUrl(): string { - return `${this.base}/api/v2/tables/${this.tableId}/records`; + return `${this.base}/api/v2/tables/${this._tableId}/records`; } private async request(url: string, init: RequestInit = {}): Promise { @@ -138,6 +146,47 @@ export class NocoDBClient { return out; } + /** Total record count in the current table (cheap — reads pageInfo). */ + async count(): Promise { + const url = new URL(this.recordsUrl); + url.searchParams.set("limit", "1"); + const body = await this.request(url.toString()); + return body?.pageInfo?.totalRows ?? (body?.list?.length ?? 0); + } + + /** Delete every record in the current table (paginated bulk delete). Returns + * the number deleted. Used by the admin "wipe slate" action. */ + async deleteAll(): Promise { + let total = 0; + for (;;) { + const rows = await this.list("", 1000); + if (!rows.length) break; + const ids = rows.map((r) => ({ Id: (r as any).Id })); + await this.request(this.recordsUrl, { method: "DELETE", body: JSON.stringify(ids) }); + total += rows.length; + } + return total; + } + + /** Reachability + primary-key probe for a candidate table id (admin switch). + * Returns { ok, hasIdPk }. hasIdPk is false only if rows exist without an Id. */ + async probeTable(tableId: string): Promise<{ ok: boolean; hasIdPk: boolean; status: number }> { + const url = new URL(`${this.base}/api/v2/tables/${tableId}/records`); + url.searchParams.set("limit", "1"); + try { + const res = await fetch(url.toString(), { + headers: { "xc-token": this.token, "Content-Type": "application/json" }, + }); + if (!res.ok) return { ok: false, hasIdPk: false, status: res.status }; + const body: any = await res.json().catch(() => ({})); + const list = body?.list ?? []; + const hasIdPk = list.length === 0 || "Id" in list[0]; + return { ok: true, hasIdPk, status: 200 }; + } catch { + return { ok: false, hasIdPk: false, status: 0 }; + } + } + /** Cheap connectivity probe for healthchecks. */ async ping(): Promise { const url = new URL(this.recordsUrl); diff --git a/backend/src/services/state.ts b/backend/src/services/state.ts new file mode 100644 index 0000000..b2cde2a --- /dev/null +++ b/backend/src/services/state.ts @@ -0,0 +1,32 @@ +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Tiny persisted state, stored as JSON on a mounted volume (STATE_DIR). Used for + * the admin "switch event table" action so the choice survives a redeploy — + * otherwise the app would revert to the .env table IDs on every restart. + */ +export interface ActiveTables { + ticketsTableId: string; + auditTableId?: string; +} + +const FILE = "active-tables.json"; + +export function loadActiveTables(dir: string): ActiveTables | null { + try { + const raw = readFileSync(join(dir, FILE), "utf8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.ticketsTableId === "string" && parsed.ticketsTableId) { + return { ticketsTableId: parsed.ticketsTableId, auditTableId: parsed.auditTableId || undefined }; + } + } catch { + // No override or unreadable — fall back to .env config. + } + return null; +} + +export function saveActiveTables(dir: string, tables: ActiveTables): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, FILE), JSON.stringify(tables, null, 2), "utf8"); +} diff --git a/docker-compose.yml b/docker-compose.yml index 3668405..dc62d8d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,4 +19,11 @@ services: # host.docker.internal resolves to the host gateway. extra_hosts: - "host.docker.internal:host-gateway" + # Small writable volume for runtime state (the active event-table override + # set from the admin area), so it survives redeploys. + volumes: + - camptickets-data:/data restart: unless-stopped + +volumes: + camptickets-data: diff --git a/scripts/switch-event.sh b/scripts/switch-event.sh new file mode 100755 index 0000000..862ad54 --- /dev/null +++ b/scripts/switch-event.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail +# +# switch-event.sh — point the scanner app at a DIFFERENT NocoDB tickets (and +# optionally audit) table, e.g. to start a NEW event on a fresh table while +# keeping the old table intact for archive. Backs up backend/.env, updates it, +# and restarts the app container. The old table is never touched. +# +# Usage: +# scripts/switch-event.sh [AUDIT_TABLE_ID] +# +# FIRST create the new table(s): in the NocoDB UI, DUPLICATE the current table +# with "structure only" (no records). That preserves every column AND the Id +# primary key — critical, because updates against a table with no primary key +# would hit every row. Then grab the new table id from its URL/API and pass it +# here. (The app also fail-safes: it refuses to update a row that has no Id.) +# +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +ENV_FILE="$ROOT/backend/.env" +CONTAINER="${CONTAINER:-camptickets}" + +NEW_TICKETS="${1:-}" +NEW_AUDIT="${2:-}" +[ -n "$NEW_TICKETS" ] || { echo "Usage: $0 [AUDIT_TABLE_ID]" >&2; exit 1; } + +get() { grep -E "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2-; } +BASE_URL="$(get NOCODB_BASE_URL)" +TOKEN="$(get NOCODB_API_TOKEN)" +CUR_TICKETS="$(get NOCODB_TABLE_ID)" +CUR_AUDIT="$(get NOCODB_AUDIT_TABLE_ID)" + +# Validate a table is reachable and (if it has rows) exposes an Id primary key. +check() { + local table="$1" tmp http + tmp="$(mktemp)" + http="$(curl -s -o "$tmp" -w '%{http_code}' -H "xc-token: $TOKEN" \ + "$BASE_URL/api/v2/tables/$table/records?limit=1")" + if [ "$http" != "200" ]; then + echo " ✗ $table not reachable (HTTP $http)"; rm -f "$tmp"; return 1 + fi + if ! python3 -c 'import sys,json; l=json.load(open(sys.argv[1]))["list"]; sys.exit(0 if (not l or "Id" in l[0]) else 1)' "$tmp"; then + echo " ✗ $table has rows without an Id primary key — refusing"; rm -f "$tmp"; return 1 + fi + rm -f "$tmp"; echo " ✓ $table reachable" +} + +echo "Validating new table(s) on $BASE_URL ..." +check "$NEW_TICKETS" || exit 1 +[ -n "$NEW_AUDIT" ] && { check "$NEW_AUDIT" || exit 1; } + +BK="$ENV_FILE.bak.$(date +%Y%m%d-%H%M%S)" +cp "$ENV_FILE" "$BK" +echo "Backed up env -> $BK" + +echo "Switching tables:" +echo " tickets: $CUR_TICKETS -> $NEW_TICKETS" +sed -i -E "s|^NOCODB_TABLE_ID=.*|NOCODB_TABLE_ID=$NEW_TICKETS|" "$ENV_FILE" +if [ -n "$NEW_AUDIT" ]; then + echo " audit: $CUR_AUDIT -> $NEW_AUDIT" + sed -i -E "s|^NOCODB_AUDIT_TABLE_ID=.*|NOCODB_AUDIT_TABLE_ID=$NEW_AUDIT|" "$ENV_FILE" +else + echo " audit: unchanged ($CUR_AUDIT) — pass a second arg to switch it too" +fi + +echo "Restarting $CONTAINER ..." +( cd "$ROOT" && docker compose up -d --force-recreate >/dev/null ) +sleep 3 + +echo "Now active:" +echo " NOCODB_TABLE_ID=$(get NOCODB_TABLE_ID)" +echo " NOCODB_AUDIT_TABLE_ID=$(get NOCODB_AUDIT_TABLE_ID)" +echo "Old tickets table $CUR_TICKETS kept intact. (env backup: $BK)" diff --git a/scripts/wipe-slate.sh b/scripts/wipe-slate.sh new file mode 100755 index 0000000..58fc3a8 --- /dev/null +++ b/scripts/wipe-slate.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail +# +# wipe-slate.sh — clear ALL ticket + audit records from the tables the scanner +# app currently uses, for a clean event run-through. Leaves the table SCHEMAS +# intact and does NOT touch donor data. Reads NocoDB creds from backend/.env. +# +# Usage: +# scripts/wipe-slate.sh # prompts for confirmation +# scripts/wipe-slate.sh --yes # skip the prompt (for automation) +# +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/../backend/.env}" + +get() { grep -E "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2-; } +BASE_URL="$(get NOCODB_BASE_URL)" +TOKEN="$(get NOCODB_API_TOKEN)" +TICKETS="$(get NOCODB_TABLE_ID)" +AUDIT="$(get NOCODB_AUDIT_TABLE_ID)" + +[ -n "$BASE_URL" ] && [ -n "$TOKEN" ] && [ -n "$TICKETS" ] || { + echo "Missing NocoDB config in $ENV_FILE" >&2; exit 1; } + +YES=0 +case "${1:-}" in -y|--yes) YES=1;; esac + +count() { + curl -s -H "xc-token: $TOKEN" "$BASE_URL/api/v2/tables/$1/records?limit=1" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin).get("pageInfo",{}).get("totalRows",0))' +} + +echo "Target: $BASE_URL" +echo " tickets ($TICKETS): $(count "$TICKETS") records" +[ -n "$AUDIT" ] && echo " audit ($AUDIT): $(count "$AUDIT") records" + +if [ "$YES" -ne 1 ]; then + read -rp "Delete ALL of the above? This cannot be undone. [y/N] " ans + case "$ans" in y|Y|yes|YES) ;; *) echo "aborted"; exit 1;; esac +fi + +wipe() { + local label="$1" table="$2" total=0 ids n + while :; do + ids="$(curl -s -H "xc-token: $TOKEN" "$BASE_URL/api/v2/tables/$table/records?limit=1000&fields=Id" \ + | python3 -c 'import sys,json;print(json.dumps([{"Id":r["Id"]} for r in json.load(sys.stdin)["list"]]))')" + n="$(printf '%s' "$ids" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')" + [ "$n" -eq 0 ] && break + curl -s -o /dev/null -X DELETE -H "xc-token: $TOKEN" -H "Content-Type: application/json" \ + "$BASE_URL/api/v2/tables/$table/records" --data "$ids" + total=$((total + n)) + done + echo " $label: deleted $total" +} + +wipe "tickets" "$TICKETS" +[ -n "$AUDIT" ] && wipe "audit" "$AUDIT" +echo "Done — slate is clean."