From 43fddec2862938ebfe602d4adf18d8ef2cbb845c Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 13 Jul 2026 17:30:18 +0000 Subject: [PATCH 01/15] Add event report dashboard, slide-out drawer, in-app comp portal + creator tracking - Reporting: GET /api/stats aggregates check-in progress, ice, ticket types, people breakdown, add-ons/donors, gate-crew leaderboard (from audit), comp tickets by creator, and a by-hour check-in timeline. New /stats screen. - Slide-out drawer (custom RN Animated, no new native deps) replaces per-screen header links; available on every main screen via a hamburger. - In-app comp portal (/comp), password-gated like /crush33, reusing the portal endpoints; records the issuing gate-staff name (Created By column) and reports comps per creator. Co-Authored-By: Claude Fable 5 --- app/app/_layout.tsx | 5 +- app/app/admin.tsx | 9 +- app/app/comp.tsx | 239 +++++++++++++++++++++++++ app/app/index.tsx | 24 +-- app/app/stats.tsx | 306 +++++++++++++++++++++++++++++++++ app/components/SideMenu.tsx | 112 ++++++++++++ app/lib/api.ts | 58 +++++++ app/lib/menu.tsx | 21 +++ backend/src/fields.ts | 3 + backend/src/routes/portal.ts | 26 ++- backend/src/routes/tickets.ts | 7 + backend/src/services/audit.ts | 37 ++++ backend/src/services/nocodb.ts | 20 +++ backend/src/services/stats.ts | 131 ++++++++++++++ backend/src/ticketService.ts | 2 + 15 files changed, 979 insertions(+), 21 deletions(-) create mode 100644 app/app/comp.tsx create mode 100644 app/app/stats.tsx create mode 100644 app/components/SideMenu.tsx create mode 100644 app/lib/menu.tsx create mode 100644 backend/src/services/stats.ts diff --git a/app/app/_layout.tsx b/app/app/_layout.tsx index 358a7a4..cfd2a76 100644 --- a/app/app/_layout.tsx +++ b/app/app/_layout.tsx @@ -4,6 +4,7 @@ import { Stack, useRouter, useSegments } from "expo-router"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { StatusBar } from "expo-status-bar"; import { AuthProvider, useAuth } from "../lib/auth"; +import { MenuProvider } from "../lib/menu"; import { theme } from "../lib/theme"; export default function RootLayout() { @@ -11,7 +12,9 @@ export default function RootLayout() { - + + + ); diff --git a/app/app/admin.tsx b/app/app/admin.tsx index e978059..92b4052 100644 --- a/app/app/admin.tsx +++ b/app/app/admin.tsx @@ -4,6 +4,7 @@ import { router } from "expo-router"; import { SafeAreaView } from "react-native-safe-area-context"; import { searchTickets, redeem, getAudit, type TicketView, type AuditEntry } from "../lib/api"; import { feedbackSuccess, feedbackError } from "../lib/feedback"; +import { useMenu } from "../lib/menu"; import { theme } from "../lib/theme"; function fmtTime(iso: string): string { @@ -43,6 +44,7 @@ function AuditList({ entries }: { entries: AuditEntry[] }) { } export default function AdminScreen() { + const { open: openMenu } = useMenu(); const [q, setQ] = useState(""); const [results, setResults] = useState([]); const [busy, setBusy] = useState(false); @@ -119,10 +121,8 @@ export default function AdminScreen() { return ( - router.replace("/")} hitSlop={10}> - - ‹ Scanner - + + Admin lookup @@ -276,6 +276,7 @@ const styles = StyleSheet.create({ 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" }, searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 }, input: { diff --git a/app/app/comp.tsx b/app/app/comp.tsx new file mode 100644 index 0000000..364aac0 --- /dev/null +++ b/app/app/comp.tsx @@ -0,0 +1,239 @@ +import { useState } from "react"; +import { + StyleSheet, + View, + Text, + TextInput, + Pressable, + ScrollView, + Image, + KeyboardAvoidingView, + Platform, +} 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 { 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: "🎤", +}; + +export default function CompScreen() { + 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 [type, setType] = useState("Guest"); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [result, setResult] = useState(null); + + 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); + } + } + + 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"); + } + } finally { + setBusy(false); + } + } + + return ( + + + + + + Comp Tickets + + + + + + {!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} + + + ))} + + + 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"} + + + )} + + )} + + + + ); +} + +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" }, + link: { color: theme.textDim, fontSize: 16, fontWeight: "600", width: 72 }, + 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, + }, + error: { color: theme.dangerBright, marginTop: 12, fontSize: 14, fontWeight: "600" }, + btn: { + backgroundColor: theme.successBright, + borderRadius: 13, + paddingVertical: 15, + alignItems: "center", + marginTop: 20, + }, + 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, + }, + typePillOn: { backgroundColor: theme.primary, borderColor: theme.primary }, + typePillText: { color: theme.textDim, fontSize: 14, 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 }, + 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 }, +}); diff --git a/app/app/index.tsx b/app/app/index.tsx index ab552ff..c3bcdbc 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -6,6 +6,7 @@ import QRScanner from "../components/QRScanner"; import ResultOverlay from "../components/ResultOverlay"; import { lookup, redeem, banquet, type TicketView, type DonorLookup } from "../lib/api"; import { useAuth } from "../lib/auth"; +import { useMenu } from "../lib/menu"; import { feedbackSuccess, feedbackError } from "../lib/feedback"; import { theme } from "../lib/theme"; @@ -19,7 +20,8 @@ const MODES: { key: Mode; label: string; icon: string }[] = [ ]; export default function ScannerScreen() { - const { signOut, operator } = useAuth(); + const { operator } = useAuth(); + const { open: openMenu } = useMenu(); const [mode, setMode] = useState("tickets"); const [phase, setPhase] = useState("scanning"); const [ticket, setTicket] = useState(null); @@ -147,29 +149,19 @@ export default function ScannerScreen() { } }, [ticket, count, mode, resume, showError]); - const doLogout = useCallback(async () => { - await signOut(); - // The auth gate redirects to /login when signedIn flips to false. - }, [signOut]); - const isIce = mode === "ice"; const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : ""; return ( - + + + + 🐻 Camp Scan {!!operator && {operator}} - - router.push("/admin")} hitSlop={10}> - Admin - - - Sign out - - @@ -474,6 +466,8 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingVertical: 10, }, + hamburger: { color: theme.text, fontSize: 26, fontWeight: "700", paddingRight: 4 }, + titleWrap: { flex: 1, marginLeft: 12 }, brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, operator: { color: theme.textDim, fontSize: 13, marginTop: 1 }, topActions: { flexDirection: "row", gap: 18, alignItems: "center" }, diff --git a/app/app/stats.tsx b/app/app/stats.tsx new file mode 100644 index 0000000..9c9428a --- /dev/null +++ b/app/app/stats.tsx @@ -0,0 +1,306 @@ +import { useCallback, useEffect, useState } from "react"; +import { StyleSheet, View, Text, Pressable, ScrollView, ActivityIndicator, RefreshControl } from "react-native"; +import { router } from "expo-router"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { getStats, type Stats } from "../lib/api"; +import { useMenu } from "../lib/menu"; +import { theme } from "../lib/theme"; + +const TYPE_ICON: Record = { + Regular: "🎟️", + Guest: "🎫", + Worker: "🛠️", + Performer: "🎭", + Volunteer: "🙌", + Speaker: "🎤", +}; +const MEDAL = ["🥇", "🥈", "🥉"]; + +function Bar({ pct, color }: { pct: number; color?: string }) { + return ( + + + + ); +} + +function Tile({ value, label, accent }: { value: string | number; label: string; accent?: boolean }) { + return ( + + {value} + {label} + + ); +} + +export default function StatsScreen() { + const { open: openMenu } = useMenu(); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(""); + + const load = useCallback(async (force = false) => { + setError(""); + try { + setStats(await getStats(force)); + } catch (e: any) { + if (e?.name === "AuthError") return router.replace("/login"); + setError(e?.message ?? "Failed to load report"); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + load(); + }, [load]); + + const onRefresh = () => { + setRefreshing(true); + load(true); + }; + + const peakHour = stats?.checkinsByHour.length + ? stats.checkinsByHour.reduce((a, b) => (b.count > a.count ? b : a)) + : null; + const maxHour = stats ? Math.max(1, ...stats.checkinsByHour.map((h) => h.count)) : 1; + + return ( + + + + + + Event Report + + + + + + {loading ? ( + + ) : error ? ( + {error} + ) : stats ? ( + } + > + {/* Hero: check-in progress */} + + {stats.tickets.pct}% + checked in + + + {stats.tickets.redeemed} of {stats.tickets.total} tickets · {stats.tickets.remaining} to go + + + + {/* Core tiles */} + + + + + + + + {/* Ice */} + + 🧊 Ice + + + {stats.ice.redeemed} of {stats.ice.total} bags handed out · {stats.ice.remaining} left · {stats.ice.ticketsSold} ice tickets sold + + + + {/* Ticket types */} + + Ticket types + {stats.types.map((t) => ( + + + {(TYPE_ICON[t.type] ?? "🎫") + " " + t.type} + + + + + + {t.redeemed}/{t.total} + · {t.count}× + + + ))} + + + {/* People breakdown */} + + Who's coming + + + + + + + + + {/* Extras + donors */} + + Add-ons & donors + + 🚗 {stats.extras.carParking} parking + 🚐 {stats.extras.rvParking} RV + 🏍️ {stats.extras.utv} UTV + 🐻 {stats.donors.members} members + ⭐ {stats.donors.orders} donor orders + 🎟️ {stats.donors.vouchers} vouchers + + + + {/* Operator leaderboard */} + {stats.operators.length > 0 && ( + + Gate crew leaderboard + {stats.operators.slice(0, 8).map((o, i) => ( + + {MEDAL[i] ?? `${i + 1}.`} + + {o.name} + + + {o.checkins} check-ins{o.ice ? ` · ${o.ice} ice` : ""} + {o.undos ? ` · ${o.undos} undo` : ""} + + + ))} + + )} + + {/* Comp tickets issued */} + {stats.comps.total > 0 && ( + + 🎟️ Comp tickets issued ({stats.comps.total}) + {stats.comps.byCreator.map((c) => ( + + + {c.name} + + {c.count} issued + + ))} + + )} + + {/* Check-in timeline */} + {stats.checkinsByHour.length > 0 && ( + + Check-ins by hour + + {stats.checkinsByHour.map((h) => ( + + {h.count} + + {h.hour.slice(11)}h + + ))} + + {peakHour && ( + Busiest hour: {peakHour.count} checked in around {peakHour.hour.slice(11)}:00 + )} + + )} + + Updated {new Date(stats.generatedAt).toLocaleTimeString()} · pull to refresh + + ) : null} + + ); +} + +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" }, + link: { color: theme.textDim, fontSize: 16, fontWeight: "700" }, + error: { color: theme.dangerBright, textAlign: "center", marginTop: 40, fontSize: 15 }, + + hero: { + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + borderRadius: 18, + padding: 22, + alignItems: "center", + }, + heroPct: { color: theme.successBright, fontSize: 64, fontWeight: "900", lineHeight: 66 }, + heroSub: { color: theme.textDim, fontSize: 15, marginBottom: 14 }, + heroCounts: { color: theme.text, fontSize: 15, marginTop: 10, textAlign: "center" }, + + barTrack: { width: "100%", height: 12, borderRadius: 6, backgroundColor: theme.cardBorder, overflow: "hidden" }, + barFill: { height: "100%", borderRadius: 6 }, + + tileRow: { flexDirection: "row", flexWrap: "wrap", gap: 10, marginTop: 12 }, + tile: { + flexGrow: 1, + flexBasis: "22%", + minWidth: 74, + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + borderRadius: 12, + paddingVertical: 12, + alignItems: "center", + }, + tileValue: { color: theme.text, fontSize: 24, fontWeight: "800" }, + tileLabel: { color: theme.textDim, fontSize: 11, marginTop: 2, textAlign: "center" }, + + card: { + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + borderRadius: 16, + padding: 16, + marginTop: 14, + }, + cardTitle: { color: theme.text, fontSize: 16, fontWeight: "800", marginBottom: 10 }, + cardSub: { color: theme.textDim, fontSize: 13, marginTop: 8, lineHeight: 18 }, + + typeRow: { flexDirection: "row", alignItems: "center", gap: 10, marginVertical: 5 }, + typeName: { color: theme.text, fontSize: 14, fontWeight: "600", width: 120 }, + typeBarWrap: { flex: 1 }, + typeCount: { color: theme.text, fontSize: 13, fontWeight: "700", minWidth: 66, textAlign: "right" }, + typeOrders: { color: theme.textDim, fontWeight: "400" }, + + chips: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, + chip: { + color: theme.text, + backgroundColor: theme.cardBorder, + borderRadius: 999, + paddingHorizontal: 12, + paddingVertical: 7, + fontSize: 13, + fontWeight: "600", + overflow: "hidden", + }, + + opRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 }, + opRank: { fontSize: 16, width: 28, textAlign: "center", color: theme.textDim, fontWeight: "800" }, + opName: { color: theme.text, fontSize: 15, fontWeight: "600", flex: 1 }, + opStat: { color: theme.textDim, fontSize: 13 }, + + spark: { flexDirection: "row", alignItems: "flex-end", justifyContent: "space-between", gap: 4, height: 118, marginTop: 4 }, + sparkCol: { flex: 1, alignItems: "center", justifyContent: "flex-end" }, + sparkVal: { color: theme.textDim, fontSize: 10, marginBottom: 3 }, + sparkBar: { width: "70%", minWidth: 8, backgroundColor: theme.successBright, borderRadius: 3 }, + sparkLabel: { color: theme.textDim, fontSize: 9, marginTop: 3 }, + + stamp: { color: theme.textDim, fontSize: 12, textAlign: "center", marginTop: 20 }, +}); diff --git a/app/components/SideMenu.tsx b/app/components/SideMenu.tsx new file mode 100644 index 0000000..522b2c3 --- /dev/null +++ b/app/components/SideMenu.tsx @@ -0,0 +1,112 @@ +import { useEffect, useRef } from "react"; +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"; + +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" }, +]; + +export default function SideMenu({ visible, onClose }: { visible: boolean; onClose: () => void }) { + const { operator, signOut } = useAuth(); + const segments = useSegments(); + const current = segments[0] ?? ""; + const { width } = useWindowDimensions(); + const panelW = Math.min(320, width * 0.84); + const tx = useRef(new Animated.Value(-panelW)).current; + const fade = useRef(new Animated.Value(0)).current; + + useEffect(() => { + Animated.parallel([ + Animated.timing(tx, { + toValue: visible ? 0 : -panelW, + duration: 220, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }), + Animated.timing(fade, { toValue: visible ? 1 : 0, duration: 220, useNativeDriver: true }), + ]).start(); + }, [visible, panelW, tx, fade]); + + const go = (item: { route: string; seg: string }) => { + onClose(); + if (item.seg !== current) router.replace(item.route as any); + }; + + return ( + + + + + + + 🐻 Camp Scan + {!!operator && {operator}} + + + {ITEMS.map((it) => { + const active = it.seg === current; + return ( + go(it)}> + {it.icon} + {it.label} + + ); + })} + + + { + onClose(); + signOut(); + }} + > + 🚪 + Sign out + + + + ); +} + +const styles = StyleSheet.create({ + scrim: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "rgba(0,0,0,0.55)" }, + panel: { + position: "absolute", + top: 0, + bottom: 0, + left: 0, + backgroundColor: theme.card, + borderRightWidth: 1, + borderRightColor: theme.cardBorder, + paddingTop: 54, + paddingHorizontal: 14, + paddingBottom: 28, + }, + header: { paddingHorizontal: 8, paddingBottom: 14, borderBottomWidth: 1, borderBottomColor: theme.cardBorder }, + logo: { color: theme.text, fontSize: 20, fontWeight: "800" }, + operator: { color: theme.textDim, fontSize: 14, marginTop: 3 }, + items: { marginTop: 14, gap: 4 }, + item: { flexDirection: "row", alignItems: "center", gap: 14, paddingVertical: 14, paddingHorizontal: 12, borderRadius: 12 }, + itemActive: { backgroundColor: theme.primary }, + itemIcon: { fontSize: 20, width: 26, textAlign: "center" }, + itemText: { color: theme.text, fontSize: 17, fontWeight: "600" }, + itemTextActive: { color: "#fff", fontWeight: "800" }, + spacer: { flex: 1 }, + signout: { + flexDirection: "row", + alignItems: "center", + gap: 14, + paddingVertical: 14, + paddingHorizontal: 12, + borderRadius: 12, + borderTopWidth: 1, + borderTopColor: theme.cardBorder, + }, + signoutText: { color: theme.dangerBright, fontSize: 17, fontWeight: "700" }, +}); diff --git a/app/lib/api.ts b/app/lib/api.ts index 2123da8..ccc02e8 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -22,6 +22,7 @@ export interface TicketView { name: string; email: string; ticketType: string; + createdBy: string; total: number; redeemed: number; remaining: number; @@ -194,6 +195,63 @@ export interface AuditEntry { action: "check-in" | "undo" | "ice" | "ice-undo"; } +export interface Stats { + orders: number; + tickets: { total: number; redeemed: number; remaining: number; pct: number }; + people: { adults: number; youth: number; kids12: number; kids9: number; kids4Free: number }; + ice: { total: number; redeemed: number; remaining: number; pct: number; ticketsSold: number }; + types: { type: string; count: number; total: number; redeemed: number }[]; + donors: { orders: number; members: number; vouchers: number }; + extras: { carParking: number; rvParking: number; utv: number }; + comps: { total: number; byCreator: { name: string; count: number }[] }; + operators: { name: string; checkins: number; ice: number; undos: number }[]; + checkinsByHour: { hour: string; count: number }[]; + generatedAt: string; +} + +export function getStats(force = false): Promise { + return authed(`/api/stats${force ? "?force=1" : ""}`); +} + +// Comp-ticket portal (password-gated; separate from the staff PIN). +export async function portalVerify(password: string): Promise<{ ok: boolean; types: string[] }> { + const res = await fetch(`${API_BASE}/api/portal/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + if (res.status === 401) throw new AuthError("Wrong password"); + if (!res.ok) throw new ApiError(`Verify failed (${res.status})`); + return res.json(); +} + +export interface PortalTicket { + ok: boolean; + code: string; + type: string; + name: string; + emailSent: boolean; + qr: string; // data URL +} + +export async function portalCreate(input: { + password: string; + name: string; + email: string; + type: string; + createdBy?: string; +}): Promise { + const res = await fetch(`${API_BASE}/api/portal/create-ticket`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }); + 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 ?? `Create failed (${res.status})`); + return body; +} + export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{ enabled: boolean; entries: AuditEntry[]; diff --git a/app/lib/menu.tsx b/app/lib/menu.tsx new file mode 100644 index 0000000..356c944 --- /dev/null +++ b/app/lib/menu.tsx @@ -0,0 +1,21 @@ +import { createContext, useContext, useState, type ReactNode } from "react"; +import SideMenu from "../components/SideMenu"; + +interface MenuState { + open: () => void; + close: () => void; +} + +const Ctx = createContext({ open: () => {}, close: () => {} }); + +export function MenuProvider({ children }: { children: ReactNode }) { + const [visible, setVisible] = useState(false); + return ( + setVisible(true), close: () => setVisible(false) }}> + {children} + setVisible(false)} /> + + ); +} + +export const useMenu = () => useContext(Ctx); diff --git a/backend/src/fields.ts b/backend/src/fields.ts index c6b4cc8..50c569d 100644 --- a/backend/src/fields.ts +++ b/backend/src/fields.ts @@ -25,6 +25,7 @@ export const COL = { iceAccess: "Ice Access", paymentMethod: "Payment Method", ticketType: "Ticket Type", // "" for regular; Guest/Worker/Performer/Volunteer/Speaker for portal comps + createdBy: "Created By", // gate-staff name who issued a comp ticket (portal) // Columns this system manages: code: "Ticket Code", @@ -96,6 +97,7 @@ export interface TicketView { name: string; email: string; ticketType: string; // "" for regular; Guest/Worker/... for special tickets + createdBy: string; // who issued a comp ticket total: number; redeemed: number; remaining: number; @@ -124,6 +126,7 @@ export function toView(rec: NocoRecord): TicketView { name: String(rec[COL.name] ?? ""), email: String(rec[COL.email] ?? ""), ticketType: String(rec[COL.ticketType] ?? ""), + createdBy: String(rec[COL.createdBy] ?? ""), total, redeemed, remaining: Math.max(0, total - redeemed), diff --git a/backend/src/routes/portal.ts b/backend/src/routes/portal.ts index 9a1f077..8c55c0e 100644 --- a/backend/src/routes/portal.ts +++ b/backend/src/routes/portal.ts @@ -22,6 +22,21 @@ export async function portalRoutes(app: FastifyInstance): Promise { reply.type("text/html").send(PAGE); }); + // Password check only (for the in-app portal to gate its form). + app.post( + "/api/portal/verify", + { config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, + async (req, reply) => { + const cfg = app.ctx.config; + if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" }); + const b = (req.body ?? {}) as { password?: string }; + if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) { + return reply.code(401).send({ error: "bad_password" }); + } + return { ok: true, types: TYPES }; + }, + ); + app.post( "/api/portal/create-ticket", { config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, @@ -29,13 +44,21 @@ export async function portalRoutes(app: FastifyInstance): Promise { const cfg = app.ctx.config; if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" }); - const b = (req.body ?? {}) as { password?: string; name?: string; email?: string; type?: string }; + const b = (req.body ?? {}) as { + password?: string; + name?: string; + email?: string; + type?: string; + createdBy?: string; + }; if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) { return reply.code(401).send({ error: "bad_password" }); } const name = String(b.name ?? "").trim(); const email = String(b.email ?? "").trim(); const type = TYPES.includes(String(b.type)) ? String(b.type) : "Guest"; + // Who issued it — from the in-app portal (signed-in gate staff) or header. + const createdBy = String(b.createdBy ?? req.headers["x-operator"] ?? "").slice(0, 80).trim(); if (!name || !email) { return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" }); } @@ -47,6 +70,7 @@ export async function portalRoutes(app: FastifyInstance): Promise { adultNames: [name], email, ticketType: type, + createdBy, counts: { adults: 1, youth: 0, kids12: 0, kids9: 0, kids4: 0 }, submissionKey: `portal:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`, }); diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index eaa2a67..eb03880 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { normalizeCode, looksLikeCode } from "../services/code.js"; import { lookupByCode, redeem, search, createTicket } from "../ticketService.js"; import { renderQrPng } from "../services/qrcode.js"; +import { computeStats } from "../services/stats.js"; import { COL } from "../fields.js"; async function requireStaff(req: FastifyRequest, reply: FastifyReply): Promise { @@ -42,6 +43,12 @@ export async function ticketRoutes(app: FastifyInstance): Promise { }, ); + // Aggregate event report (check-in progress, ice, types, extras, operators). + app.get("/api/stats", { preHandler: requireStaff }, async (req) => { + const force = String((req.query as any)?.force ?? "") === "1"; + return computeStats(app.ctx, force); + }); + // Recent check-in audit log (all, or filtered to one code via ?code=). app.get("/api/audit", { preHandler: requireStaff }, async (req) => { const code = (req.query as any)?.code ? normalizeCode(String((req.query as any).code)) : undefined; diff --git a/backend/src/services/audit.ts b/backend/src/services/audit.ts index 05e3b50..2782958 100644 --- a/backend/src/services/audit.ts +++ b/backend/src/services/audit.ts @@ -79,6 +79,43 @@ export class AuditLogger { } } + private mapRow(r: any): AuditRow { + return { + id: r.Id, + code: r[AUDIT_COL.code] ?? "", + people: Number(r[AUDIT_COL.people]) || 0, + name: r[AUDIT_COL.name] ?? "", + operator: r[AUDIT_COL.operator] ?? "", + remainingAfter: Number(r[AUDIT_COL.remainingAfter]) || 0, + at: r[AUDIT_COL.at] ?? r.CreatedAt ?? "", + action: (r[AUDIT_COL.action] ?? "check-in") as AuditEntry["action"], + }; + } + + /** Every audit row, paginated (for reporting/aggregation). */ + async all(): Promise { + if (!this.tableId) return []; + const out: AuditRow[] = []; + const pageSize = 1000; + let offset = 0; + for (;;) { + const url = new URL(this.url); + url.searchParams.set("limit", String(pageSize)); + url.searchParams.set("offset", String(offset)); + 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 ?? []; + out.push(...list.map((r: any) => this.mapRow(r))); + if (!list.length || body?.pageInfo?.isLastPage || list.length < pageSize) break; + offset += pageSize; + if (offset > 200000) break; + } + return out; + } + /** Recent entries, newest first, optionally filtered to one code. */ async recent(opts: { code?: string; limit?: number } = {}): Promise { if (!this.tableId) return []; diff --git a/backend/src/services/nocodb.ts b/backend/src/services/nocodb.ts index 707a70a..a9f8587 100644 --- a/backend/src/services/nocodb.ts +++ b/backend/src/services/nocodb.ts @@ -102,6 +102,26 @@ export class NocoDBClient { return (Array.isArray(body) ? body[0] : body) as NocoRecord; } + /** Fetch every record in the table, paginating. */ + async all(): Promise { + const out: NocoRecord[] = []; + const pageSize = 1000; + let offset = 0; + for (;;) { + const url = new URL(this.recordsUrl); + url.searchParams.set("limit", String(pageSize)); + url.searchParams.set("offset", String(offset)); + const body = await this.request(url.toString()); + const list = (body?.list ?? []) as NocoRecord[]; + out.push(...list); + const info = body?.pageInfo; + if (!list.length || info?.isLastPage || list.length < pageSize) break; + offset += pageSize; + if (offset > 200000) break; // safety + } + return out; + } + /** Cheap connectivity probe for healthchecks. */ async ping(): Promise { const url = new URL(this.recordsUrl); diff --git a/backend/src/services/stats.ts b/backend/src/services/stats.ts new file mode 100644 index 0000000..3e9801f --- /dev/null +++ b/backend/src/services/stats.ts @@ -0,0 +1,131 @@ +import type { AppContext } from "../context.js"; +import { COL, toView, toNumber, type NocoRecord } from "../fields.js"; + +export interface Stats { + orders: number; + tickets: { total: number; redeemed: number; remaining: number; pct: number }; + people: { adults: number; youth: number; kids12: number; kids9: number; kids4Free: number }; + ice: { total: number; redeemed: number; remaining: number; pct: number; ticketsSold: number }; + types: { type: string; count: number; total: number; redeemed: number }[]; + donors: { orders: number; members: number; vouchers: number }; + extras: { carParking: number; rvParking: number; utv: number }; + comps: { total: number; byCreator: { name: string; count: number }[] }; + operators: { name: string; checkins: number; ice: number; undos: number }[]; + checkinsByHour: { hour: string; count: number }[]; + generatedAt: string; +} + +let cache: { at: number; data: Stats } | null = null; +const TTL_MS = 20_000; + +export async function computeStats(ctx: AppContext, force = false): Promise { + const now = Date.now(); + if (!force && cache && now - cache.at < TTL_MS) return cache.data; + + const records = await ctx.nocodb.all(); + const bagsPerTicket = ctx.config.ICE_BAGS_PER_TICKET || 3; + + let total = 0, + redeemed = 0, + iceTotal = 0, + iceRedeemed = 0; + let adults = 0, + youth = 0, + kids12 = 0, + kids9 = 0, + kids4 = 0; + let carParking = 0, + rvParking = 0, + utv = 0, + donorOrders = 0, + members = 0, + vouchers = 0; + const typeMap = new Map(); + const compByCreator = new Map(); + let compTotal = 0; + + for (const r of records as NocoRecord[]) { + const v = toView(r); + if (v.ticketType) { + compTotal += 1; + const who = v.createdBy || "(unknown)"; + compByCreator.set(who, (compByCreator.get(who) ?? 0) + 1); + } + total += v.total; + redeemed += v.redeemed; + iceTotal += v.ice.total; + iceRedeemed += v.ice.redeemed; + adults += toNumber(r[COL.adults]); + youth += toNumber(r[COL.youth]); + kids12 += toNumber(r[COL.kids12]); + kids9 += toNumber(r[COL.kids9]); + kids4 += toNumber(r[COL.kids4]); + + const t = v.ticketType || "Regular"; + const e = typeMap.get(t) ?? { count: 0, total: 0, redeemed: 0 }; + e.count += 1; + e.total += v.total; + e.redeemed += v.redeemed; + typeMap.set(t, e); + + if (v.extras.carParking) carParking += 1; + if (v.extras.rvParking) rvParking += 1; + if (v.extras.utv) utv += 1; + if (v.extras.isDonor) donorOrders += 1; + if (v.extras.donorTier === "member") members += 1; + vouchers += v.extras.vouchers; + } + + // Operator activity + check-in timeline from the audit log. + const audit = await ctx.audit.all().catch(() => []); + const opMap = new Map(); + const hourMap = new Map(); + for (const a of audit) { + if (a.operator) { + const o = opMap.get(a.operator) ?? { checkins: 0, ice: 0, undos: 0 }; + if (a.action === "check-in") o.checkins += a.people; + else if (a.action === "undo") o.undos += -a.people; + else if (a.action === "ice") o.ice += a.people; + opMap.set(a.operator, o); + } + if (a.action === "check-in" && a.people > 0 && a.at) { + const hour = String(a.at).slice(0, 13); // YYYY-MM-DDTHH + hourMap.set(hour, (hourMap.get(hour) ?? 0) + a.people); + } + } + + const data: Stats = { + orders: records.length, + tickets: { total, redeemed, remaining: Math.max(0, total - redeemed), pct: total ? Math.round((redeemed / total) * 100) : 0 }, + people: { adults, youth, kids12, kids9, kids4Free: kids4 }, + ice: { + total: iceTotal, + redeemed: iceRedeemed, + remaining: Math.max(0, iceTotal - iceRedeemed), + pct: iceTotal ? Math.round((iceRedeemed / iceTotal) * 100) : 0, + ticketsSold: Math.round(iceTotal / bagsPerTicket), + }, + types: [...typeMap.entries()] + .map(([type, e]) => ({ type, ...e })) + .sort((a, b) => b.total - a.total), + donors: { orders: donorOrders, members, vouchers }, + extras: { carParking, rvParking, utv }, + comps: { + total: compTotal, + byCreator: [...compByCreator.entries()] + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count), + }, + operators: [...opMap.entries()] + .map(([name, o]) => ({ name, ...o })) + .sort((a, b) => b.checkins - a.checkins), + checkinsByHour: [...hourMap.entries()] + .sort((a, b) => (a[0] < b[0] ? -1 : 1)) + .slice(-12) + .map(([hour, count]) => ({ hour, count })), + generatedAt: new Date().toISOString(), + }; + + cache = { at: now, data }; + return data; +} diff --git a/backend/src/ticketService.ts b/backend/src/ticketService.ts index c894f76..040ae07 100644 --- a/backend/src/ticketService.ts +++ b/backend/src/ticketService.ts @@ -131,6 +131,7 @@ export interface WebhookInput { adultNames?: string[]; email: string; ticketType?: string; // Guest/Worker/Performer/Volunteer/Speaker for portal comps + createdBy?: string; // gate-staff name who issued a comp address?: string; isDonor?: boolean; donorTier?: string; @@ -180,6 +181,7 @@ export async function createTicket( }; if (input.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n"); if (input.ticketType) fields[COL.ticketType] = input.ticketType; + if (input.createdBy) fields[COL.createdBy] = input.createdBy; if (input.address !== undefined) fields[COL.address] = input.address; if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor; if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier; From 7296555964f2a43597a51ac9bea48c23299ba500 Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 16 Jul 2026 05:13:07 +0000 Subject: [PATCH 02/15] Vendor webhooks, free kids through 12, multi-origin lookup CORS Children now free through age 12: - Scannable/paid ticket total = adults + youth 13-16 only; kids 12 & under (0-4, 5-9, 10-12) are stored but not counted (charging starts at 13). computeTotal + freeKidsCount in fields.ts, webhook guard, scan/admin badges, event-report labels, docs, and personas updated. Vendor booth webhooks (vendors.beartariacampgrounds.com): - New /vendor-webhook/food (2 named pass-holders) and /vendor-webhook/non-food (1 pass-holder), reusing WEBHOOK_SECRET. Booth name -> ticket title; each named person = one entry pass; tagged with a "Food Vendor"/"Vendor" Ticket Type (badge on scan + event-report rollup). Idempotent + QR email like the attendee hook. - Extracted shared FluentForms parsing (nameGroup/qty/selected/ addressLine/readDonor) into fluentforms.ts; attendee webhook now imports it. 13 new unit tests. Public lookup CORS is now a comma-separated allowlist; the caller's Origin is echoed only if it matches. tickets + vendors both allowed on donor-eligibility and ticket-vouchers. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/admin.tsx | 2 +- app/app/index.tsx | 4 +- app/app/stats.tsx | 8 +- app/lib/api.ts | 2 +- backend/src/config.ts | 12 ++- backend/src/fields.ts | 20 ++-- backend/src/fluentforms.ts | 68 ++++++++++++++ backend/src/routes/publicLookup.ts | 16 ++-- backend/src/routes/test.ts | 8 +- backend/src/routes/vendorWebhook.ts | 133 +++++++++++++++++++++++++++ backend/src/routes/webhook.ts | 65 ++----------- backend/src/routes/webhookDoc.ts | 25 ++++- backend/src/server.ts | 2 + backend/src/test/fields.test.ts | 10 +- backend/src/test/fluentforms.test.ts | 84 +++++++++++++++++ 15 files changed, 368 insertions(+), 91 deletions(-) create mode 100644 backend/src/fluentforms.ts create mode 100644 backend/src/routes/vendorWebhook.ts create mode 100644 backend/src/test/fluentforms.test.ts diff --git a/app/app/admin.tsx b/app/app/admin.tsx index 92b4052..4d1a277 100644 --- a/app/app/admin.tsx +++ b/app/app/admin.tsx @@ -209,7 +209,7 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti if (e.rvParking) tags.push("🚐 RV"); if (e.utv) tags.push("🏍️ UTV"); if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total}`); - if (e.freeUnder5 > 0) tags.push(`👶 ${e.freeUnder5} free`); + if (e.freeKids > 0) tags.push(`👶 ${e.freeKids} free kids`); return ( diff --git a/app/app/index.tsx b/app/app/index.tsx index c3bcdbc..d3211da 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -346,7 +346,7 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) { if (e.rvParking) tags.push("🚐 RV parking"); if (e.utv) tags.push("🏍️ UTV/ATV"); if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`); - if (e.freeUnder5 > 0) tags.push(`👶 ${e.freeUnder5} under 5 (free)`); + if (e.freeKids > 0) tags.push(`👶 ${e.freeKids} ${e.freeKids === 1 ? "kid" : "kids"} 12 & under (free)`); if (!tags.length) return null; return ( @@ -365,6 +365,8 @@ const TYPE_ICON: Record = { Performer: "🎭", Volunteer: "🙌", Speaker: "🎤", + "Food Vendor": "🍔", + Vendor: "🛒", }; function TypeBadge({ type }: { type: string }) { diff --git a/app/app/stats.tsx b/app/app/stats.tsx index 9c9428a..acd8c84 100644 --- a/app/app/stats.tsx +++ b/app/app/stats.tsx @@ -13,6 +13,8 @@ const TYPE_ICON: Record = { Performer: "🎭", Volunteer: "🙌", Speaker: "🎤", + "Food Vendor": "🍔", + Vendor: "🛒", }; const MEDAL = ["🥇", "🥈", "🥉"]; @@ -138,9 +140,9 @@ export default function StatsScreen() { Who's coming - - - + + + diff --git a/app/lib/api.ts b/app/lib/api.ts index ccc02e8..9f9d4f1 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -36,7 +36,7 @@ export interface TicketView { isDonor: boolean; donorTier: string; vouchers: number; - freeUnder5: number; + freeKids: number; }; ages: { bracket: string; count: number; free: boolean }[]; } diff --git a/backend/src/config.ts b/backend/src/config.ts index ef9f7f5..88954cf 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -27,7 +27,17 @@ const schema = z.object({ // Disabled unless a secret is set. Returns only eligibility + tier, never // names or dollar amounts. Rate-limited + CORS-restricted. PUBLIC_LOOKUP_SECRET: z.string().optional(), - PUBLIC_LOOKUP_ORIGIN: z.string().default("https://tickets.beartariacampgrounds.com"), + // Comma-separated allowlist of browser origins permitted to call the public + // lookups (the request's Origin is echoed back only if it matches one). + PUBLIC_LOOKUP_ORIGIN: z + .string() + .default("https://tickets.beartariacampgrounds.com,https://vendors.beartariacampgrounds.com") + .transform((s) => + s + .split(",") + .map((o) => o.trim().replace(/\/+$/, "")) + .filter(Boolean), + ), // Ticket-voucher entitlement: donations on/after VOUCHER_SINCE totalling // >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the date each year. diff --git a/backend/src/fields.ts b/backend/src/fields.ts index 50c569d..ae4dbf9 100644 --- a/backend/src/fields.ts +++ b/backend/src/fields.ts @@ -51,11 +51,17 @@ function bool(v: unknown): boolean { } /** - * Total scannable tickets = everyone except kids 0-4 (who are free): - * adults + youth (13-16) + kids 10-12 + kids 5-9. + * Total scannable (paid) tickets = adults + youth 13-16. Children 12 and under + * (kids 10-12 / 5-9 / 0-4) are admitted free and not counted; charging starts + * at age 13. */ export function computeTotal(rec: NocoRecord): number { - return num(rec[COL.adults]) + num(rec[COL.youth]) + num(rec[COL.kids12]) + num(rec[COL.kids9]); + return num(rec[COL.adults]) + num(rec[COL.youth]); +} + +/** Free children (age 12 and under). */ +export function freeKidsCount(rec: NocoRecord): number { + return num(rec[COL.kids12]) + num(rec[COL.kids9]) + num(rec[COL.kids4]); } export function computeIceTotal(rec: NocoRecord): number { @@ -67,8 +73,8 @@ export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number; return [ { bracket: "Adults", count: num(rec[COL.adults]), free: false }, { bracket: "Youth 13-16", count: num(rec[COL.youth]), free: false }, - { bracket: "Kids 10-12", count: num(rec[COL.kids12]), free: false }, - { bracket: "Kids 5-9", count: num(rec[COL.kids9]), free: false }, + { bracket: "Kids 10-12", count: num(rec[COL.kids12]), free: true }, + { bracket: "Kids 5-9", count: num(rec[COL.kids9]), free: true }, { bracket: "Kids 0-4", count: num(rec[COL.kids4]), free: true }, ].filter((b) => b.count > 0); } @@ -111,7 +117,7 @@ export interface TicketView { isDonor: boolean; donorTier: string; vouchers: number; - freeUnder5: number; + freeKids: number; // children 12 & under (free admission) }; ages: { bracket: string; count: number; free: boolean }[]; } @@ -144,7 +150,7 @@ export function toView(rec: NocoRecord): TicketView { isDonor: bool(rec[COL.isDonor]), donorTier: String(rec[COL.donorTier] ?? ""), vouchers: num(rec[COL.vouchers]), - freeUnder5: num(rec[COL.kids4]), + freeKids: freeKidsCount(rec), }, ages: ageBreakdown(rec), }; diff --git a/backend/src/fluentforms.ts b/backend/src/fluentforms.ts new file mode 100644 index 0000000..de628a7 --- /dev/null +++ b/backend/src/fluentforms.ts @@ -0,0 +1,68 @@ +import { timingSafeEqual } from "node:crypto"; +import { toBool, toNumber } from "./fields.js"; + +/** Constant-time string compare for shared webhook secrets. */ +export 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); +} + +/** Read a FluentForms compound name field, given as a nested object + * (`names: {first_name,...}`) or flattened bracket keys (`names[first_name]`). */ +export function nameGroup(body: Record, base: string): string { + const obj = body[base]; + let first: any, middle: any, last: any; + if (obj && typeof obj === "object") { + ({ first_name: first, middle_name: middle, last_name: last } = obj); + } else { + first = body[`${base}[first_name]`]; + middle = body[`${base}[middle_name]`]; + last = body[`${base}[last_name]`]; + } + return [first, middle, last] + .map((x) => (x == null ? "" : String(x).trim())) + .filter(Boolean) + .join(" "); +} + +/** Read an item_quantity / payment field's numeric value (handles nested + * objects like {quantity} / {value} and money strings like "$40.00"). */ +export function qty(v: any): number { + if (v == null || v === "") return 0; + if (typeof v === "object") return toNumber(v.quantity ?? v.value ?? v.item_quantity ?? v.amount ?? 0); + if (typeof v === "string") return toNumber(v.replace(/[^0-9.\-]/g, "")); + return toNumber(v); +} + +/** A payment/extra field counts as "selected" if it has a meaningful value. + * Donor (free) items can be $0, so a non-empty, non-"no"/"0" value also counts. */ +export function selected(v: any): boolean { + if (v == null || v === "") return false; + if (typeof v === "object") { + if ("selected" in v) return toBool((v as any).selected); + return qty(v) > 0 || Object.keys(v).length > 0; + } + const s = String(v).trim().toLowerCase(); + if (!s || s === "no" || s === "0" || s === "$0" || s === "$0.00" || s === "false" || s === "none") return false; + return true; +} + +/** Flatten a FluentForms compound address (`address_1`) to a single line. */ +export function addressLine(v: any): string | undefined { + if (v && typeof v === "object") return Object.values(v).filter(Boolean).join(", "); + if (v !== undefined) return String(v); + return undefined; +} + +/** Donor status from the hidden lookup fields + the "are you a donor?" radio. */ +export function readDonor(body: Record): { isDonor: boolean; donorTier: string } { + const donorTier = String(body.donor_tier ?? "").trim(); + const isDonor = + donorTier === "member" || + donorTier === "donor" || + toBool(body.donor_eligible) || + selected(body.input_radio); // "Are you a campground donor?" + return { isDonor, donorTier }; +} diff --git a/backend/src/routes/publicLookup.ts b/backend/src/routes/publicLookup.ts index c804b30..d1437f9 100644 --- a/backend/src/routes/publicLookup.ts +++ b/backend/src/routes/publicLookup.ts @@ -17,17 +17,21 @@ function safeEqual(a: string, b: string): boolean { */ export async function publicLookupRoutes(app: FastifyInstance): Promise { const cfg = app.ctx.config; - const origin = cfg.PUBLIC_LOOKUP_ORIGIN; + const allowed = cfg.PUBLIC_LOOKUP_ORIGIN; // string[] allowlist - const cors = (reply: any) => { + const cors = (req: any, reply: any) => { + const reqOrigin = String(req.headers?.origin ?? "").replace(/\/+$/, ""); + // Echo the caller's origin only if it's on the allowlist; otherwise fall + // back to the first configured origin (keeps non-browser callers working). + const origin = allowed.includes(reqOrigin) ? reqOrigin : allowed[0]; reply.header("Access-Control-Allow-Origin", origin); reply.header("Vary", "Origin"); reply.header("Access-Control-Allow-Methods", "GET, OPTIONS"); }; // Preflight (in case the form sends one). - const preflight = async (_req: any, reply: any) => { - cors(reply); + const preflight = async (req: any, reply: any) => { + cors(req, reply); return reply.code(204).send(); }; app.options("/api/public/donor-eligibility", preflight); @@ -42,7 +46,7 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise { "/api/public/donor-eligibility", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { - cors(reply); + cors(req, reply); // Disabled unless configured. if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) { return reply.code(404).send({ error: "not_available" }); @@ -72,7 +76,7 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise { "/api/public/ticket-vouchers", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { - cors(reply); + cors(req, reply); if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) { return reply.code(404).send({ error: "not_available" }); } diff --git a/backend/src/routes/test.ts b/backend/src/routes/test.ts index 1096416..a82c0f3 100644 --- a/backend/src/routes/test.ts +++ b/backend/src/routes/test.ts @@ -37,11 +37,11 @@ const PERSONAS: Persona[] = [ name: "Family Fay", email: "family@test.beartaria", adultNames: ["Family Fay", "Frank Fay"], - counts: C(2, 0, 0, 3, 2), // 2 adults + 3 kids(5-9) = 5 scannable; 2 kids 0-4 free + counts: C(2, 1, 1, 2, 2), // 2 adults + 1 youth = 3 paid; 5 kids 12 & under free iceBags: 3, carParking: true, blurb: - "5 tickets (2 adults + 3 kids 5-9; two 0-4 free), car parking, 3 ice bags. Check-in a few at a time to test QR reuse + see adult names; then Ice mode.", + "3 paid tickets (2 adults + 1 youth 13-16); 5 kids 12 & under free; car parking, 3 ice bags. Check-in a few at a time to test QR reuse + see adult names; then Ice mode.", }, { key: "donor2", @@ -119,8 +119,8 @@ export async function testRoutes(app: FastifyInstance): Promise { // Keep the "exhausted" persona fully redeemed on every load so its state // is deterministic (total = scannable count from the persona's counts). if (p.exhaust) { - const { adults, youth, kids12, kids9 } = p.counts; - await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth + kids12 + kids9 }); + const { adults, youth } = p.counts; + await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth }); } cards.push({ code: result.code, diff --git a/backend/src/routes/vendorWebhook.ts b/backend/src/routes/vendorWebhook.ts new file mode 100644 index 0000000..95eb076 --- /dev/null +++ b/backend/src/routes/vendorWebhook.ts @@ -0,0 +1,133 @@ +import { createHash } from "node:crypto"; +import type { FastifyInstance } from "fastify"; +import { createTicket } from "../ticketService.js"; +import { renderQrPng } from "../services/qrcode.js"; +import { safeEqual, nameGroup, addressLine, readDonor } from "../fluentforms.js"; + +/** + * Vendor booth webhooks (Vendor Fee Food / Non-Food 2026, on + * vendors.beartariacampgrounds.com). Structurally these are the same form; the + * only difference is how many entry passes a booth includes: + * + * - Food: two named pass-holders (`names` = "Name Ticket 1", + * `names_1` = "Name Ticket #2") → up to 2 passes. + * - Non-Food: one named pass-holder (`names`) → 1 pass. + * + * Each named person gets one gate ticket. The booth name becomes the ticket + * title (so gate staff see the booth) and the pass-holders are stored as the + * attendee names. The ticket is tagged with a vendor `Ticket Type` so it shows + * a badge on scan and rolls up in the event report. Booth size / additional + * space are logistics, not admissions, so they don't affect the pass count. + * + * Shares WEBHOOK_SECRET with the attendee webhook (same X-Webhook-Secret header). + */ +interface VendorKind { + ticketType: string; // badge label shown on scan + nameSlots: string[]; // pass-holder name field bases, in order +} + +const KINDS: Record<"food" | "nonfood", VendorKind> = { + food: { ticketType: "Food Vendor", nameSlots: ["names", "names_1"] }, + nonfood: { ticketType: "Vendor", nameSlots: ["names"] }, +}; + +function makeHandler(app: FastifyInstance, kind: VendorKind) { + return async (req: any, reply: any) => { + const secret = req.headers["x-webhook-secret"]; + if (typeof secret !== "string" || !safeEqual(secret, app.ctx.config.WEBHOOK_SECRET)) { + return reply.code(401).send({ error: "unauthorized" }); + } + + const body = (req.body ?? {}) as Record; + + const boothName = String(body.input_text ?? "").trim(); + // Pass-holder names (non-empty slots, in order). + const passHolders = kind.nameSlots.map((b) => nameGroup(body, b)).filter(Boolean); + const primary = passHolders[0] ?? ""; + // Ticket title = booth name (most useful at the gate), else the first person. + const title = boothName || primary; + if (!title) { + return reply.code(400).send({ error: "missing_fields", detail: "booth name or vendor name is required" }); + } + + const email = String(body.email ?? "").trim(); + // One entry pass per named person; a booth with no names still gets 1. + const passes = Math.max(1, passHolders.length); + + const { isDonor, donorTier } = readDonor(body); + const address = addressLine(body.address_1); + + // Idempotency: prefer a stable submission id, else hash the content. + const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id; + const submissionKey = submissionId + ? `sub:${String(submissionId)}` + : "hash:" + + createHash("sha256") + .update(`vendor|${kind.ticketType}|${email}|${title}|${passes}`) + .digest("hex") + .slice(0, 32); + + // Vendor passes are adult admissions; no youth/kids/ice/parking. + const counts = { adults: passes, youth: 0, kids12: 0, kids9: 0, kids4: 0 }; + + let result: Awaited>; + try { + result = await createTicket(app.ctx, { + name: title, + adultNames: passHolders, + email, + address, + isDonor, + donorTier, + ticketType: kind.ticketType, + counts, + paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined, + submissionKey, + }); + } catch (e: any) { + req.log.error({ err: e }, "vendor webhook: failed to create ticket"); + return reply.code(502).send({ error: "db_error", detail: e?.message }); + } + + if (result.status === "duplicate") { + return { status: "duplicate", code: result.code }; + } + + // Email the ticket QR (FluentForms sends the receipt separately). + if (!email) { + req.log.warn({ code: result.code }, "vendor webhook: ticket created but no email"); + return { status: "created", code: result.code, passes, emailSent: false, emailSkipped: "no_email" }; + } + if (app.ctx.mailer.isBlockedRecipient(email)) { + req.log.warn({ email }, "vendor webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send"); + return { status: "created", code: result.code, passes, emailSent: false, emailSkipped: "trial_restriction" }; + } + + try { + const qr = await renderQrPng(result.code); + await app.ctx.mailer.sendTicket({ + toEmail: email, + toName: primary || title, + code: result.code, + quantity: passes, + qrPng: qr, + }); + } catch (e: any) { + req.log.error({ err: e, code: result.code }, "vendor webhook: created but email failed"); + return reply.code(502).send({ status: "created", code: result.code, passes, emailSent: false, error: e?.message }); + } + + return { status: "created", code: result.code, passes, emailSent: true }; + }; +} + +export async function vendorWebhookRoutes(app: FastifyInstance): Promise { + // Configure these URLs in the two FluentForms vendor forms: + // Food: https://scan.beartariacampgrounds.com/vendor-webhook/food + // Non-Food: https://scan.beartariacampgrounds.com/vendor-webhook/non-food + app.post("/vendor-webhook/food", makeHandler(app, KINDS.food)); + app.post("/vendor-webhook/non-food", makeHandler(app, KINDS.nonfood)); + // Explicit API aliases. + app.post("/api/webhook/vendor-food", makeHandler(app, KINDS.food)); + app.post("/api/webhook/vendor-non-food", makeHandler(app, KINDS.nonfood)); +} diff --git a/backend/src/routes/webhook.ts b/backend/src/routes/webhook.ts index 9d96961..77d7522 100644 --- a/backend/src/routes/webhook.ts +++ b/backend/src/routes/webhook.ts @@ -1,55 +1,9 @@ -import { createHash, timingSafeEqual } from "node:crypto"; +import { createHash } from "node:crypto"; import type { FastifyInstance } from "fastify"; -import { toBool, toNumber } from "../fields.js"; +import { toBool } from "../fields.js"; import { createTicket } from "../ticketService.js"; import { renderQrPng } from "../services/qrcode.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); -} - -/** Read a FluentForms compound name field, given as a nested object - * (`names: {first_name,...}`) or flattened bracket keys (`names[first_name]`). */ -function nameGroup(body: Record, base: string): string { - const obj = body[base]; - let first: any, middle: any, last: any; - if (obj && typeof obj === "object") { - ({ first_name: first, middle_name: middle, last_name: last } = obj); - } else { - first = body[`${base}[first_name]`]; - middle = body[`${base}[middle_name]`]; - last = body[`${base}[last_name]`]; - } - return [first, middle, last] - .map((x) => (x == null ? "" : String(x).trim())) - .filter(Boolean) - .join(" "); -} - -/** Read an item_quantity / payment field's numeric value (handles nested - * objects like {quantity} / {value} and money strings like "$40.00"). */ -function qty(v: any): number { - if (v == null || v === "") return 0; - if (typeof v === "object") return toNumber(v.quantity ?? v.value ?? v.item_quantity ?? v.amount ?? 0); - if (typeof v === "string") return toNumber(v.replace(/[^0-9.\-]/g, "")); - return toNumber(v); -} - -/** A payment/extra field counts as "selected" if it has a meaningful value. - * Donor (free) items can be $0, so a non-empty, non-"no"/"0" value also counts. */ -function selected(v: any): boolean { - if (v == null || v === "") return false; - if (typeof v === "object") { - if ("selected" in v) return toBool((v as any).selected); - return qty(v) > 0 || Object.keys(v).length > 0; - } - const s = String(v).trim().toLowerCase(); - if (!s || s === "no" || s === "0" || s === "$0" || s === "$0.00" || s === "false" || s === "none") return false; - return true; -} +import { safeEqual, nameGroup, qty, selected, addressLine } from "../fluentforms.js"; // Adult name field bases, in order (purchaser first). const ADULT_NAME_BASES = ["names", "names_1", "names_2", "names_3", "names_4", "names_5", "names_6", "names_7", "names_8", "names_9"]; @@ -81,11 +35,13 @@ export async function webhookRoutes(app: FastifyInstance): Promise { kids9: qty(body.item_quantity_kids_9), kids4: qty(body.item_quantity_kids_4), }; - const scannable = counts.adults + counts.youth + counts.kids12 + counts.kids9; + // Paid/scannable admissions = adults + youth 13-16. Children 12 & under are + // free (charging starts at 13) and are stored but not counted at the gate. + const scannable = counts.adults + counts.youth; if (scannable <= 0) { // Nothing to check in at the gate. Log the payload so we can calibrate. req.log.warn({ body }, "webhook: no scannable tickets in submission"); - return reply.code(400).send({ error: "no_tickets", detail: "no scannable tickets (adults/youth/kids 5+)" }); + return reply.code(400).send({ error: "no_tickets", detail: "no paid tickets (adults / youth 13-16)" }); } // Donor info (hidden fields from the eligibility/voucher lookups) + radio. @@ -108,12 +64,7 @@ export async function webhookRoutes(app: FastifyInstance): Promise { const iceBags = Math.max(0, iceTickets) * app.ctx.config.ICE_BAGS_PER_TICKET; const iceAccess = iceBags > 0 || selected(body.input_radio_7); - const address = - body.address_1 && typeof body.address_1 === "object" - ? Object.values(body.address_1).filter(Boolean).join(", ") - : body.address_1 !== undefined - ? String(body.address_1) - : undefined; + const address = addressLine(body.address_1); // Idempotency: prefer a stable submission id, else hash the content. const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id; diff --git a/backend/src/routes/webhookDoc.ts b/backend/src/routes/webhookDoc.ts index 8f7bd8a..276062b 100644 --- a/backend/src/routes/webhookDoc.ts +++ b/backend/src/routes/webhookDoc.ts @@ -18,9 +18,9 @@ const FIELDS: Field[] = [ { key: "item_quantity_adult_ticket_reg", req: "required", type: "quantity", desc: "Adult tickets (regular)." }, { key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "Adult tickets (donor). Added to the regular adults." }, { key: "item_quantity_youth_ticket_reg / _donor", req: "optional", type: "quantity", desc: "Youth 13-16 tickets (regular + donor)." }, - { key: "item_quantity_kids_12", req: "optional", type: "quantity", desc: "Kids 10-12. Counts toward the scannable total." }, - { key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. Counts toward the scannable total." }, - { key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE — NOT counted toward the scannable ticket total." }, + { key: "item_quantity_kids_12", req: "optional", type: "quantity", desc: "Kids 10-12. FREE — stored but NOT counted toward the scannable ticket total." }, + { key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. FREE — stored but NOT counted toward the scannable ticket total." }, + { key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE — stored but NOT counted toward the scannable ticket total." }, { key: "donor_tier", req: "optional", type: "hidden", desc: "member / donor / empty (from the donor-eligibility lookup)." }, { key: "donor_eligible", req: "optional", type: "hidden", desc: "true / false (from the donor-eligibility lookup)." }, { key: "vouchers", req: "optional", type: "hidden", desc: "Integer voucher count (from the ticket-voucher lookup)." }, @@ -118,7 +118,7 @@ const PAGE = `

What it does

On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject "2026 Beartaria Campgrounds Tickets"). FluentForms sends the payment receipt separately.

-

Scannable ticket total = adults + youth (13-16) + kids 10-12 + kids 5-9. Kids 0-4 are free and not counted. Each adult name provided is stored and shown to gate staff on a successful scan.

+

Scannable ticket total = adults + youth (13-16). Children 12 & under are free (charging starts at 13) — their counts are stored and shown to gate staff, but not counted toward the ticket total. Each adult name provided is stored and shown on a successful scan.

Fields

@@ -132,7 +132,7 @@ const PAGE = `

Example payload

${exampleJson}
-

This issues 5 scannable tickets (2 adults + 1 youth + 2 kids 5-9; the two kids 0-4 are free), member donor with 2 vouchers, car parking, and 6 bags of ice (2 ice tickets).

+

This issues 3 scannable tickets (2 adults + 1 youth 13-16; all four kids 12 & under are free), member donor with 2 vouchers, car parking, and 6 bags of ice (2 ice tickets).

Test with curl

${exampleCurl}
@@ -158,6 +158,21 @@ const PAGE = `
  • Save, submit a test purchase, and confirm the QR email arrives.
  • +

    Vendor booth webhooks

    +

    The two vendor forms on vendors.beartariacampgrounds.com post to their own endpoints (same X-Webhook-Secret). Each named booth person gets one entry pass; the booth name (input_text) becomes the ticket title, and the ticket is tagged with a vendor Ticket Type that shows a badge on scan and rolls up in the event report. Booth size / additional space are logistics and don't affect passes.

    +
    + + + + + +
    FormEndpointPassesTicket Type
    Vendor Fee Food 2026POST /vendor-webhook/foodup to 2 (names + names_1)🍔 Food Vendor
    Vendor Fee Non-Food 2026POST /vendor-webhook/non-food1 (names)🛒 Vendor
    +

    Relevant keys: input_text (Booth Name), names / names_1 (pass-holders), email, address_1, donor_tier / donor_eligible / input_radio (donor), payment_method. Same idempotency (id/submission_id) and response shapes as above, plus a passes count.

    +
    curl -X POST https://scan.beartariacampgrounds.com/vendor-webhook/food \\
    +  -H "Content-Type: application/json" \\
    +  -H "X-Webhook-Secret: <your WEBHOOK_SECRET>" \\
    +  -d '{"id":"v-101","input_text":"Joe'\\''s Tacos","names":{"first_name":"Joe","last_name":"Taco"},"names_1":{"first_name":"Jane","last_name":"Taco"},"email":"joe@example.com","donor_tier":"member","payment_method":"stripe"}'
    +
    Beartaria Campgrounds · scan.beartariacampgrounds.com
    diff --git a/backend/src/server.ts b/backend/src/server.ts index b10433e..7895fe1 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -9,6 +9,7 @@ import { loadConfig } from "./config.js"; import { buildContext } from "./context.js"; import { authRoutes } from "./routes/auth.js"; import { webhookRoutes } from "./routes/webhook.js"; +import { vendorWebhookRoutes } from "./routes/vendorWebhook.js"; import { ticketRoutes } from "./routes/tickets.js"; import { testRoutes } from "./routes/test.js"; import { installRoutes } from "./routes/install.js"; @@ -32,6 +33,7 @@ export async function build() { await app.register(authRoutes); await app.register(webhookRoutes); + await app.register(vendorWebhookRoutes); await app.register(ticketRoutes); await app.register(testRoutes); await app.register(installRoutes); diff --git a/backend/src/test/fields.test.ts b/backend/src/test/fields.test.ts index 834de59..be0a4f1 100644 --- a/backend/src/test/fields.test.ts +++ b/backend/src/test/fields.test.ts @@ -2,16 +2,16 @@ import { describe, it, expect } from "vitest"; import { computeTotal, toView, COL } from "../fields.js"; describe("computeTotal", () => { - it("sums adults + youth + kids 10-12 + kids 5-9, excluding kids 0-4 (free)", () => { + it("sums adults + youth 13-16 only; all kids 12 & under are free", () => { const rec = { Id: 1, [COL.adults]: 2, [COL.youth]: 1, - [COL.kids12]: 1, - [COL.kids9]: 1, + [COL.kids12]: 1, // free, not counted + [COL.kids9]: 1, // free, not counted [COL.kids4]: 3, // free, not counted }; - expect(computeTotal(rec)).toBe(5); + expect(computeTotal(rec)).toBe(3); }); it("coerces string counts and treats blanks as 0", () => { @@ -47,7 +47,7 @@ describe("toView", () => { expect(v.extras.rvParking).toBe(false); expect(v.extras.donorTier).toBe("member"); expect(v.extras.vouchers).toBe(2); - expect(v.extras.freeUnder5).toBe(1); + expect(v.extras.freeKids).toBe(1); expect(v.ages.find((a) => a.bracket === "Kids 0-4")?.free).toBe(true); }); }); diff --git a/backend/src/test/fluentforms.test.ts b/backend/src/test/fluentforms.test.ts new file mode 100644 index 0000000..4f21e4b --- /dev/null +++ b/backend/src/test/fluentforms.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect } from "vitest"; +import { nameGroup, qty, selected, addressLine, readDonor } from "../fluentforms.js"; + +// Pass-holder name slots per the two vendor forms. +const FOOD_SLOTS = ["names", "names_1"]; +const NONFOOD_SLOTS = ["names"]; + +/** Mirror the vendor handler's pass count: one per named person, min 1. */ +function passCount(body: Record, slots: string[]): number { + const holders = slots.map((b) => nameGroup(body, b)).filter(Boolean); + return Math.max(1, holders.length); +} + +describe("nameGroup", () => { + it("reads flattened bracket keys", () => { + const body = { "names[first_name]": "Joe", "names[last_name]": "Taco" }; + expect(nameGroup(body, "names")).toBe("Joe Taco"); + }); + it("reads a nested object and includes the middle name", () => { + const body = { names: { first_name: "Ann", middle_name: "B", last_name: "Cole" } }; + expect(nameGroup(body, "names")).toBe("Ann B Cole"); + }); + it("returns empty string when the group is blank", () => { + expect(nameGroup({}, "names_1")).toBe(""); + }); +}); + +describe("vendor pass counting", () => { + it("food booth with two named holders gets 2 passes", () => { + const body = { + input_text: "Joe's Tacos", + "names[first_name]": "Joe", + "names[last_name]": "Taco", + "names_1[first_name]": "Jane", + "names_1[last_name]": "Taco", + }; + expect(passCount(body, FOOD_SLOTS)).toBe(2); + }); + it("food booth with only the first name gets 1 pass", () => { + const body = { input_text: "Solo BBQ", "names[first_name]": "Sam", "names[last_name]": "Que" }; + expect(passCount(body, FOOD_SLOTS)).toBe(1); + }); + it("non-food booth gets 1 pass (only one name slot)", () => { + const body = { + input_text: "Craft Corner", + "names[first_name]": "Pat", + "names[last_name]": "Maker", + // a stray names_1 must NOT count for non-food + "names_1[first_name]": "Ignore", + }; + expect(passCount(body, NONFOOD_SLOTS)).toBe(1); + }); + it("booth with no names still gets 1 pass", () => { + expect(passCount({ input_text: "Nameless Booth" }, FOOD_SLOTS)).toBe(1); + }); +}); + +describe("readDonor", () => { + it("treats donor_tier=member as a donor", () => { + expect(readDonor({ donor_tier: "member" })).toEqual({ isDonor: true, donorTier: "member" }); + }); + it("honors the donor_eligible hidden flag", () => { + expect(readDonor({ donor_eligible: "true" }).isDonor).toBe(true); + }); + it("is not a donor when nothing indicates it", () => { + expect(readDonor({ input_radio: "No" })).toEqual({ isDonor: false, donorTier: "" }); + }); +}); + +describe("qty / selected / addressLine", () => { + it("parses money strings and nested quantities", () => { + expect(qty("$40.00")).toBe(40); + expect(qty({ quantity: 2 })).toBe(2); + expect(qty("")).toBe(0); + }); + it("selected() treats $0.00 / no / blank as unselected", () => { + expect(selected("$0.00")).toBe(false); + expect(selected("No")).toBe(false); + expect(selected("Yes")).toBe(true); + }); + it("flattens a compound address", () => { + expect(addressLine({ address_line_1: "1 Main", city: "Boise", state: "ID" })).toBe("1 Main, Boise, ID"); + }); +}); From 267957d333446638badb3d0a0cf221d919318518 Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 16 Jul 2026 21:57:22 +0000 Subject: [PATCH 03/15] Non-food vendors get no entry ticket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only food vendors receive gate passes. The /vendor-webhook/non-food endpoint now acknowledges the submission ({"status":"ignored"}) and issues nothing, instead of creating a 1-pass ticket — kept as a safe no-op so an accidentally-wired FluentForms feed doesn't 404. Food webhook unchanged. Doc + tests updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/vendorWebhook.ts | 72 +++++++++++++++------------- backend/src/routes/webhookDoc.ts | 10 ++-- backend/src/test/fluentforms.test.ts | 17 ++----- 3 files changed, 48 insertions(+), 51 deletions(-) diff --git a/backend/src/routes/vendorWebhook.ts b/backend/src/routes/vendorWebhook.ts index 95eb076..86722c2 100644 --- a/backend/src/routes/vendorWebhook.ts +++ b/backend/src/routes/vendorWebhook.ts @@ -6,35 +6,31 @@ import { safeEqual, nameGroup, addressLine, readDonor } from "../fluentforms.js" /** * Vendor booth webhooks (Vendor Fee Food / Non-Food 2026, on - * vendors.beartariacampgrounds.com). Structurally these are the same form; the - * only difference is how many entry passes a booth includes: + * vendors.beartariacampgrounds.com). Only FOOD vendors receive entry tickets: * * - Food: two named pass-holders (`names` = "Name Ticket 1", - * `names_1` = "Name Ticket #2") → up to 2 passes. - * - Non-Food: one named pass-holder (`names`) → 1 pass. + * `names_1` = "Name Ticket #2") → up to 2 gate passes. + * - Non-Food: NO entry ticket. The endpoint acknowledges the submission + * (so a wired FluentForms feed doesn't error) but issues nothing. * - * Each named person gets one gate ticket. The booth name becomes the ticket - * title (so gate staff see the booth) and the pass-holders are stored as the - * attendee names. The ticket is tagged with a vendor `Ticket Type` so it shows - * a badge on scan and rolls up in the event report. Booth size / additional - * space are logistics, not admissions, so they don't affect the pass count. + * For food, each named person gets one gate ticket. The booth name becomes the + * ticket title (so gate staff see the booth) and the pass-holders are stored as + * the attendee names. The ticket is tagged with a "Food Vendor" `Ticket Type` + * so it shows a badge on scan and rolls up in the event report. Booth size / + * additional space are logistics, not admissions, so they don't affect passes. * * Shares WEBHOOK_SECRET with the attendee webhook (same X-Webhook-Secret header). */ -interface VendorKind { - ticketType: string; // badge label shown on scan - nameSlots: string[]; // pass-holder name field bases, in order +const FOOD_NAME_SLOTS = ["names", "names_1"]; // pass-holder name field bases + +function checkSecret(app: FastifyInstance, req: any): boolean { + const secret = req.headers["x-webhook-secret"]; + return typeof secret === "string" && safeEqual(secret, app.ctx.config.WEBHOOK_SECRET); } -const KINDS: Record<"food" | "nonfood", VendorKind> = { - food: { ticketType: "Food Vendor", nameSlots: ["names", "names_1"] }, - nonfood: { ticketType: "Vendor", nameSlots: ["names"] }, -}; - -function makeHandler(app: FastifyInstance, kind: VendorKind) { +function foodHandler(app: FastifyInstance) { return async (req: any, reply: any) => { - const secret = req.headers["x-webhook-secret"]; - if (typeof secret !== "string" || !safeEqual(secret, app.ctx.config.WEBHOOK_SECRET)) { + if (!checkSecret(app, req)) { return reply.code(401).send({ error: "unauthorized" }); } @@ -42,7 +38,7 @@ function makeHandler(app: FastifyInstance, kind: VendorKind) { const boothName = String(body.input_text ?? "").trim(); // Pass-holder names (non-empty slots, in order). - const passHolders = kind.nameSlots.map((b) => nameGroup(body, b)).filter(Boolean); + const passHolders = FOOD_NAME_SLOTS.map((b) => nameGroup(body, b)).filter(Boolean); const primary = passHolders[0] ?? ""; // Ticket title = booth name (most useful at the gate), else the first person. const title = boothName || primary; @@ -63,7 +59,7 @@ function makeHandler(app: FastifyInstance, kind: VendorKind) { ? `sub:${String(submissionId)}` : "hash:" + createHash("sha256") - .update(`vendor|${kind.ticketType}|${email}|${title}|${passes}`) + .update(`vendor|Food Vendor|${email}|${title}|${passes}`) .digest("hex") .slice(0, 32); @@ -79,7 +75,7 @@ function makeHandler(app: FastifyInstance, kind: VendorKind) { address, isDonor, donorTier, - ticketType: kind.ticketType, + ticketType: "Food Vendor", counts, paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined, submissionKey, @@ -121,13 +117,25 @@ function makeHandler(app: FastifyInstance, kind: VendorKind) { }; } -export async function vendorWebhookRoutes(app: FastifyInstance): Promise { - // Configure these URLs in the two FluentForms vendor forms: - // Food: https://scan.beartariacampgrounds.com/vendor-webhook/food - // Non-Food: https://scan.beartariacampgrounds.com/vendor-webhook/non-food - app.post("/vendor-webhook/food", makeHandler(app, KINDS.food)); - app.post("/vendor-webhook/non-food", makeHandler(app, KINDS.nonfood)); - // Explicit API aliases. - app.post("/api/webhook/vendor-food", makeHandler(app, KINDS.food)); - app.post("/api/webhook/vendor-non-food", makeHandler(app, KINDS.nonfood)); +/** Non-food vendors don't get an entry ticket. Acknowledge and issue nothing + * (so a wired FluentForms feed doesn't error), but never create a ticket. */ +function nonFoodHandler(app: FastifyInstance) { + return async (req: any, reply: any) => { + if (!checkSecret(app, req)) { + return reply.code(401).send({ error: "unauthorized" }); + } + return { status: "ignored", reason: "non_food_no_ticket" }; + }; +} + +export async function vendorWebhookRoutes(app: FastifyInstance): Promise { + // Configure this URL in the FOOD vendor FluentForms form: + // https://scan.beartariacampgrounds.com/vendor-webhook/food + // Non-food vendors receive no entry ticket; the endpoint below is a safe + // no-op only so an accidentally-wired feed doesn't 404. + app.post("/vendor-webhook/food", foodHandler(app)); + app.post("/vendor-webhook/non-food", nonFoodHandler(app)); + // Explicit API aliases. + app.post("/api/webhook/vendor-food", foodHandler(app)); + app.post("/api/webhook/vendor-non-food", nonFoodHandler(app)); } diff --git a/backend/src/routes/webhookDoc.ts b/backend/src/routes/webhookDoc.ts index 276062b..b5c25cb 100644 --- a/backend/src/routes/webhookDoc.ts +++ b/backend/src/routes/webhookDoc.ts @@ -159,15 +159,15 @@ const PAGE = `

    Vendor booth webhooks

    -

    The two vendor forms on vendors.beartariacampgrounds.com post to their own endpoints (same X-Webhook-Secret). Each named booth person gets one entry pass; the booth name (input_text) becomes the ticket title, and the ticket is tagged with a vendor Ticket Type that shows a badge on scan and rolls up in the event report. Booth size / additional space are logistics and don't affect passes.

    +

    Only food vendors receive entry tickets. The vendor forms live on vendors.beartariacampgrounds.com and share the same X-Webhook-Secret. For a food booth, each named person gets one entry pass; the booth name (input_text) becomes the ticket title, and the ticket is tagged with a Food Vendor Ticket Type that shows a badge on scan and rolls up in the event report. Booth size / additional space are logistics and don't affect passes.

    - + - - + +
    FormEndpointPassesTicket Type
    FormEndpointResult
    Vendor Fee Food 2026POST /vendor-webhook/foodup to 2 (names + names_1)🍔 Food Vendor
    Vendor Fee Non-Food 2026POST /vendor-webhook/non-food1 (names)🛒 Vendor
    Vendor Fee Food 2026POST /vendor-webhook/food🍔 up to 2 passes (names + names_1), Food Vendor ticket + QR email
    Vendor Fee Non-Food 2026POST /vendor-webhook/non-foodNo ticket — acknowledged only ({"status":"ignored"}). You can leave this form's webhook unconfigured.
    -

    Relevant keys: input_text (Booth Name), names / names_1 (pass-holders), email, address_1, donor_tier / donor_eligible / input_radio (donor), payment_method. Same idempotency (id/submission_id) and response shapes as above, plus a passes count.

    +

    Relevant food keys: input_text (Booth Name), names / names_1 (pass-holders), email, address_1, donor_tier / donor_eligible / input_radio (donor), payment_method. Same idempotency (id/submission_id) and response shapes as above, plus a passes count.

    curl -X POST https://scan.beartariacampgrounds.com/vendor-webhook/food \\
       -H "Content-Type: application/json" \\
       -H "X-Webhook-Secret: <your WEBHOOK_SECRET>" \\
    diff --git a/backend/src/test/fluentforms.test.ts b/backend/src/test/fluentforms.test.ts
    index 4f21e4b..083f3b4 100644
    --- a/backend/src/test/fluentforms.test.ts
    +++ b/backend/src/test/fluentforms.test.ts
    @@ -1,11 +1,10 @@
     import { describe, it, expect } from "vitest";
     import { nameGroup, qty, selected, addressLine, readDonor } from "../fluentforms.js";
     
    -// Pass-holder name slots per the two vendor forms.
    +// Food vendors are the only vendor tickets; two pass-holder name slots.
     const FOOD_SLOTS = ["names", "names_1"];
    -const NONFOOD_SLOTS = ["names"];
     
    -/** Mirror the vendor handler's pass count: one per named person, min 1. */
    +/** Mirror the food vendor handler's pass count: one per named person, min 1. */
     function passCount(body: Record, slots: string[]): number {
       const holders = slots.map((b) => nameGroup(body, b)).filter(Boolean);
       return Math.max(1, holders.length);
    @@ -25,7 +24,7 @@ describe("nameGroup", () => {
       });
     });
     
    -describe("vendor pass counting", () => {
    +describe("food vendor pass counting", () => {
       it("food booth with two named holders gets 2 passes", () => {
         const body = {
           input_text: "Joe's Tacos",
    @@ -40,16 +39,6 @@ describe("vendor pass counting", () => {
         const body = { input_text: "Solo BBQ", "names[first_name]": "Sam", "names[last_name]": "Que" };
         expect(passCount(body, FOOD_SLOTS)).toBe(1);
       });
    -  it("non-food booth gets 1 pass (only one name slot)", () => {
    -    const body = {
    -      input_text: "Craft Corner",
    -      "names[first_name]": "Pat",
    -      "names[last_name]": "Maker",
    -      // a stray names_1 must NOT count for non-food
    -      "names_1[first_name]": "Ignore",
    -    };
    -    expect(passCount(body, NONFOOD_SLOTS)).toBe(1);
    -  });
       it("booth with no names still gets 1 pass", () => {
         expect(passCount({ input_text: "Nameless Booth" }, FOOD_SLOTS)).toBe(1);
       });
    
    From 1ba3f9ad1c675a9d79ed109a718bcacf1e0b4ee5 Mon Sep 17 00:00:00 2001
    From: Hank 
    Date: Thu, 16 Jul 2026 22:11:08 +0000
    Subject: [PATCH 04/15] Ticket vouchers now return remaining and decrement on
     use
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    The ticket-vouchers lookup previously returned the tier entitlement
    every time, so a donor could keep claiming free tickets by re-
    submitting the form. It now subtracts vouchers already consumed:
    
      remaining = entitled - used
    
    where `used` is the sum of the Vouchers column across that donor's
    prior ticket orders (each checkout stores what it applied). Response
    gains entitled/used/remaining; `vouchers` is now the remaining count
    the form should grant. Consumption is implicit — no counter to keep in
    sync — and resets by zeroing/deleting the Vouchers value on the order
    row in NocoDB.
    
    - nocodb: findByEmail + vouchersUsedByEmail (case-insensitive).
    - 8 new tests (36 total). Doc updated with the new response + reset.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) 
    ---
     backend/src/routes/publicLookup.ts  | 22 +++++++++++------
     backend/src/services/nocodb.ts      | 16 ++++++++++++
     backend/src/test/fakeNocodb.ts      | 11 +++++++++
     backend/src/test/vouchers.test.ts   | 38 +++++++++++++++++++++++++++++
     docs/fluentforms-ticket-vouchers.md | 27 +++++++++++++++-----
     5 files changed, 101 insertions(+), 13 deletions(-)
     create mode 100644 backend/src/test/vouchers.test.ts
    
    diff --git a/backend/src/routes/publicLookup.ts b/backend/src/routes/publicLookup.ts
    index d1437f9..998a7fb 100644
    --- a/backend/src/routes/publicLookup.ts
    +++ b/backend/src/routes/publicLookup.ts
    @@ -69,9 +69,15 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise {
         },
       );
     
    -  // Ticket-voucher entitlement: how many free tickets a donor has earned from
    -  // giving on/after VOUCHER_SINCE. Same secret/CORS/rate-limit as above.
    -  // Returns only the count (0/1/2) — no dollar amounts.
    +  // Ticket-voucher entitlement: how many FREE tickets a donor has left. This is
    +  // the tier entitlement earned from giving on/after VOUCHER_SINCE, MINUS the
    +  // vouchers already consumed by their prior ticket orders (each order stores
    +  // how many it used), so a donor can't keep claiming free tickets by
    +  // re-submitting the form. `vouchers` is the remaining count the form should
    +  // grant; `entitled`/`used`/`remaining` are the breakdown. No dollar amounts.
    +  //
    +  // To reset for testing: zero out (or delete) the "Vouchers" value on that
    +  // donor's ticket order row(s) in NocoDB — `used` drops and `remaining` rises.
       app.get(
         "/api/public/ticket-vouchers",
         { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
    @@ -85,16 +91,18 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise {
           }
           const { email } = (req.query ?? {}) as { email?: string };
           const addr = String(email ?? "").trim();
    -      if (!addr) return { vouchers: 0 };
    +      if (!addr) return { vouchers: 0, entitled: 0, used: 0, remaining: 0 };
     
           try {
             const cutoff = new Date(cfg.VOUCHER_SINCE);
             const { amount } = await app.ctx.donors.amountSince(addr, cutoff);
    -        const vouchers = amount >= cfg.VOUCHER_TIER2_MIN ? 2 : amount >= cfg.VOUCHER_TIER1_MIN ? 1 : 0;
    -        return { vouchers };
    +        const entitled = amount >= cfg.VOUCHER_TIER2_MIN ? 2 : amount >= cfg.VOUCHER_TIER1_MIN ? 1 : 0;
    +        const used = await app.ctx.nocodb.vouchersUsedByEmail(addr);
    +        const remaining = Math.max(0, entitled - used);
    +        return { vouchers: remaining, entitled, used, remaining };
           } catch {
             // Fail closed — grant no vouchers rather than error.
    -        return { vouchers: 0 };
    +        return { vouchers: 0, entitled: 0, used: 0, remaining: 0 };
           }
         },
       );
    diff --git a/backend/src/services/nocodb.ts b/backend/src/services/nocodb.ts
    index a9f8587..f26f452 100644
    --- a/backend/src/services/nocodb.ts
    +++ b/backend/src/services/nocodb.ts
    @@ -76,6 +76,22 @@ export class NocoDBClient {
         return this.list(`(${COL.name},like,%${q}%)~or(${COL.email},like,%${q}%)`, limit);
       }
     
    +  /** Every order for an exact email (case-insensitive). */
    +  async findByEmail(email: string, limit = 1000): Promise {
    +    const rows = await this.list(`(${COL.email},eq,${escapeValue(email)})`, limit);
    +    // Belt-and-suspenders: some NocoDB backends do a case-sensitive eq, so
    +    // narrow/confirm against a lowercased compare in JS.
    +    const target = email.trim().toLowerCase();
    +    const exact = rows.filter((r) => String(r[COL.email] ?? "").trim().toLowerCase() === target);
    +    return exact.length ? exact : rows;
    +  }
    +
    +  /** Sum of ticket vouchers a donor has already consumed across their orders. */
    +  async vouchersUsedByEmail(email: string): Promise {
    +    const rows = await this.findByEmail(email);
    +    return rows.reduce((sum, r) => sum + (Number(r[COL.vouchers]) || 0), 0);
    +  }
    +
       async create(fields: Record): Promise {
         const body = await this.request(this.recordsUrl, {
           method: "POST",
    diff --git a/backend/src/test/fakeNocodb.ts b/backend/src/test/fakeNocodb.ts
    index 9a158f8..d8fe66b 100644
    --- a/backend/src/test/fakeNocodb.ts
    +++ b/backend/src/test/fakeNocodb.ts
    @@ -41,6 +41,17 @@ export class FakeNocoDB {
         );
       }
     
    +  async findByEmail(email: string): Promise {
    +    await this.delay();
    +    const target = email.trim().toLowerCase();
    +    return this.rows.filter((r) => String(r[COL.email] ?? "").trim().toLowerCase() === target);
    +  }
    +
    +  async vouchersUsedByEmail(email: string): Promise {
    +    const rows = await this.findByEmail(email);
    +    return rows.reduce((sum, r) => sum + (Number(r[COL.vouchers]) || 0), 0);
    +  }
    +
       async create(fields: Record): Promise {
         await this.delay();
         const rec = { Id: this.nextId++, ...fields } as NocoRecord;
    diff --git a/backend/src/test/vouchers.test.ts b/backend/src/test/vouchers.test.ts
    new file mode 100644
    index 0000000..497887a
    --- /dev/null
    +++ b/backend/src/test/vouchers.test.ts
    @@ -0,0 +1,38 @@
    +import { describe, it, expect } from "vitest";
    +import { FakeNocoDB } from "./fakeNocodb.js";
    +import { COL } from "../fields.js";
    +
    +/** Mirror the ticket-vouchers endpoint's remaining math. */
    +function remaining(entitled: number, used: number): number {
    +  return Math.max(0, entitled - used);
    +}
    +
    +describe("voucher consumption", () => {
    +  it("sums vouchers used across a donor's orders", async () => {
    +    const db = new FakeNocoDB(0);
    +    await db.create({ [COL.email]: "donor@example.com", [COL.vouchers]: 2 });
    +    await db.create({ [COL.email]: "donor@example.com", [COL.vouchers]: 1 });
    +    await db.create({ [COL.email]: "someone-else@example.com", [COL.vouchers]: 2 });
    +    await db.create({ [COL.email]: "donor@example.com", [COL.vouchers]: 0 }); // non-voucher order
    +    expect(await db.vouchersUsedByEmail("donor@example.com")).toBe(3);
    +  });
    +
    +  it("matches email case-insensitively", async () => {
    +    const db = new FakeNocoDB(0);
    +    await db.create({ [COL.email]: "Donor@Example.com", [COL.vouchers]: 2 });
    +    expect(await db.vouchersUsedByEmail("donor@example.com")).toBe(2);
    +  });
    +
    +  it("returns 0 used for a donor with no orders", async () => {
    +    const db = new FakeNocoDB(0);
    +    expect(await db.vouchersUsedByEmail("nobody@example.com")).toBe(0);
    +  });
    +
    +  it("remaining = entitled - used, floored at 0", () => {
    +    expect(remaining(2, 0)).toBe(2); // fresh 2-voucher donor
    +    expect(remaining(2, 1)).toBe(1); // used one
    +    expect(remaining(2, 2)).toBe(0); // used both — no more free tickets
    +    expect(remaining(1, 2)).toBe(0); // over-consumed (edge) never goes negative
    +    expect(remaining(0, 0)).toBe(0); // non-donor
    +  });
    +});
    diff --git a/docs/fluentforms-ticket-vouchers.md b/docs/fluentforms-ticket-vouchers.md
    index 6f079a5..af66c45 100644
    --- a/docs/fluentforms-ticket-vouchers.md
    +++ b/docs/fluentforms-ticket-vouchers.md
    @@ -14,16 +14,32 @@ ticketing backend.
     GET https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=&email=
     ```
     
    -Returns only the count — never names or dollar amounts:
    +Returns the **remaining** free-ticket count — never names or dollar amounts:
     
     ```json
    -{ "vouchers": 0 }   // or 1, or 2
    +{ "vouchers": 1, "entitled": 2, "used": 1, "remaining": 1 }
     ```
     
    +- `vouchers` / `remaining` — how many free tickets are **still available** (this
    +  is what the form should grant). Use `vouchers`; `remaining` is an alias.
    +- `entitled` — the tier entitlement earned from giving (0/1/2).
    +- `used` — vouchers already consumed by this donor's prior ticket orders.
     - `key` = the value of `PUBLIC_LOOKUP_SECRET` (set in the backend `.env`).
     - `email` = the donor's email (URL-encoded).
     - Rate-limited (30 requests / minute / IP) and CORS-restricted to
    -  `PUBLIC_LOOKUP_ORIGIN` (default `https://tickets.beartariacampgrounds.com`).
    +  `PUBLIC_LOOKUP_ORIGIN` (`tickets.` + `vendors.beartariacampgrounds.com`).
    +
    +### Vouchers decrement as they're used
    +
    +`remaining = entitled − used`, where `used` is the sum of the **Vouchers**
    +column across every ticket order placed with that email. Each checkout stores
    +the vouchers it applied, so the next lookup returns fewer — a donor can't keep
    +claiming free tickets by re-submitting the form. Once `used ≥ entitled`,
    +`vouchers` is `0`.
    +
    +**To reset for testing:** in NocoDB, zero out (or delete) the **Vouchers**
    +value on that donor's ticket order row(s). `used` drops and `remaining` rises on
    +the next lookup — no redeploy needed.
     
     > The secret is visible in page source, so treat it as **deterrence, not
     > security** — it only gates a 0/1/2 count. Rotate it by changing
    @@ -107,9 +123,8 @@ field `free_tickets` you can use for conditional logic or to cap a quantity.
     
     ```
     curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=&email="
    -# >= $1000 since cutoff -> {"vouchers":2}
    -# >= $400  since cutoff -> {"vouchers":1}
    -# otherwise             -> {"vouchers":0}
    +# entitled 2, none used yet -> {"vouchers":2,"entitled":2,"used":0,"remaining":2}
    +# after a checkout using 2  -> {"vouchers":0,"entitled":2,"used":2,"remaining":0}
     ```
     
     Related: [`fluentforms-donor-discount.md`](./fluentforms-donor-discount.md) — the
    
    From e86651723d5c793acf301d9a09a49c3c0d069587 Mon Sep 17 00:00:00 2001
    From: Hank 
    Date: Fri, 17 Jul 2026 04:33:21 +0000
    Subject: [PATCH 05/15] Webhook: capture donor adult ticket names (Tickets 2026
     update)
    MIME-Version: 1.0
    Content-Type: text/plain; charset=UTF-8
    Content-Transfer-Encoding: 8bit
    
    The updated Tickets 2026 form adds two donor (voucher) adult-ticket
    name groups, names_Donor_1 / names_Donor_2, for the free adult
    admissions. These were already counted via
    item_quantity_adult_ticket_donor but their attendee names weren't
    captured — added them to the adult-name list so they show at the gate.
    Doc updated (new name fields + note that pure pricing line items and
    payment_donor_voucher1/2 are ignored; the vouchers hidden count is
    authoritative). No other schema changes needed — counts, extras,
    donor, ice, and voucher handling already matched.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) 
    ---
     backend/src/routes/webhook.ts    | 11 +++++++++--
     backend/src/routes/webhookDoc.ts |  3 ++-
     2 files changed, 11 insertions(+), 3 deletions(-)
    
    diff --git a/backend/src/routes/webhook.ts b/backend/src/routes/webhook.ts
    index 77d7522..08df9e0 100644
    --- a/backend/src/routes/webhook.ts
    +++ b/backend/src/routes/webhook.ts
    @@ -5,8 +5,15 @@ import { createTicket } from "../ticketService.js";
     import { renderQrPng } from "../services/qrcode.js";
     import { safeEqual, nameGroup, qty, selected, addressLine } from "../fluentforms.js";
     
    -// Adult name field bases, in order (purchaser first).
    -const ADULT_NAME_BASES = ["names", "names_1", "names_2", "names_3", "names_4", "names_5", "names_6", "names_7", "names_8", "names_9"];
    +// Adult name field bases, in order: purchaser first, then additional regular
    +// adults (Adult Ticket #2–#10), then donor adult tickets (Adult Donor #1–#2).
    +// Donor tickets are the free/voucher adult admissions; their names live in the
    +// separate names_Donor_* groups but are still adults who need a gate pass.
    +const ADULT_NAME_BASES = [
    +  "names",
    +  "names_1", "names_2", "names_3", "names_4", "names_5", "names_6", "names_7", "names_8", "names_9",
    +  "names_Donor_1", "names_Donor_2",
    +];
     
     export async function webhookRoutes(app: FastifyInstance): Promise {
       const handler = async (req: any, reply: any) => {
    diff --git a/backend/src/routes/webhookDoc.ts b/backend/src/routes/webhookDoc.ts
    index b5c25cb..b9930fb 100644
    --- a/backend/src/routes/webhookDoc.ts
    +++ b/backend/src/routes/webhookDoc.ts
    @@ -13,6 +13,7 @@ interface Field {
     const FIELDS: Field[] = [
       { key: "names", req: "required", type: "name (compound)", desc: "Purchaser / Adult #1 — object {first_name, middle_name, last_name}. Also accepts flat names[first_name] keys." },
       { key: "names_1 … names_9", req: "optional", type: "name (compound)", desc: "Additional adult attendee names (Adults #2–#10). Empty groups are ignored. Stored as the adult-name list shown at the gate." },
    +  { key: "names_Donor_1 / names_Donor_2", req: "optional", type: "name (compound)", desc: "Donor (voucher) adult ticket names — the free adult admissions. Counted via item_quantity_adult_ticket_donor and added to the gate name list." },
       { key: "email", req: "optional", type: "email", desc: "Purchaser email — the QR ticket is sent here (FluentForms sends the receipt separately)." },
       { key: "address_1", req: "optional", type: "address (compound)", desc: "Mailing address object; joined into one line." },
       { key: "item_quantity_adult_ticket_reg", req: "required", type: "quantity", desc: "Adult tickets (regular)." },
    @@ -125,7 +126,7 @@ const PAGE = `
           KeyRequiredTypeDescription
           ${rows}
         
    -    

    Compound name fields arrive as objects (names: {first_name,…}) or flattened names[first_name] keys — both handled. Quantity/payment fields accept numbers, money strings ("$40.00"), or {quantity} objects.

    +

    Compound name fields arrive as objects (names: {first_name,…}) or flattened names[first_name] keys — both handled. Quantity/payment fields accept numbers, money strings ("$40.00"), or {quantity} objects. Counts come from the item_quantity_* fields, so pure pricing line items (payment_adult_reg, payment_youth_*, payment_kids_free, payment_donor_voucher1/2, custom-payment-amount/Tax) are ignored — the vouchers hidden count is authoritative for donor vouchers.

    Idempotency

    Send a stable id / submission_id. A repeat returns {"status":"duplicate"} without creating a second ticket or re-emailing — safe for retries and double-submits.

    From a987e046dae040c1f7712bcccdb85c23dc7e5f04 Mon Sep 17 00:00:00 2001 From: Hank Date: Fri, 17 Jul 2026 17:59:56 +0000 Subject: [PATCH 06/15] Webhook: customer_name purchaser + allow ticketless orders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for the updated Tickets 2026 form: 1. Purchaser name now comes from the new customer_name (billing) field, falling back to the first attendee then a plain `name`. Donor-only and buy-for-others orders (where the Adult #1 `names` group is empty) no longer 400 with "purchaser name is required". The ticket title is the first attendee if present, else the customer; the QR email is addressed to the customer. 2. Tickets are now optional. A customer can buy ice / ATV-UTV / parking with no admission ticket. A record + QR is created whenever there's anything to redeem or verify at the gate (ticket, ice, or add-on); only a truly empty order is rejected (no_items, replacing no_tickets). The ticket email adapts its copy for ticketless (add-on-only) orders — it reads as a gate pass for ice/parking/UTV instead of "0 tickets", and names the ice bag count when present. Idempotency hash now includes ice/extras so distinct add-on-only orders don't collide. Doc updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/webhook.ts | 40 ++++++++++++++--------- backend/src/routes/webhookDoc.ts | 8 +++-- backend/src/services/mailer.ts | 54 +++++++++++++++++++++++++------- 3 files changed, 74 insertions(+), 28 deletions(-) diff --git a/backend/src/routes/webhook.ts b/backend/src/routes/webhook.ts index 08df9e0..3cf3347 100644 --- a/backend/src/routes/webhook.ts +++ b/backend/src/routes/webhook.ts @@ -24,15 +24,19 @@ export async function webhookRoutes(app: FastifyInstance): Promise { const body = (req.body ?? {}) as Record; - // Purchaser = the first adult name group; fall back to a plain `name` field. - const name = nameGroup(body, "names") || String(body.name ?? "").trim(); const email = String(body.email ?? "").trim(); - if (!name) { - return reply.code(400).send({ error: "missing_fields", detail: "purchaser name is required" }); - } - // Adult attendee names (non-empty groups, in order). + // Billing/customer name (the purchaser — may differ from attendees, e.g. + // buying for others or add-ons only) + the attendee name groups. + const customerName = nameGroup(body, "customer_name") || String(body.name ?? "").trim(); const adultNames = ADULT_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean); + // Person the email is addressed to (the buyer). + const purchaser = customerName || adultNames[0]; + // Title shown at the gate: the first attendee if any, else the customer. + const title = adultNames[0] || customerName; + if (!title) { + return reply.code(400).send({ error: "missing_fields", detail: "customer or attendee name is required" }); + } // Attendee counts. const counts = { @@ -45,11 +49,6 @@ export async function webhookRoutes(app: FastifyInstance): Promise { // Paid/scannable admissions = adults + youth 13-16. Children 12 & under are // free (charging starts at 13) and are stored but not counted at the gate. const scannable = counts.adults + counts.youth; - if (scannable <= 0) { - // Nothing to check in at the gate. Log the payload so we can calibrate. - req.log.warn({ body }, "webhook: no scannable tickets in submission"); - return reply.code(400).send({ error: "no_tickets", detail: "no paid tickets (adults / youth 13-16)" }); - } // Donor info (hidden fields from the eligibility/voucher lookups) + radio. const donorTier = String(body.donor_tier ?? "").trim(); @@ -71,22 +70,32 @@ export async function webhookRoutes(app: FastifyInstance): Promise { const iceBags = Math.max(0, iceTickets) * app.ctx.config.ICE_BAGS_PER_TICKET; const iceAccess = iceBags > 0 || selected(body.input_radio_7); + // Tickets are optional: a customer can buy ice/UTV/parking with no admission + // ticket, or buy tickets for others. Only reject a truly empty order — + // nothing to check in, redeem, or verify at the gate. + const hasIssuable = scannable > 0 || iceBags > 0 || utv || carParking || rvParking; + if (!hasIssuable) { + req.log.warn({ body }, "webhook: submission has nothing to issue"); + return reply.code(400).send({ error: "no_items", detail: "no tickets, ice, or add-ons in submission" }); + } + const address = addressLine(body.address_1); - // Idempotency: prefer a stable submission id, else hash the content. + // Idempotency: prefer a stable submission id, else hash the content + // (include ice/extras so distinct add-on-only orders don't collide). const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id; const submissionKey = submissionId ? `sub:${String(submissionId)}` : "hash:" + createHash("sha256") - .update(`${email}|${name}|${JSON.stringify(counts)}`) + .update(`${email}|${title}|${JSON.stringify(counts)}|${iceBags}|${carParking}|${rvParking}|${utv}`) .digest("hex") .slice(0, 32); let result: Awaited>; try { result = await createTicket(app.ctx, { - name, + name: title, adultNames, email, address, @@ -127,10 +136,11 @@ export async function webhookRoutes(app: FastifyInstance): Promise { const qr = await renderQrPng(result.code); await app.ctx.mailer.sendTicket({ toEmail: email, - toName: name, + toName: purchaser, code: result.code, quantity: scannable, qrPng: qr, + iceBags, }); } catch (e: any) { req.log.error({ err: e, code: result.code }, "webhook: ticket created but email failed"); diff --git a/backend/src/routes/webhookDoc.ts b/backend/src/routes/webhookDoc.ts index b9930fb..ea5f10a 100644 --- a/backend/src/routes/webhookDoc.ts +++ b/backend/src/routes/webhookDoc.ts @@ -11,7 +11,8 @@ interface Field { } const FIELDS: Field[] = [ - { key: "names", req: "required", type: "name (compound)", desc: "Purchaser / Adult #1 — object {first_name, middle_name, last_name}. Also accepts flat names[first_name] keys." }, + { key: "customer_name", req: "required", type: "name (compound)", desc: "Billing / customer name — the buyer. Used to address the email and as the ticket title when there are no attendee names (add-on-only orders). Object {first_name, middle_name, last_name}; flat customer_name[first_name] keys also accepted." }, + { key: "names", req: "optional", type: "name (compound)", desc: "Adult Ticket #1 attendee — object {first_name, middle_name, last_name}. Also accepts flat names[first_name] keys. May be empty when buying only donor tickets or add-ons." }, { key: "names_1 … names_9", req: "optional", type: "name (compound)", desc: "Additional adult attendee names (Adults #2–#10). Empty groups are ignored. Stored as the adult-name list shown at the gate." }, { key: "names_Donor_1 / names_Donor_2", req: "optional", type: "name (compound)", desc: "Donor (voucher) adult ticket names — the free adult admissions. Counted via item_quantity_adult_ticket_donor and added to the gate name list." }, { key: "email", req: "optional", type: "email", desc: "Purchaser email — the QR ticket is sent here (FluentForms sends the receipt separately)." }, @@ -55,6 +56,7 @@ const rows = FIELDS.map( const exampleJson = esc(`{ "id": "412", + "customer_name": { "first_name": "Jane", "last_name": "Bear" }, "names": { "first_name": "Jane", "last_name": "Bear" }, "names_1": { "first_name": "John", "last_name": "Bear" }, "email": "jane@example.com", @@ -120,6 +122,7 @@ const PAGE = `

    What it does

    On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject "2026 Beartaria Campgrounds Tickets"). FluentForms sends the payment receipt separately.

    Scannable ticket total = adults + youth (13-16). Children 12 & under are free (charging starts at 13) — their counts are stored and shown to gate staff, but not counted toward the ticket total. Each adult name provided is stored and shown on a successful scan.

    +

    Tickets are optional. A customer can buy ice, an ATV/UTV pass, or parking with no admission ticket, or buy tickets for other people. A record + QR is still created as long as there's something to redeem or verify at the gate (a ticket, ice, or an add-on). Only a truly empty order is rejected.

    Fields

    @@ -144,7 +147,8 @@ const PAGE = ` - + + diff --git a/backend/src/services/mailer.ts b/backend/src/services/mailer.ts index a27ce84..a97f10b 100644 --- a/backend/src/services/mailer.ts +++ b/backend/src/services/mailer.ts @@ -9,6 +9,43 @@ export interface TicketEmail { code: string; quantity: number; qrPng: Buffer; + iceBags?: number; // for add-on-only (ticketless) orders +} + +/** Describe what a purchase is good for — handles ticketless (ice/UTV) orders. */ +function purchaseSummary(mail: TicketEmail): { lead: string; footer: string } { + const qty = mail.quantity; + if (qty > 0) { + const w = qty === 1 ? "ticket" : "tickets"; + return { + lead: `This email is your ticket for ${qty} ${w} to the 2026 Beartaria Campgrounds event. Show the QR code below at the gate.`, + footer: `Each ticket admits one entry. This code is good for all ${qty} ${w} on one purchase — gate staff will check people in against it. See you there!`, + }; + } + const bags = mail.iceBags ?? 0; + const extra = bags > 0 ? ` It includes ${bags} bag${bags === 1 ? "" : "s"} of ice.` : ""; + return { + lead: `This email is your gate pass for your 2026 Beartaria Campgrounds purchase (add-ons such as ice, parking, or an ATV/UTV).${extra} Show the QR code below at the gate.`, + footer: `Show this QR at the gate and staff will redeem your add-ons against it. See you there!`, + }; +} + +/** Plain-text version of purchaseSummary (no HTML tags). */ +function purchaseSummaryText(mail: TicketEmail): { lead: string; footer: string } { + const qty = mail.quantity; + if (qty > 0) { + const w = qty === 1 ? "ticket" : "tickets"; + return { + lead: `This is your ticket for ${qty} ${w} to the 2026 Beartaria Campgrounds event.`, + footer: `It is good for all ${qty} ${w} on this purchase.`, + }; + } + const bags = mail.iceBags ?? 0; + const extra = bags > 0 ? ` It includes ${bags} bag${bags === 1 ? "" : "s"} of ice.` : ""; + return { + lead: `This is your gate pass for your purchase (add-ons such as ice, parking, or an ATV/UTV).${extra}`, + footer: `Show this code at the gate and staff will redeem your add-ons against it.`, + }; } export class MailerSendError extends Error { @@ -94,8 +131,7 @@ function esc(s: string): string { function renderHtml(mail: TicketEmail): string { const name = esc(mail.toName || ""); - const qty = mail.quantity; - const ticketWord = qty === 1 ? "ticket" : "tickets"; + const { lead, footer } = purchaseSummary(mail); return ` @@ -108,9 +144,7 @@ function renderHtml(mail: TicketEmail): string {
    200{"status":"created","code":"BC26-…","emailSent":true}Ticket created and emailed.
    200{"status":"duplicate","code":"BC26-…"}Same submission already processed — no-op.
    400{"error":"missing_fields"} / "no_tickets"Missing purchaser name, or zero scannable tickets.
    400{"error":"missing_fields"}No customer name and no attendee names.
    400{"error":"no_items"}Empty order — no tickets, ice, or add-ons.
    401{"error":"unauthorized"}Missing or wrong X-Webhook-Secret.
    502{"status":"created","emailSent":false,…}Ticket row created but the email failed — re-send from the admin app.

    Hi ${name || "there"},

    - Thank you for your purchase! This email is your ticket for - ${qty} ${ticketWord} to the 2026 Beartaria Campgrounds event. - Show the QR code below at the gate. + Thank you for your purchase! ${lead}

    Ticket QR code

    - Each ticket admits one entry. This code is good for all ${qty} ${ticketWord} on one purchase — - gate staff will check people in against it. See you there! + ${footer}

    @@ -134,17 +167,16 @@ function renderHtml(mail: TicketEmail): string { } function renderText(mail: TicketEmail): string { - const qty = mail.quantity; - const ticketWord = qty === 1 ? "ticket" : "tickets"; + const { lead, footer } = purchaseSummaryText(mail); return [ `Hi ${mail.toName || "there"},`, "", - `Thank you for your purchase! This is your ticket for ${qty} ${ticketWord} to the 2026 Beartaria Campgrounds event.`, + `Thank you for your purchase! ${lead}`, "", `Your ticket code: ${mail.code}`, "", "Show this code (or the QR code in the HTML version of this email) at the gate.", - `It is good for all ${qty} ${ticketWord} on this purchase.`, + footer, "", "See you there!", "Beartaria Campgrounds · beartariacampgrounds.com", From 49e45ff94c780498067bbbc1e0e4bdd9d0defc61 Mon Sep 17 00:00:00 2001 From: Hank Date: Fri, 17 Jul 2026 19:07:56 +0000 Subject: [PATCH 07/15] Webhook: count voucher tickets + use customer_name as title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two corrections to the adult-ticket model: 1. Adult total was undercounting. Voucher (donor) tickets live only in the names_Donor_1 / names_Donor_2 name fields — each filled name is one free voucher adult ticket — and weren't counted at all. Adults now = item_quantity_adult_ticket_reg (regular) + item_quantity_adult_ticket_donor (extra PAID donor tickets beyond vouchers) + the donor voucher-name count. Vouchers consumed is now that same donor-name count (what the ticket-voucher lookup subtracts), instead of the hidden `vouchers` entitlement. 2. Ticket title now comes from customer_name (billing name), not the first adult ticket name. Doc updated to describe the adult total and the title source. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/src/routes/webhook.ts | 34 +++++++++++++++++++++----------- backend/src/routes/webhookDoc.ts | 14 ++++++------- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/backend/src/routes/webhook.ts b/backend/src/routes/webhook.ts index 3cf3347..7f5595d 100644 --- a/backend/src/routes/webhook.ts +++ b/backend/src/routes/webhook.ts @@ -5,15 +5,17 @@ import { createTicket } from "../ticketService.js"; import { renderQrPng } from "../services/qrcode.js"; import { safeEqual, nameGroup, qty, selected, addressLine } from "../fluentforms.js"; -// Adult name field bases, in order: purchaser first, then additional regular -// adults (Adult Ticket #2–#10), then donor adult tickets (Adult Donor #1–#2). -// Donor tickets are the free/voucher adult admissions; their names live in the -// separate names_Donor_* groups but are still adults who need a gate pass. -const ADULT_NAME_BASES = [ +// Regular adult attendee name groups (Adult Ticket #1–#10), in order. +const REGULAR_NAME_BASES = [ "names", "names_1", "names_2", "names_3", "names_4", "names_5", "names_6", "names_7", "names_8", "names_9", - "names_Donor_1", "names_Donor_2", ]; +// Donor voucher ticket name groups. Each FILLED group is one free voucher adult +// ticket — the voucher tickets live in these two name fields (there's no +// separate quantity field for them). +const DONOR_NAME_BASES = ["names_Donor_1", "names_Donor_2"]; +// All adult names for the gate display list. +const ADULT_NAME_BASES = [...REGULAR_NAME_BASES, ...DONOR_NAME_BASES]; export async function webhookRoutes(app: FastifyInstance): Promise { const handler = async (req: any, reply: any) => { @@ -30,17 +32,23 @@ export async function webhookRoutes(app: FastifyInstance): Promise { // buying for others or add-ons only) + the attendee name groups. const customerName = nameGroup(body, "customer_name") || String(body.name ?? "").trim(); const adultNames = ADULT_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean); - // Person the email is addressed to (the buyer). + // Free voucher adult tickets = number of donor name fields filled. + const voucherTickets = DONOR_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean).length; + // Ticket title + email recipient = the billing/customer name (fall back to + // the first attendee only if the customer name is somehow missing). const purchaser = customerName || adultNames[0]; - // Title shown at the gate: the first attendee if any, else the customer. - const title = adultNames[0] || customerName; + const title = customerName || adultNames[0]; if (!title) { return reply.code(400).send({ error: "missing_fields", detail: "customer or attendee name is required" }); } - // Attendee counts. + // Attendee counts. Adults = regular (paid) tickets + additional paid donor + // tickets + free voucher tickets (one per donor name provided). const counts = { - adults: qty(body.item_quantity_adult_ticket_reg) + qty(body.item_quantity_adult_ticket_donor), + adults: + qty(body.item_quantity_adult_ticket_reg) + + qty(body.item_quantity_adult_ticket_donor) + + voucherTickets, youth: qty(body.item_quantity_youth_ticket_reg) + qty(body.item_quantity_youth_ticket_donor), kids12: qty(body.item_quantity_kids_12), kids9: qty(body.item_quantity_kids_9), @@ -57,7 +65,9 @@ export async function webhookRoutes(app: FastifyInstance): Promise { donorTier === "donor" || toBool(body.donor_eligible) || selected(body.input_radio); // "Are you a campground donor?" - const vouchers = qty(body.vouchers); + // Vouchers consumed in this order = the free voucher tickets actually taken + // (donor names filled), which is what the ticket-voucher lookup subtracts. + const vouchers = voucherTickets; // Extras (best-effort from payment fields — donor variants may be free/$0). const carParking = selected(body.payment_parking_reg) || selected(body.payment_parking_donor); diff --git a/backend/src/routes/webhookDoc.ts b/backend/src/routes/webhookDoc.ts index ea5f10a..c2a146c 100644 --- a/backend/src/routes/webhookDoc.ts +++ b/backend/src/routes/webhookDoc.ts @@ -11,21 +11,21 @@ interface Field { } const FIELDS: Field[] = [ - { key: "customer_name", req: "required", type: "name (compound)", desc: "Billing / customer name — the buyer. Used to address the email and as the ticket title when there are no attendee names (add-on-only orders). Object {first_name, middle_name, last_name}; flat customer_name[first_name] keys also accepted." }, + { key: "customer_name", req: "required", type: "name (compound)", desc: "Billing / customer name — the buyer. Stored as the ticket title and used to address the email. Object {first_name, middle_name, last_name}; flat customer_name[first_name] keys also accepted." }, { key: "names", req: "optional", type: "name (compound)", desc: "Adult Ticket #1 attendee — object {first_name, middle_name, last_name}. Also accepts flat names[first_name] keys. May be empty when buying only donor tickets or add-ons." }, - { key: "names_1 … names_9", req: "optional", type: "name (compound)", desc: "Additional adult attendee names (Adults #2–#10). Empty groups are ignored. Stored as the adult-name list shown at the gate." }, - { key: "names_Donor_1 / names_Donor_2", req: "optional", type: "name (compound)", desc: "Donor (voucher) adult ticket names — the free adult admissions. Counted via item_quantity_adult_ticket_donor and added to the gate name list." }, + { key: "names_1 … names_9", req: "optional", type: "name (compound)", desc: "Additional regular adult attendee names (Adults #2–#10). Empty groups are ignored. Stored as the adult-name list shown at the gate." }, + { key: "names_Donor_1 / names_Donor_2", req: "optional", type: "name (compound)", desc: "Donor voucher ticket names. Each FILLED group is one FREE voucher adult ticket — this is how voucher tickets are counted (there's no quantity field for them). Also added to the gate name list and recorded as the vouchers consumed." }, { key: "email", req: "optional", type: "email", desc: "Purchaser email — the QR ticket is sent here (FluentForms sends the receipt separately)." }, { key: "address_1", req: "optional", type: "address (compound)", desc: "Mailing address object; joined into one line." }, - { key: "item_quantity_adult_ticket_reg", req: "required", type: "quantity", desc: "Adult tickets (regular)." }, - { key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "Adult tickets (donor). Added to the regular adults." }, + { key: "item_quantity_adult_ticket_reg", req: "required", type: "quantity", desc: "Regular (non-donor) adult tickets." }, + { key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "ADDITIONAL paid donor adult tickets bought beyond the free vouchers. Added to the adult total; does NOT include the voucher tickets (those come from names_Donor_1/2)." }, { key: "item_quantity_youth_ticket_reg / _donor", req: "optional", type: "quantity", desc: "Youth 13-16 tickets (regular + donor)." }, { key: "item_quantity_kids_12", req: "optional", type: "quantity", desc: "Kids 10-12. FREE — stored but NOT counted toward the scannable ticket total." }, { key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. FREE — stored but NOT counted toward the scannable ticket total." }, { key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE — stored but NOT counted toward the scannable ticket total." }, { key: "donor_tier", req: "optional", type: "hidden", desc: "member / donor / empty (from the donor-eligibility lookup)." }, { key: "donor_eligible", req: "optional", type: "hidden", desc: "true / false (from the donor-eligibility lookup)." }, - { key: "vouchers", req: "optional", type: "hidden", desc: "Integer voucher count (from the ticket-voucher lookup)." }, + { key: "vouchers", req: "optional", type: "hidden", desc: "Voucher entitlement from the ticket-voucher lookup (informational). The vouchers actually consumed are counted from the filled names_Donor_1/2 groups, not this field." }, { key: "input_radio", req: "optional", type: "choice", desc: "'Are you a campground donor?' — also used as a donor signal." }, { key: "payment_parking_reg / _donor", req: "optional", type: "payment", desc: "Car parking. Flagged if either variant is selected." }, { key: "payment_rv_reg / _donor", req: "optional", type: "payment", desc: "RV. Flagged if either variant is selected." }, @@ -121,7 +121,7 @@ const PAGE = `

    What it does

    On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject "2026 Beartaria Campgrounds Tickets"). FluentForms sends the payment receipt separately.

    -

    Scannable ticket total = adults + youth (13-16). Children 12 & under are free (charging starts at 13) — their counts are stored and shown to gate staff, but not counted toward the ticket total. Each adult name provided is stored and shown on a successful scan.

    +

    Scannable ticket total = adults + youth (13-16). Adults = item_quantity_adult_ticket_reg (regular) + item_quantity_adult_ticket_donor (extra paid donor tickets) + the number of donor voucher names (names_Donor_1/2 — each filled name is one free voucher ticket). Children 12 & under are free (charging starts at 13) — stored and shown to gate staff, but not counted toward the total. Each adult name provided is stored and shown on a successful scan; the ticket title is the customer_name.

    Tickets are optional. A customer can buy ice, an ATV/UTV pass, or parking with no admission ticket, or buy tickets for other people. A record + QR is still created as long as there's something to redeem or verify at the gate (a ticket, ice, or an add-on). Only a truly empty order is rejected.

    Fields

    From 049f433930de3fa74fe1eecd57ce8e2043bd0a02 Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 20 Jul 2026 07:40:05 +0000 Subject: [PATCH 08/15] Fix ice bag count + default ice check-in to 1 bag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The form sends payment_ice as a descriptive label, e.g. "One Ice ticket good for one bag per day (3 total bags)". The old parser pulled the first number ("3") and treated it as 3 tickets, then multiplied by 3 bags/ticket → 9 bags for one ice ticket (18 for two). New iceBagsFromPayment reads the "(N total bags)" the label states directly, with worded-count and numeric dollar/count fallbacks for forward compatibility. 1 ice → 3 bags, 2 → 6. 5 new tests. Scanner: Ice mode now defaults the check-in count to 1 (a bag at a time) instead of all remaining bags; staff can bump it up. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/index.tsx | 5 +++-- backend/src/fluentforms.ts | 28 ++++++++++++++++++++++++++++ backend/src/routes/webhook.ts | 13 +++++++------ backend/src/test/fluentforms.test.ts | 27 ++++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/app/app/index.tsx b/app/app/index.tsx index d3211da..b7a0b2e 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -109,8 +109,9 @@ export default function ScannerScreen() { feedbackSuccess(); const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining; setTicket(res.ticket); - // Ice: default to grabbing all remaining bags at once. Tickets: default 1. - setCount(mode === "ice" ? Math.max(1, remaining) : Math.min(1, remaining)); + // Default to 1 (people usually grab ice a bag at a time); staff can bump + // the count up. Clamp to what's left so a 0-remaining ticket stays at 0. + setCount(Math.min(1, remaining)); setPhase("confirm"); } catch (e: any) { if (e?.name === "AuthError") return router.replace("/login"); diff --git a/backend/src/fluentforms.ts b/backend/src/fluentforms.ts index de628a7..60f9e4c 100644 --- a/backend/src/fluentforms.ts +++ b/backend/src/fluentforms.ts @@ -56,6 +56,34 @@ export function addressLine(v: any): string | undefined { return undefined; } +const NUMBER_WORDS: Record = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 }; + +/** + * Total bags of ice from the `payment_ice` field. The form sends a descriptive + * option label, e.g. "One Ice ticket good for one bag per day (3 total bags)", + * so the reliable signal is the "(N total bags)" the label states. Falls back to + * a worded ticket count ("Two Ice tickets" → 2 × bagsPerTicket), then to a + * numeric dollar-total/ticket-count for forward compatibility. + */ +export function iceBagsFromPayment( + value: unknown, + opts: { bagsPerTicket: number; ticketPrice: number }, +): number { + const { bagsPerTicket, ticketPrice } = opts; + const s = typeof value === "string" ? value : ""; + // Preferred: the label states the total bags directly. + const bagsMatch = s.match(/(\d+)\s*total\s*bags/i); + if (bagsMatch) return Math.max(0, parseInt(bagsMatch[1], 10)); + // Worded ticket count: "One Ice ticket", "Two Ice tickets". + const wordMatch = s.match(/\b(one|two|three|four|five|six)\b\s+ice/i); + if (wordMatch) return NUMBER_WORDS[wordMatch[1].toLowerCase()] * bagsPerTicket; + // Numeric fallback: a dollar total (>= price) → tickets; else a small count. + const n = qty(value); + if (n <= 0) return 0; + const tickets = n >= ticketPrice ? Math.round(n / ticketPrice) : Math.round(n); + return Math.max(0, tickets) * bagsPerTicket; +} + /** Donor status from the hidden lookup fields + the "are you a donor?" radio. */ export function readDonor(body: Record): { isDonor: boolean; donorTier: string } { const donorTier = String(body.donor_tier ?? "").trim(); diff --git a/backend/src/routes/webhook.ts b/backend/src/routes/webhook.ts index 7f5595d..250c14f 100644 --- a/backend/src/routes/webhook.ts +++ b/backend/src/routes/webhook.ts @@ -3,7 +3,7 @@ import type { FastifyInstance } from "fastify"; import { toBool } from "../fields.js"; import { createTicket } from "../ticketService.js"; import { renderQrPng } from "../services/qrcode.js"; -import { safeEqual, nameGroup, qty, selected, addressLine } from "../fluentforms.js"; +import { safeEqual, nameGroup, qty, selected, addressLine, iceBagsFromPayment } from "../fluentforms.js"; // Regular adult attendee name groups (Adult Ticket #1–#10), in order. const REGULAR_NAME_BASES = [ @@ -73,11 +73,12 @@ export async function webhookRoutes(app: FastifyInstance): Promise { const carParking = selected(body.payment_parking_reg) || selected(body.payment_parking_donor); const rvParking = selected(body.payment_rv_reg) || selected(body.payment_rv_donor); const utv = selected(body.payment_utv_reg) || selected(body.payment_utv_donor); - // Ice: payment_ice is either a ticket count (1-4) or a dollar total - // ($20-$80). One ice ticket = ICE_BAGS_PER_TICKET bags. - const iceRaw = qty(body.payment_ice); - const iceTickets = iceRaw >= app.ctx.config.ICE_TICKET_PRICE ? Math.round(iceRaw / app.ctx.config.ICE_TICKET_PRICE) : Math.round(iceRaw); - const iceBags = Math.max(0, iceTickets) * app.ctx.config.ICE_BAGS_PER_TICKET; + // Ice: payment_ice is a descriptive option label whose "(N total bags)" + // states the bags. One ice ticket = ICE_BAGS_PER_TICKET bags. + const iceBags = iceBagsFromPayment(body.payment_ice, { + bagsPerTicket: app.ctx.config.ICE_BAGS_PER_TICKET, + ticketPrice: app.ctx.config.ICE_TICKET_PRICE, + }); const iceAccess = iceBags > 0 || selected(body.input_radio_7); // Tickets are optional: a customer can buy ice/UTV/parking with no admission diff --git a/backend/src/test/fluentforms.test.ts b/backend/src/test/fluentforms.test.ts index 083f3b4..9023e24 100644 --- a/backend/src/test/fluentforms.test.ts +++ b/backend/src/test/fluentforms.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from "vitest"; -import { nameGroup, qty, selected, addressLine, readDonor } from "../fluentforms.js"; +import { nameGroup, qty, selected, addressLine, readDonor, iceBagsFromPayment } from "../fluentforms.js"; + +const ICE = { bagsPerTicket: 3, ticketPrice: 20 }; // Food vendors are the only vendor tickets; two pass-holder name slots. const FOOD_SLOTS = ["names", "names_1"]; @@ -44,6 +46,29 @@ describe("food vendor pass counting", () => { }); }); +describe("iceBagsFromPayment", () => { + it("reads '(N total bags)' from the real form label", () => { + expect(iceBagsFromPayment("One Ice ticket good for one bag per day (3 total bags)", ICE)).toBe(3); + expect(iceBagsFromPayment("Two Ice tickets good for one bag per day (6 total bags)", ICE)).toBe(6); + }); + it("falls back to a worded ice-ticket count", () => { + expect(iceBagsFromPayment("Two Ice tickets", ICE)).toBe(6); // 2 × 3 + expect(iceBagsFromPayment("Four Ice tickets", ICE)).toBe(12); + }); + it("falls back to a dollar total at the ticket price", () => { + expect(iceBagsFromPayment("$40.00", ICE)).toBe(6); // 2 tickets × 3 + expect(iceBagsFromPayment(20, ICE)).toBe(3); // 1 ticket × 3 + }); + it("treats a small plain count as ticket count", () => { + expect(iceBagsFromPayment(2, ICE)).toBe(6); // 2 tickets × 3 + }); + it("is 0 for blank / no ice", () => { + expect(iceBagsFromPayment("", ICE)).toBe(0); + expect(iceBagsFromPayment(undefined, ICE)).toBe(0); + expect(iceBagsFromPayment(0, ICE)).toBe(0); + }); +}); + describe("readDonor", () => { it("treats donor_tier=member as a donor", () => { expect(readDonor({ donor_tier: "member" })).toEqual({ isDonor: true, donorTier: "member" }); From 63e00fae618851640bdbfd35116e02b484879dd8 Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 20 Jul 2026 18:48:10 +0000 Subject: [PATCH 09/15] Scanner: large Adults / Kids party panel on scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate-enforcement aid against adults signing up under a free/cheaper kid bracket. When a ticket is scanned, a prominent amber-bordered panel shows big "# ADULTS # KIDS" counts (Adults = the 18+ bracket; Kids = youth 13-16 + all under-13), plus a per-bracket detail line (e.g. "1× 13-16 · 2× 5-9") so staff can eyeball the claimed ages against the actual party. Shown on the confirm card (before check-in) and the success overlay; hidden in Ice mode. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/index.tsx | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/app/app/index.tsx b/app/app/index.tsx index b7a0b2e..3766c6c 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -253,6 +253,7 @@ export default function ScannerScreen() { {ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining + @@ -394,6 +395,37 @@ function AdultNames({ names }: { names: string[] }) { ); } +/** + * Big Adults / Kids breakdown so gate staff can eyeball the party against the + * ticket — a deterrent for adults signing up under a (free/cheaper) kid bracket. + * Adults = the 18+ bracket; Kids = everyone else (youth 13-16 + all under-13). + */ +function PartyPanel({ ticket }: { ticket: TicketView }) { + const adults = ticket.ages.find((a) => a.bracket === "Adults")?.count ?? 0; + const kidBrackets = ticket.ages.filter((a) => a.bracket !== "Adults"); + const kids = kidBrackets.reduce((s, a) => s + a.count, 0); + return ( + + + + {adults} + {adults === 1 ? "ADULT" : "ADULTS"} + + + + {kids} + {kids === 1 ? "KID" : "KIDS"} + + + {kidBrackets.length > 0 && ( + + {kidBrackets.map((a) => `${a.count}× ${a.bracket.replace(/^(Kids|Youth)\s*/, "")}`).join(" · ")} + + )} + + ); +} + function ConfirmCard({ ticket, isIce, @@ -422,6 +454,7 @@ function ConfirmCard({ {ticket.name} {ticket.code} + {!isIce && } {remaining} of {total} {unit} remaining @@ -535,6 +568,23 @@ const styles = StyleSheet.create({ typeBadgeText: { color: "#fff", fontSize: 20, fontWeight: "900", letterSpacing: 1 }, namesBox: { marginTop: 12, alignItems: "center", gap: 3 }, nameLine: { color: "#fff", fontSize: 18, fontWeight: "600", textAlign: "center" }, + party: { + alignSelf: "stretch", + backgroundColor: "#1d2a1f", + borderWidth: 2, + borderColor: theme.warn, + borderRadius: 16, + paddingVertical: 18, + paddingHorizontal: 12, + marginTop: 16, + marginBottom: 2, + }, + partyRow: { flexDirection: "row", alignItems: "center", justifyContent: "center" }, + partyCell: { flex: 1, alignItems: "center" }, + partyNum: { color: theme.text, fontSize: 60, fontWeight: "900", lineHeight: 64 }, + partyLbl: { color: theme.warn, fontSize: 15, fontWeight: "800", letterSpacing: 2, marginTop: 2 }, + partyDivider: { width: 2, alignSelf: "stretch", backgroundColor: theme.cardBorder, marginVertical: 6 }, + partyDetail: { color: theme.textDim, fontSize: 14, textAlign: "center", marginTop: 12, fontWeight: "600" }, tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 }, tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" }, From 62231e9b1070896dad05b02a634afaf6c6236407 Mon Sep 17 00:00:00 2001 From: Hank Date: Tue, 21 Jul 2026 00:34:32 +0000 Subject: [PATCH 10/15] Release v0.2.0 (versionCode 2) Webhook: Tickets 2026 form (customer_name title, voucher-name ticket counting, ticketless ice/UTV orders, donor adult names); ice bag count fix; voucher decrement; vendor webhooks; free kids through 12; multi- origin lookup CORS; scanner Adults/Kids party panel + ice default 1. First versionCode bump (was stuck at 1), so this installs over v0.1.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/app.json b/app/app.json index 143c506..ecfabbc 100644 --- a/app/app.json +++ b/app/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Camp Scan", "slug": "camptickets", - "version": "0.1.0", + "version": "0.2.0", "orientation": "portrait", "scheme": "campscan", "userInterfaceStyle": "automatic", @@ -10,7 +10,7 @@ "icon": "./assets/icon.png", "android": { "package": "top.mowden.campscan", - "versionCode": 1, + "versionCode": 2, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#0f1a12" From 60f090829916a04b3c7ff392864f8117b784d3af Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 22 Jul 2026 03:34:14 +0000 Subject: [PATCH 11/15] Scanner: compact 3-cell Adults / Youth / Kids party panel Split the party panel into Adults (18+) / Youth (13-16) / Kids (0-12) so the paid tickets (adults + youth) are both visible, and shrank it (36px numbers, tighter padding, single-line kid detail) so the confirm card no longer scrolls on a phone. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/index.tsx | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/app/app/index.tsx b/app/app/index.tsx index 3766c6c..e624c36 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -396,30 +396,38 @@ function AdultNames({ names }: { names: string[] }) { } /** - * Big Adults / Kids breakdown so gate staff can eyeball the party against the - * ticket — a deterrent for adults signing up under a (free/cheaper) kid bracket. - * Adults = the 18+ bracket; Kids = everyone else (youth 13-16 + all under-13). + * Big Adults / Youth / Kids breakdown so gate staff can eyeball the party + * against the ticket — a deterrent for adults signing up under a (free/cheaper) + * younger bracket. Adults (18+) and Youth (13-16) are the paid tickets; Kids + * (0-12) are free. A detail line breaks the kids into their age bands. */ function PartyPanel({ ticket }: { ticket: TicketView }) { - const adults = ticket.ages.find((a) => a.bracket === "Adults")?.count ?? 0; - const kidBrackets = ticket.ages.filter((a) => a.bracket !== "Adults"); + const get = (b: string) => ticket.ages.find((a) => a.bracket === b)?.count ?? 0; + const adults = get("Adults"); + const youth = get("Youth 13-16"); + const kidBrackets = ticket.ages.filter((a) => a.bracket.startsWith("Kids")); const kids = kidBrackets.reduce((s, a) => s + a.count, 0); return ( {adults} - {adults === 1 ? "ADULT" : "ADULTS"} + ADULTS{"\n"}18+ + + + + {youth} + YOUTH{"\n"}13-16 {kids} - {kids === 1 ? "KID" : "KIDS"} + KIDS{"\n"}0-12 {kidBrackets.length > 0 && ( - {kidBrackets.map((a) => `${a.count}× ${a.bracket.replace(/^(Kids|Youth)\s*/, "")}`).join(" · ")} + kids: {kidBrackets.map((a) => `${a.count}× ${a.bracket.replace(/^Kids\s*/, "")}`).join(" · ")} )} @@ -573,18 +581,18 @@ const styles = StyleSheet.create({ backgroundColor: "#1d2a1f", borderWidth: 2, borderColor: theme.warn, - borderRadius: 16, - paddingVertical: 18, - paddingHorizontal: 12, - marginTop: 16, + borderRadius: 14, + paddingVertical: 10, + paddingHorizontal: 10, + marginTop: 10, marginBottom: 2, }, partyRow: { flexDirection: "row", alignItems: "center", justifyContent: "center" }, partyCell: { flex: 1, alignItems: "center" }, - partyNum: { color: theme.text, fontSize: 60, fontWeight: "900", lineHeight: 64 }, - partyLbl: { color: theme.warn, fontSize: 15, fontWeight: "800", letterSpacing: 2, marginTop: 2 }, - partyDivider: { width: 2, alignSelf: "stretch", backgroundColor: theme.cardBorder, marginVertical: 6 }, - partyDetail: { color: theme.textDim, fontSize: 14, textAlign: "center", marginTop: 12, fontWeight: "600" }, + partyNum: { color: theme.text, fontSize: 36, fontWeight: "900", lineHeight: 40 }, + partyLbl: { color: theme.warn, fontSize: 11, fontWeight: "800", letterSpacing: 0.5, marginTop: 1, textAlign: "center", lineHeight: 13 }, + partyDivider: { width: 1.5, height: 42, backgroundColor: theme.cardBorder }, + partyDetail: { color: theme.textDim, fontSize: 12, textAlign: "center", marginTop: 8, fontWeight: "600" }, tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 }, tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" }, From 3a3119e3240d0b33173b3e9ef8a58c802f973628 Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 23 Jul 2026 01:29:10 +0000 Subject: [PATCH 12/15] 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 13/15] 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 14/15] 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 15/15] 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 🔒