From 3a3119e3240d0b33173b3e9ef8a58c802f973628 Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 23 Jul 2026 01:29:10 +0000 Subject: [PATCH 1/4] Admin hub in /crush33: sidebar, donor lookup, danger-zone actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt the password-gated /crush33 (in-app /comp) screen into an admin hub with a left sidebar and three sections: - Comp tickets — the existing entry-only comp creator. - Donor lookup — admin-only free-text search across the donor master list + online/offline transaction tables by name / email / phone / address / bear name (columns discovered per table, deduped by email). - Actions (danger zone) — heavy warnings, red buttons, and an "are you sure" modal that spells out exactly what will happen: • Wipe slate — delete ALL ticket + audit records in the active event table (donor data untouched, irreversible). • Switch event table — repoint the app at a different NocoDB tickets/audit table to start a new event while keeping the old one intact. Backend: - New /api/admin/{status,wipe,switch-table,donor-search}, all gated by PORTAL_PASSWORD (POST-only so it never lands in a URL/log). - NocoDBClient + AuditLogger: runtime-switchable tableId, count(), deleteAll(), probeTable() (reachable + Id-PK check before switching). - DonorService.search() with adaptive column discovery. - Table switch persists across redeploys via a small state file on a new /data volume (Dockerfile creates it owned by node so it's writable); applied at startup in buildContext. Also shipped equivalent CLI scripts: scripts/wipe-slate.sh and scripts/switch-event.sh. Drawer: "Comp tickets" -> "Admin (crush33)". Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 7 +- app/app/comp.tsx | 630 ++++++++++++++++++++++++--------- app/components/SideMenu.tsx | 4 +- app/lib/api.ts | 50 +++ backend/src/config.ts | 4 + backend/src/context.ts | 16 +- backend/src/routes/admin.ts | 122 +++++++ backend/src/server.ts | 2 + backend/src/services/audit.ts | 48 ++- backend/src/services/donors.ts | 107 ++++++ backend/src/services/nocodb.ts | 55 ++- backend/src/services/state.ts | 32 ++ docker-compose.yml | 7 + scripts/switch-event.sh | 73 ++++ scripts/wipe-slate.sh | 57 +++ 15 files changed, 1042 insertions(+), 172 deletions(-) create mode 100644 backend/src/routes/admin.ts create mode 100644 backend/src/services/state.ts create mode 100755 scripts/switch-event.sh create mode 100755 scripts/wipe-slate.sh 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." From 1c8cb47209fc2694350b6fca424d73a0ddfb7e1f Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 23 Jul 2026 01:49:15 +0000 Subject: [PATCH 2/4] Move admin hub into the /crush33 page; drop the /comp app route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin area belongs at /crush33 (the standalone, portal-password page — no app login), not an in-app /comp route I'd added unasked. - Rebuilt the /crush33 page into the full hub: password unlock → sidebar (Comp tickets · Donor lookup · Actions). Vanilla JS calling the same /api/portal + /api/admin endpoints. Actions has the danger cards + an "are you sure" modal spelling out exactly what happens. - Deleted app/app/comp.tsx (removes the /comp route). - Drawer "Admin (crush33)" now opens the /crush33 web page (Linking) instead of routing to /comp. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/comp.tsx | 545 ----------------------------------- app/components/SideMenu.tsx | 15 +- backend/src/routes/portal.ts | 381 ++++++++++++++++++++---- 3 files changed, 328 insertions(+), 613 deletions(-) delete mode 100644 app/app/comp.tsx diff --git a/app/app/comp.tsx b/app/app/comp.tsx deleted file mode 100644 index 648af1e..0000000 --- a/app/app/comp.tsx +++ /dev/null @@ -1,545 +0,0 @@ -import { useState, useCallback, useEffect } from "react"; -import { - StyleSheet, - View, - Text, - TextInput, - Pressable, - ScrollView, - Image, - KeyboardAvoidingView, - Platform, - ActivityIndicator, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -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"; - -const TYPES = ["Guest", "Worker", "Performer", "Volunteer", "Speaker"]; -const TYPE_ICON: Record = { - Guest: "🎫", - Worker: "🛠️", - Performer: "🎭", - Volunteer: "🙌", - Speaker: "🎤", -}; - -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 relock = useCallback(() => { - setUnlocked(false); - setError("Password changed — unlock again."); - }, []); - - async function unlock() { - if (!password || busy) return; - setBusy(true); - setError(""); - try { - await portalVerify(password); - setUnlocked(true); - } catch (e: any) { - setError(e instanceof AuthError ? "Wrong password" : e?.message ?? "Failed"); - } finally { - setBusy(false); - } - } - - return ( - - - - - - Admin · crush33 - - - - {!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} - - ); - })} - - - - - {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 }, - brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, - hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" }, - - 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: 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" }, - 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: 13, paddingVertical: 8 }, - typePillOn: { backgroundColor: theme.primary, borderColor: theme.primary }, - 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: 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 890027b..a91600e 100644 --- a/app/components/SideMenu.tsx +++ b/app/components/SideMenu.tsx @@ -1,13 +1,16 @@ import { useEffect, useRef } from "react"; -import { Animated, StyleSheet, View, Text, Pressable, Easing, useWindowDimensions } from "react-native"; +import { Animated, StyleSheet, View, Text, Pressable, Easing, useWindowDimensions, Linking, Platform } from "react-native"; import { router, useSegments } from "expo-router"; import { useAuth } from "../lib/auth"; import { theme } from "../lib/theme"; -const ITEMS: { label: string; icon: string; route: string; seg: string }[] = [ +// The admin hub is the standalone /crush33 web page (not an app route). +const CRUSH_URL = Platform.OS === "web" ? "/crush33" : "https://scan.beartariacampgrounds.com/crush33"; + +const ITEMS: { label: string; icon: string; route: string; seg: string; external?: string }[] = [ { label: "Scanner", icon: "📷", route: "/", seg: "" }, { label: "Event report", icon: "📊", route: "/stats", seg: "stats" }, - { label: "Admin (crush33)", icon: "🔐", route: "/comp", seg: "comp" }, + { label: "Admin (crush33)", icon: "🔐", route: "", seg: "__admin", external: CRUSH_URL }, { label: "Banquet lookup", icon: "🍽️", route: "/admin", seg: "admin" }, ]; @@ -32,8 +35,12 @@ export default function SideMenu({ visible, onClose }: { visible: boolean; onClo ]).start(); }, [visible, panelW, tx, fade]); - const go = (item: { route: string; seg: string }) => { + const go = (item: { route: string; seg: string; external?: string }) => { onClose(); + if (item.external) { + Linking.openURL(item.external).catch(() => {}); + return; + } if (item.seg !== current) router.replace(item.route as any); }; diff --git a/backend/src/routes/portal.ts b/backend/src/routes/portal.ts index 8c55c0e..b6efcba 100644 --- a/backend/src/routes/portal.ts +++ b/backend/src/routes/portal.ts @@ -103,92 +103,345 @@ const PAGE = ` -Camp Scan — Comp Tickets +Camp Scan — Admin (crush33) -
-
- -

Comp Ticket Portal

-

Entry-only tickets for workers & guests

-
+
+ +

Admin · crush33

+

Admin-only area. Enter the shared portal password.

+ + +
+
- - +
+
+
🐻 Admin · crush33
+
Lock 🔒
+
+
+
+ + + +
+
+ +
+

Comp tickets

+

Entry-only tickets for guests & staff.

+ +
+ 🎫 Guest🛠️ Worker🎭 Performer🙌 Volunteer🎤 Speaker +
+ + + + + +
+
+ Ticket QR +
+
+
+
+
- - + +
+

Donor lookup

+

🔒 Admin only · private donor info. Search by name, email, phone, address, bear name…

+
+ + +
+
+
+
- - + +
+

Actions

+

Event-management tools. These change live data — read the warnings.

+
+
Active event table
+
loading…
+
+
- - +
+

🧹 Wipe the slate clean

+

Permanently deletes every ticket and every check-in in the active event table. Use before a run-through or a fresh event.

+
  • Does NOT affect donor data.
  • Cannot be undone.
+ +
- -
+
+

🔀 Switch event table

+

Point the scanner at a different NocoDB table — 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.
+ + + + + +
+
+
+
+
-
- Ticket QR -
-
-
- +
+
`; From efe331a77ace4f6c8c6b84a36e3da4f5ea95aa84 Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 23 Jul 2026 01:58:08 +0000 Subject: [PATCH 3/4] Hide /crush33 from the staff drawer (admin-only URL) The admin hub link was showing in the app side menu; removed it so it isn't surfaced to gate staff. /crush33 is reached by URL only. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/components/SideMenu.tsx | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/app/components/SideMenu.tsx b/app/components/SideMenu.tsx index a91600e..db0ca3c 100644 --- a/app/components/SideMenu.tsx +++ b/app/components/SideMenu.tsx @@ -1,16 +1,14 @@ import { useEffect, useRef } from "react"; -import { Animated, StyleSheet, View, Text, Pressable, Easing, useWindowDimensions, Linking, Platform } from "react-native"; +import { Animated, StyleSheet, View, Text, Pressable, Easing, useWindowDimensions } from "react-native"; import { router, useSegments } from "expo-router"; import { useAuth } from "../lib/auth"; import { theme } from "../lib/theme"; -// The admin hub is the standalone /crush33 web page (not an app route). -const CRUSH_URL = Platform.OS === "web" ? "/crush33" : "https://scan.beartariacampgrounds.com/crush33"; - -const ITEMS: { label: string; icon: string; route: string; seg: string; external?: string }[] = [ +// Note: the /crush33 admin hub is intentionally NOT listed here — it's an +// admin-only URL, not surfaced to gate staff in the app drawer. +const ITEMS: { label: string; icon: string; route: string; seg: string }[] = [ { label: "Scanner", icon: "📷", route: "/", seg: "" }, { label: "Event report", icon: "📊", route: "/stats", seg: "stats" }, - { label: "Admin (crush33)", icon: "🔐", route: "", seg: "__admin", external: CRUSH_URL }, { label: "Banquet lookup", icon: "🍽️", route: "/admin", seg: "admin" }, ]; @@ -35,12 +33,8 @@ export default function SideMenu({ visible, onClose }: { visible: boolean; onClo ]).start(); }, [visible, panelW, tx, fade]); - const go = (item: { route: string; seg: string; external?: string }) => { + const go = (item: { route: string; seg: string }) => { onClose(); - if (item.external) { - Linking.openURL(item.external).catch(() => {}); - return; - } if (item.seg !== current) router.replace(item.route as any); }; From bc93ef43f7277c5bc234b7c780b46332ff2dd70a Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 23 Jul 2026 02:01:30 +0000 Subject: [PATCH 4/4] crush33: back-to-scanner links + release v0.3.0 (versionCode 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added "← Back to the scan app" on the crush33 unlock screen and a "← Scanner" link in the admin hub top bar (both -> /). v0.3.0 rolls up everything since v0.2.0: the /crush33 admin hub (sidebar, admin-only donor lookup, wipe/switch danger zone with confirm modals), removal of the /comp route, drawer no longer shows crush33, the customer_name / voucher-count / ticketless-order webhook fixes, ice bag fix, and the Adults/Youth/Kids gate panel. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app.json | 4 ++-- backend/src/routes/portal.ts | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/app.json b/app/app.json index ecfabbc..146efd3 100644 --- a/app/app.json +++ b/app/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Camp Scan", "slug": "camptickets", - "version": "0.2.0", + "version": "0.3.0", "orientation": "portrait", "scheme": "campscan", "userInterfaceStyle": "automatic", @@ -10,7 +10,7 @@ "icon": "./assets/icon.png", "android": { "package": "top.mowden.campscan", - "versionCode": 2, + "versionCode": 3, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#0f1a12" diff --git a/backend/src/routes/portal.ts b/backend/src/routes/portal.ts index b6efcba..e8f9d75 100644 --- a/backend/src/routes/portal.ts +++ b/backend/src/routes/portal.ts @@ -126,6 +126,9 @@ const PAGE = ` #unlock .sub { color: #9db3a4; font-size: 14px; } #unlock input { text-align: center; margin-top: 18px; } #unlock .btn { width: 100%; margin-top: 16px; } + .backlink { display: inline-block; margin-top: 18px; color: #9db3a4; font-size: 14px; text-decoration: none; } + .backlink:hover { color: #eaf2ec; } + .top-back { margin-top: 0; } /* Hub */ #hub { display: none; min-height: 100vh; } @@ -194,10 +197,12 @@ const PAGE = `
+ ← Back to the scan app
+ ← Scanner
🐻 Admin · crush33
Lock 🔒