Admin hub in /crush33: sidebar, donor lookup, danger-zone actions

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) <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-23 01:29:10 +00:00
parent 60f0908299
commit 3a3119e324
15 changed files with 1042 additions and 172 deletions

View file

@ -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

View file

@ -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<string, string> = {
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<Section>("comp");
const [type, setType] = useState("Guest");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [result, setResult] = useState<PortalTicket | null>(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,15 +78,14 @@ export default function CompScreen() {
<Pressable onPress={openMenu} hitSlop={12}>
<Text style={styles.hamburger}></Text>
</Pressable>
<Text style={styles.brand}>Comp Tickets</Text>
<Text style={styles.brand}>Admin · crush33</Text>
<View style={{ width: 60 }} />
</View>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<ScrollView contentContainerStyle={{ padding: 20, paddingBottom: 48 }}>
{!unlocked ? (
<View>
<Text style={styles.lead}>Entry-only tickets for workers &amp; guests. Enter the shared portal password.</Text>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<ScrollView contentContainerStyle={{ padding: 20 }}>
<Text style={styles.lead}>Admin-only area. Enter the shared portal password to unlock.</Text>
<Text style={styles.label}>Portal password</Text>
<TextInput
style={styles.input}
@ -106,34 +103,78 @@ export default function CompScreen() {
<Pressable style={[styles.btn, (busy || !password) && styles.btnOff]} onPress={unlock} disabled={busy || !password}>
<Text style={styles.btnText}>{busy ? "Checking…" : "Unlock"}</Text>
</Pressable>
</View>
</ScrollView>
</KeyboardAvoidingView>
) : (
<View>
<View style={styles.body}>
<View style={styles.sidebar}>
{NAV.map((n) => {
const active = section === n.key;
return (
<Pressable key={n.key} style={[styles.navItem, active && styles.navItemOn]} onPress={() => setSection(n.key)}>
<Text style={styles.navIcon}>{n.icon}</Text>
<Text style={[styles.navLabel, active && styles.navLabelOn]}>{n.label}</Text>
</Pressable>
);
})}
</View>
<View style={styles.content}>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined}>
{section === "comp" && <CompSection password={password} operator={operator} onRelock={relock} />}
{section === "donors" && <DonorSection password={password} onRelock={relock} />}
{section === "actions" && <ActionsSection password={password} onRelock={relock} />}
</KeyboardAvoidingView>
</View>
</View>
)}
</SafeAreaView>
);
}
/* ---------------- 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<PortalTicket | null>(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 (
<ScrollView contentContainerStyle={styles.pad}>
<Text style={styles.h1}>Comp tickets</Text>
<Text style={styles.sub}>Entry-only tickets for guests &amp; staff.</Text>
<Text style={styles.label}>Ticket type</Text>
<View style={styles.types}>
{TYPES.map((t) => (
<Pressable
key={t}
style={[styles.typePill, type === t && styles.typePillOn]}
onPress={() => setType(t)}
>
<Text style={[styles.typePillText, type === t && styles.typePillTextOn]}>
{(TYPE_ICON[t] ?? "🎫") + " " + t}
</Text>
<Pressable key={t} style={[styles.typePill, type === t && styles.typePillOn]} onPress={() => setType(t)}>
<Text style={[styles.typePillText, type === t && styles.typePillTextOn]}>{(TYPE_ICON[t] ?? "🎫") + " " + t}</Text>
</Pressable>
))}
</View>
<Text style={styles.label}>Full name</Text>
<TextInput
style={styles.input}
value={name}
onChangeText={setName}
placeholder="Attendee name"
placeholderTextColor={theme.textDim}
autoCapitalize="words"
/>
<TextInput style={styles.input} value={name} onChangeText={setName} placeholder="Attendee name" placeholderTextColor={theme.textDim} autoCapitalize="words" />
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
@ -147,11 +188,7 @@ export default function CompScreen() {
/>
{!!error && <Text style={styles.error}>{error}</Text>}
<Pressable
style={[styles.btn, (busy || !name.trim() || !email.trim()) && styles.btnOff]}
onPress={create}
disabled={busy || !name.trim() || !email.trim()}
>
<Pressable style={[styles.btn, (busy || !name.trim() || !email.trim()) && styles.btnOff]} onPress={create} disabled={busy || !name.trim() || !email.trim()}>
<Text style={styles.btnText}>{busy ? "Creating…" : `Create ${type} ticket`}</Text>
</Pressable>
@ -159,81 +196,350 @@ export default function CompScreen() {
<View style={styles.result}>
<Image source={{ uri: result.qr }} style={styles.qr} />
<Text style={styles.rcode}>{result.code}</Text>
<Text style={styles.rwho}>
{result.type} · {result.name}
</Text>
<Text style={styles.rmail}>
{result.emailSent ? "✓ Emailed the ticket" : "Email not sent — screenshot this QR"}
</Text>
</View>
)}
<Text style={styles.rwho}>{result.type} · {result.name}</Text>
<Text style={styles.rmail}>{result.emailSent ? "✓ Emailed the ticket" : "Email not sent — screenshot this QR"}</Text>
</View>
)}
</ScrollView>
</KeyboardAvoidingView>
</SafeAreaView>
);
}
/* ---------------- 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<DonorSearchResult[] | null>(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 (
<ScrollView contentContainerStyle={styles.pad} keyboardShouldPersistTaps="handled">
<Text style={styles.h1}>Donor lookup</Text>
<Text style={styles.sub}>🔒 Admin only · private donor info. Search by name, email, phone, address, bear name</Text>
<View style={styles.searchRow}>
<TextInput
style={[styles.input, { flex: 1, marginBottom: 0 }]}
value={query}
onChangeText={setQuery}
placeholder="Search donors…"
placeholderTextColor={theme.textDim}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="search"
onSubmitEditing={run}
/>
<Pressable style={[styles.searchBtn, (busy || query.trim().length < 2) && styles.btnOff]} onPress={run} disabled={busy || query.trim().length < 2}>
<Text style={styles.searchBtnText}>{busy ? "…" : "Search"}</Text>
</Pressable>
</View>
{!!error && <Text style={styles.error}>{error}</Text>}
{results !== null && !busy && results.length === 0 && <Text style={styles.sub}>No donors match {query.trim()}.</Text>}
{results?.map((d, i) => (
<View key={i} style={styles.donorCard}>
<View style={styles.donorHead}>
<Text style={styles.donorName}>{d.name || d.email || "(unnamed)"}</Text>
{d.lifetime != null && <Text style={styles.donorAmt}>{money(d.lifetime)}</Text>}
</View>
{!!d.bearName && <Text style={styles.donorLine}>🐻 {d.bearName}</Text>}
{!!d.email && <Text style={styles.donorLine}> {d.email}</Text>}
{!!d.altEmail && <Text style={styles.donorLine}> {d.altEmail} (alt)</Text>}
{!!d.phone && <Text style={styles.donorLine}>📞 {d.phone}</Text>}
{!!d.address && <Text style={styles.donorLine}>🏠 {d.address}</Text>}
<View style={styles.donorTags}>
<Text style={styles.donorSource}>{d.source === "master" ? "directory" : "transactions"}</Text>
{d.tags.map((t) => (
<Text key={t} style={styles.donorTag}>{t}</Text>
))}
</View>
</View>
))}
</ScrollView>
);
}
/* ---------------- Actions (danger zone) ---------------- */
function ActionsSection({ password, onRelock }: { password: string; onRelock: () => void }) {
const [status, setStatus] = useState<AdminStatus | null>(null);
const [loading, setLoading] = useState(false);
const [msg, setMsg] = useState("");
const [confirm, setConfirm] = useState<null | "wipe" | "switch">(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 (
<ScrollView contentContainerStyle={styles.pad} keyboardShouldPersistTaps="handled">
<Text style={styles.h1}>Actions</Text>
<Text style={styles.sub}>Event-management tools. These change live data read the warnings.</Text>
{/* Current status */}
<View style={styles.statusBox}>
<View style={styles.statusRow}>
<Text style={styles.statusLabel}>Active event table</Text>
<Pressable onPress={refresh} hitSlop={8}>
<Text style={styles.refresh}>{loading ? "…" : "↻"}</Text>
</Pressable>
</View>
{status ? (
<>
<Text style={styles.statusVal}>tickets: {status.tickets.tableId} · {status.tickets.count} records</Text>
<Text style={styles.statusVal}>audit: {status.audit.tableId ?? "—"} · {status.audit.count} records</Text>
</>
) : (
<Text style={styles.statusVal}>{loading ? "loading…" : "—"}</Text>
)}
</View>
{!!msg && <Text style={styles.msg}>{msg}</Text>}
{/* Wipe slate */}
<View style={styles.dangerCard}>
<Text style={styles.dangerTitle}>🧹 Wipe the slate clean</Text>
<Text style={styles.dangerBody}>
Permanently deletes <Text style={styles.bold}>every ticket and every check-in</Text> in the active event
table. Use this to reset before a run-through or a fresh event.
</Text>
<Text style={styles.dangerBullet}> Does NOT affect donor data.</Text>
<Text style={styles.dangerBullet}> Cannot be undone.</Text>
<Pressable style={styles.redBtn} onPress={() => { setMsg(""); setConfirm("wipe"); }}>
<Text style={styles.redBtnText}>Wipe slate</Text>
</Pressable>
</View>
{/* Switch table */}
<View style={styles.dangerCard}>
<Text style={styles.dangerTitle}>🔀 Switch event table</Text>
<Text style={styles.dangerBody}>
Point the scanner at a <Text style={styles.bold}>different NocoDB table</Text> e.g. to start a new event on
a fresh table while keeping the current one intact.
</Text>
<Text style={styles.dangerBullet}> Create the new table first (duplicate the current one's structure in NocoDB keep the Id column).</Text>
<Text style={styles.dangerBullet}> The current event's data is NOT deleted, just no longer shown.</Text>
<Text style={styles.label}>New tickets table ID</Text>
<TextInput style={styles.input} value={newTickets} onChangeText={setNewTickets} placeholder="e.g. mv1a2b3c…" placeholderTextColor={theme.textDim} autoCapitalize="none" autoCorrect={false} />
<Text style={styles.label}>New audit table ID (optional)</Text>
<TextInput style={styles.input} value={newAudit} onChangeText={setNewAudit} placeholder="leave blank to keep current" placeholderTextColor={theme.textDim} autoCapitalize="none" autoCorrect={false} />
<Pressable style={[styles.redBtn, !newTickets.trim() && styles.btnOff]} onPress={() => { setMsg(""); setConfirm("switch"); }} disabled={!newTickets.trim()}>
<Text style={styles.redBtnText}>Switch table</Text>
</Pressable>
</View>
{confirm === "wipe" && (
<ConfirmModal
title="Wipe the slate clean?"
lines={[
`This will PERMANENTLY DELETE all data in the active event table:`,
`${status?.tickets.count ?? "?"} ticket records (${status?.tickets.tableId ?? "?"})`,
`${status?.audit.count ?? "?"} check-in / audit records`,
``,
`Donor data is not touched. This CANNOT be undone.`,
]}
confirmLabel={busy ? "Wiping…" : "Yes, delete everything"}
busy={busy}
onConfirm={doWipe}
onCancel={() => setConfirm(null)}
/>
)}
{confirm === "switch" && (
<ConfirmModal
title="Switch the active event table?"
lines={[
`The scanner will start using:`,
`• tickets → ${newTickets.trim()}`,
newAudit.trim() ? `• audit → ${newAudit.trim()}` : `• audit → unchanged`,
``,
`The current event (${status?.tickets.tableId ?? "?"}, ${status?.tickets.count ?? "?"} records) stays intact but will no longer be shown until you switch back. Purchases and scans will go to the new table.`,
]}
confirmLabel={busy ? "Switching…" : "Yes, switch table"}
busy={busy}
onConfirm={doSwitch}
onCancel={() => setConfirm(null)}
/>
)}
</ScrollView>
);
}
function ConfirmModal({
title,
lines,
confirmLabel,
busy,
onConfirm,
onCancel,
}: {
title: string;
lines: string[];
confirmLabel: string;
busy: boolean;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<View style={styles.modalScrim}>
<View style={styles.modalCard}>
<Text style={styles.modalWarn}></Text>
<Text style={styles.modalTitle}>{title}</Text>
{lines.map((l, i) => (
<Text key={i} style={styles.modalLine}>{l}</Text>
))}
<Pressable style={[styles.redBtn, busy && styles.btnOff]} onPress={onConfirm} disabled={busy}>
{busy ? <ActivityIndicator color="#fff" /> : <Text style={styles.redBtnText}>{confirmLabel}</Text>}
</Pressable>
<Pressable style={styles.cancelBtn} onPress={onCancel} disabled={busy}>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
</View>
</View>
);
}
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" },
});

View file

@ -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 }) {

View file

@ -252,6 +252,56 @@ export async function portalCreate(input: {
return body;
}
// ---- Admin actions (all gated by the portal password) ----
async function adminPost<T>(path: string, password: string, extra: Record<string, unknown> = {}): Promise<T> {
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<AdminStatus> {
return adminPost<AdminStatus>("/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[];

View file

@ -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.

View file

@ -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),
};
}

122
backend/src/routes/admin.ts Normal file
View file

@ -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<void> {
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 });
}
});
}

View file

@ -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");

View file

@ -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<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_AUDIT_TABLE_ID">) {
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<number> {
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<number> {
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<void> {
if (!this.tableId) return;
const sign = entry.people >= 0 ? "+" : "";

View file

@ -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<DonorSearchResult[]> {
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<string, DonorSearchResult>();
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<string, string[]>();
/** Discover the text columns worth searching (name/contact) from a sample row. */
private async searchableColumns(tableId: string): Promise<string[]> {
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<any[]> {
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();

View file

@ -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<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_TABLE_ID">) {
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<any> {
@ -138,6 +146,47 @@ export class NocoDBClient {
return out;
}
/** Total record count in the current table (cheap — reads pageInfo). */
async count(): Promise<number> {
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<number> {
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<boolean> {
const url = new URL(this.recordsUrl);

View file

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

View file

@ -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:

73
scripts/switch-event.sh Executable file
View file

@ -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 <TICKETS_TABLE_ID> [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 <TICKETS_TABLE_ID> [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)"

57
scripts/wipe-slate.sh Executable file
View file

@ -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."