Compare commits

..

15 commits
v0.0.9 ... main

Author SHA1 Message Date
bc93ef43f7 crush33: back-to-scanner links + release v0.3.0 (versionCode 3)
All checks were successful
Build Android APK / build-apk (push) Successful in 46m57s
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) <noreply@anthropic.com>
2026-07-23 02:01:30 +00:00
efe331a77a 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) <noreply@anthropic.com>
2026-07-23 01:58:08 +00:00
1c8cb47209 Move admin hub into the /crush33 page; drop the /comp app route
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) <noreply@anthropic.com>
2026-07-23 01:49:15 +00:00
3a3119e324 Admin hub in /crush33: sidebar, donor lookup, danger-zone actions
Rebuilt the password-gated /crush33 (in-app /comp) screen into an admin
hub with a left sidebar and three sections:

- Comp tickets — the existing entry-only comp creator.
- Donor lookup — admin-only free-text search across the donor master
  list + online/offline transaction tables by name / email / phone /
  address / bear name (columns discovered per table, deduped by email).
- Actions (danger zone) — heavy warnings, red buttons, and an
  "are you sure" modal that spells out exactly what will happen:
    • Wipe slate — delete ALL ticket + audit records in the active
      event table (donor data untouched, irreversible).
    • Switch event table — repoint the app at a different NocoDB
      tickets/audit table to start a new event while keeping the old
      one intact.

Backend:
- New /api/admin/{status,wipe,switch-table,donor-search}, all gated by
  PORTAL_PASSWORD (POST-only so it never lands in a URL/log).
- NocoDBClient + AuditLogger: runtime-switchable tableId, count(),
  deleteAll(), probeTable() (reachable + Id-PK check before switching).
- DonorService.search() with adaptive column discovery.
- Table switch persists across redeploys via a small state file on a
  new /data volume (Dockerfile creates it owned by node so it's
  writable); applied at startup in buildContext.

Also shipped equivalent CLI scripts: scripts/wipe-slate.sh and
scripts/switch-event.sh. Drawer: "Comp tickets" -> "Admin (crush33)".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 01:29:10 +00:00
60f0908299 Scanner: compact 3-cell Adults / Youth / Kids party panel
All checks were successful
Build Android APK / build-apk (push) Successful in 55m8s
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) <noreply@anthropic.com>
2026-07-22 03:34:14 +00:00
62231e9b10 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) <noreply@anthropic.com>
2026-07-21 00:34:32 +00:00
63e00fae61 Scanner: large Adults / Kids party panel on scan
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) <noreply@anthropic.com>
2026-07-20 18:48:10 +00:00
049f433930 Fix ice bag count + default ice check-in to 1 bag
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) <noreply@anthropic.com>
2026-07-20 07:40:05 +00:00
49e45ff94c Webhook: count voucher tickets + use customer_name as title
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) <noreply@anthropic.com>
2026-07-17 19:07:56 +00:00
a987e046da Webhook: customer_name purchaser + allow ticketless orders
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) <noreply@anthropic.com>
2026-07-17 17:59:56 +00:00
e86651723d Webhook: capture donor adult ticket names (Tickets 2026 update)
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) <noreply@anthropic.com>
2026-07-17 04:33:21 +00:00
1ba3f9ad1c Ticket vouchers now return remaining and decrement on use
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) <noreply@anthropic.com>
2026-07-16 22:11:08 +00:00
267957d333 Non-food vendors get no entry ticket
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) <noreply@anthropic.com>
2026-07-16 21:57:22 +00:00
7296555964 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) <noreply@anthropic.com>
2026-07-16 05:13:07 +00:00
43fddec286 Add event report dashboard, slide-out drawer, in-app comp portal + creator tracking
All checks were successful
Build Android APK / build-apk (push) Successful in 56m36s
- 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 <noreply@anthropic.com>
2026-07-13 17:30:18 +00:00
37 changed files with 2322 additions and 241 deletions

View file

@ -32,8 +32,11 @@ ENV WEB_DIR=/srv/web
ENV PORT=8080 ENV PORT=8080
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
# Run as the non-root node user shipped in the base image. # Run as the non-root node user shipped in the base image. /data is a mount
RUN chown -R node:node /srv # 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 USER node
EXPOSE 8080 EXPOSE 8080

View file

@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Camp Scan", "name": "Camp Scan",
"slug": "camptickets", "slug": "camptickets",
"version": "0.1.0", "version": "0.3.0",
"orientation": "portrait", "orientation": "portrait",
"scheme": "campscan", "scheme": "campscan",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
@ -10,7 +10,7 @@
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"android": { "android": {
"package": "top.mowden.campscan", "package": "top.mowden.campscan",
"versionCode": 1, "versionCode": 3,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0f1a12" "backgroundColor": "#0f1a12"

View file

@ -4,6 +4,7 @@ import { Stack, useRouter, useSegments } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { AuthProvider, useAuth } from "../lib/auth"; import { AuthProvider, useAuth } from "../lib/auth";
import { MenuProvider } from "../lib/menu";
import { theme } from "../lib/theme"; import { theme } from "../lib/theme";
export default function RootLayout() { export default function RootLayout() {
@ -11,7 +12,9 @@ export default function RootLayout() {
<SafeAreaProvider> <SafeAreaProvider>
<StatusBar style="light" /> <StatusBar style="light" />
<AuthProvider> <AuthProvider>
<MenuProvider>
<AuthGate /> <AuthGate />
</MenuProvider>
</AuthProvider> </AuthProvider>
</SafeAreaProvider> </SafeAreaProvider>
); );

View file

@ -4,6 +4,7 @@ import { router } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { searchTickets, redeem, getAudit, type TicketView, type AuditEntry } from "../lib/api"; import { searchTickets, redeem, getAudit, type TicketView, type AuditEntry } from "../lib/api";
import { feedbackSuccess, feedbackError } from "../lib/feedback"; import { feedbackSuccess, feedbackError } from "../lib/feedback";
import { useMenu } from "../lib/menu";
import { theme } from "../lib/theme"; import { theme } from "../lib/theme";
function fmtTime(iso: string): string { function fmtTime(iso: string): string {
@ -43,6 +44,7 @@ function AuditList({ entries }: { entries: AuditEntry[] }) {
} }
export default function AdminScreen() { export default function AdminScreen() {
const { open: openMenu } = useMenu();
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [results, setResults] = useState<TicketView[]>([]); const [results, setResults] = useState<TicketView[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -119,10 +121,8 @@ export default function AdminScreen() {
return ( return (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}> <SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}> <View style={styles.topbar}>
<Pressable onPress={() => router.replace("/")} hitSlop={10}> <Pressable onPress={openMenu} hitSlop={12}>
<Text style={styles.link} numberOfLines={1}> <Text style={styles.hamburger}></Text>
Scanner
</Text>
</Pressable> </Pressable>
<Text style={styles.brand}>Admin lookup</Text> <Text style={styles.brand}>Admin lookup</Text>
<View style={{ width: 72 }} /> <View style={{ width: 72 }} />
@ -209,7 +209,7 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti
if (e.rvParking) tags.push("🚐 RV"); if (e.rvParking) tags.push("🚐 RV");
if (e.utv) tags.push("🏍️ UTV"); if (e.utv) tags.push("🏍️ UTV");
if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total}`); 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 ( return (
<View style={styles.card}> <View style={styles.card}>
@ -276,6 +276,7 @@ const styles = StyleSheet.create({
paddingVertical: 10, paddingVertical: 10,
}, },
brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" },
link: { color: theme.textDim, fontSize: 16, fontWeight: "600" }, link: { color: theme.textDim, fontSize: 16, fontWeight: "600" },
searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 }, searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 },
input: { input: {

View file

@ -6,6 +6,7 @@ import QRScanner from "../components/QRScanner";
import ResultOverlay from "../components/ResultOverlay"; import ResultOverlay from "../components/ResultOverlay";
import { lookup, redeem, banquet, type TicketView, type DonorLookup } from "../lib/api"; import { lookup, redeem, banquet, type TicketView, type DonorLookup } from "../lib/api";
import { useAuth } from "../lib/auth"; import { useAuth } from "../lib/auth";
import { useMenu } from "../lib/menu";
import { feedbackSuccess, feedbackError } from "../lib/feedback"; import { feedbackSuccess, feedbackError } from "../lib/feedback";
import { theme } from "../lib/theme"; import { theme } from "../lib/theme";
@ -19,7 +20,8 @@ const MODES: { key: Mode; label: string; icon: string }[] = [
]; ];
export default function ScannerScreen() { export default function ScannerScreen() {
const { signOut, operator } = useAuth(); const { operator } = useAuth();
const { open: openMenu } = useMenu();
const [mode, setMode] = useState<Mode>("tickets"); const [mode, setMode] = useState<Mode>("tickets");
const [phase, setPhase] = useState<Phase>("scanning"); const [phase, setPhase] = useState<Phase>("scanning");
const [ticket, setTicket] = useState<TicketView | null>(null); const [ticket, setTicket] = useState<TicketView | null>(null);
@ -107,8 +109,9 @@ export default function ScannerScreen() {
feedbackSuccess(); feedbackSuccess();
const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining; const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining;
setTicket(res.ticket); setTicket(res.ticket);
// Ice: default to grabbing all remaining bags at once. Tickets: default 1. // Default to 1 (people usually grab ice a bag at a time); staff can bump
setCount(mode === "ice" ? Math.max(1, remaining) : Math.min(1, remaining)); // the count up. Clamp to what's left so a 0-remaining ticket stays at 0.
setCount(Math.min(1, remaining));
setPhase("confirm"); setPhase("confirm");
} catch (e: any) { } catch (e: any) {
if (e?.name === "AuthError") return router.replace("/login"); if (e?.name === "AuthError") return router.replace("/login");
@ -147,29 +150,19 @@ export default function ScannerScreen() {
} }
}, [ticket, count, mode, resume, showError]); }, [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 isIce = mode === "ice";
const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : ""; const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : "";
return ( return (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}> <SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}> <View style={styles.topbar}>
<View> <Pressable onPress={openMenu} hitSlop={12}>
<Text style={styles.hamburger}></Text>
</Pressable>
<View style={styles.titleWrap}>
<Text style={styles.brand}>🐻 Camp Scan</Text> <Text style={styles.brand}>🐻 Camp Scan</Text>
{!!operator && <Text style={styles.operator}>{operator}</Text>} {!!operator && <Text style={styles.operator}>{operator}</Text>}
</View> </View>
<View style={styles.topActions}>
<Pressable onPress={() => router.push("/admin")} hitSlop={10}>
<Text style={styles.link}>Admin</Text>
</Pressable>
<Pressable onPress={doLogout} hitSlop={10}>
<Text style={styles.link}>Sign out</Text>
</Pressable>
</View>
</View> </View>
<View style={styles.modeBar}> <View style={styles.modeBar}>
@ -260,6 +253,7 @@ export default function ScannerScreen() {
<Text style={styles.counts}> <Text style={styles.counts}>
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining {ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
</Text> </Text>
<PartyPanel ticket={ticket} />
<AdultNames names={ticket.adultNames} /> <AdultNames names={ticket.adultNames} />
<ExtrasRow ticket={ticket} /> <ExtrasRow ticket={ticket} />
</> </>
@ -354,7 +348,7 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) {
if (e.rvParking) tags.push("🚐 RV parking"); if (e.rvParking) tags.push("🚐 RV parking");
if (e.utv) tags.push("🏍️ UTV/ATV"); 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.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; if (!tags.length) return null;
return ( return (
<View style={styles.tags}> <View style={styles.tags}>
@ -373,6 +367,8 @@ const TYPE_ICON: Record<string, string> = {
Performer: "🎭", Performer: "🎭",
Volunteer: "🙌", Volunteer: "🙌",
Speaker: "🎤", Speaker: "🎤",
"Food Vendor": "🍔",
Vendor: "🛒",
}; };
function TypeBadge({ type }: { type: string }) { function TypeBadge({ type }: { type: string }) {
@ -399,6 +395,45 @@ function AdultNames({ names }: { names: string[] }) {
); );
} }
/**
* 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 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 (
<View style={styles.party}>
<View style={styles.partyRow}>
<View style={styles.partyCell}>
<Text style={styles.partyNum}>{adults}</Text>
<Text style={styles.partyLbl}>ADULTS{"\n"}18+</Text>
</View>
<View style={styles.partyDivider} />
<View style={styles.partyCell}>
<Text style={styles.partyNum}>{youth}</Text>
<Text style={styles.partyLbl}>YOUTH{"\n"}13-16</Text>
</View>
<View style={styles.partyDivider} />
<View style={styles.partyCell}>
<Text style={styles.partyNum}>{kids}</Text>
<Text style={styles.partyLbl}>KIDS{"\n"}0-12</Text>
</View>
</View>
{kidBrackets.length > 0 && (
<Text style={styles.partyDetail}>
kids: {kidBrackets.map((a) => `${a.count}× ${a.bracket.replace(/^Kids\s*/, "")}`).join(" · ")}
</Text>
)}
</View>
);
}
function ConfirmCard({ function ConfirmCard({
ticket, ticket,
isIce, isIce,
@ -427,6 +462,7 @@ function ConfirmCard({
<Text style={styles.cardName}>{ticket.name}</Text> <Text style={styles.cardName}>{ticket.name}</Text>
<TypeBadge type={ticket.ticketType} /> <TypeBadge type={ticket.ticketType} />
<Text style={styles.cardCode}>{ticket.code}</Text> <Text style={styles.cardCode}>{ticket.code}</Text>
{!isIce && <PartyPanel ticket={ticket} />}
<Text style={styles.cardCounts}> <Text style={styles.cardCounts}>
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining <Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining
</Text> </Text>
@ -474,6 +510,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 10, 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" }, brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
operator: { color: theme.textDim, fontSize: 13, marginTop: 1 }, operator: { color: theme.textDim, fontSize: 13, marginTop: 1 },
topActions: { flexDirection: "row", gap: 18, alignItems: "center" }, topActions: { flexDirection: "row", gap: 18, alignItems: "center" },
@ -538,6 +576,23 @@ const styles = StyleSheet.create({
typeBadgeText: { color: "#fff", fontSize: 20, fontWeight: "900", letterSpacing: 1 }, typeBadgeText: { color: "#fff", fontSize: 20, fontWeight: "900", letterSpacing: 1 },
namesBox: { marginTop: 12, alignItems: "center", gap: 3 }, namesBox: { marginTop: 12, alignItems: "center", gap: 3 },
nameLine: { color: "#fff", fontSize: 18, fontWeight: "600", textAlign: "center" }, nameLine: { color: "#fff", fontSize: 18, fontWeight: "600", textAlign: "center" },
party: {
alignSelf: "stretch",
backgroundColor: "#1d2a1f",
borderWidth: 2,
borderColor: theme.warn,
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: 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 }, 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" }, tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" },

308
app/app/stats.tsx Normal file
View file

@ -0,0 +1,308 @@
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<string, string> = {
Regular: "🎟️",
Guest: "🎫",
Worker: "🛠️",
Performer: "🎭",
Volunteer: "🙌",
Speaker: "🎤",
"Food Vendor": "🍔",
Vendor: "🛒",
};
const MEDAL = ["🥇", "🥈", "🥉"];
function Bar({ pct, color }: { pct: number; color?: string }) {
return (
<View style={styles.barTrack}>
<View style={[styles.barFill, { width: `${Math.min(100, Math.max(0, pct))}%`, backgroundColor: color ?? theme.successBright }]} />
</View>
);
}
function Tile({ value, label, accent }: { value: string | number; label: string; accent?: boolean }) {
return (
<View style={styles.tile}>
<Text style={[styles.tileValue, accent && { color: theme.successBright }]}>{value}</Text>
<Text style={styles.tileLabel}>{label}</Text>
</View>
);
}
export default function StatsScreen() {
const { open: openMenu } = useMenu();
const [stats, setStats] = useState<Stats | null>(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 (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}>
<Pressable onPress={openMenu} hitSlop={12}>
<Text style={styles.hamburger}></Text>
</Pressable>
<Text style={styles.brand}>Event Report</Text>
<Pressable onPress={onRefresh} hitSlop={10}>
<Text style={styles.link}></Text>
</Pressable>
</View>
{loading ? (
<ActivityIndicator color={theme.successBright} size="large" style={{ marginTop: 40 }} />
) : error ? (
<Text style={styles.error}>{error}</Text>
) : stats ? (
<ScrollView
contentContainerStyle={{ padding: 16, paddingBottom: 48 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.successBright} />}
>
{/* Hero: check-in progress */}
<View style={styles.hero}>
<Text style={styles.heroPct}>{stats.tickets.pct}%</Text>
<Text style={styles.heroSub}>checked in</Text>
<Bar pct={stats.tickets.pct} />
<Text style={styles.heroCounts}>
{stats.tickets.redeemed} of {stats.tickets.total} tickets · {stats.tickets.remaining} to go
</Text>
</View>
{/* Core tiles */}
<View style={styles.tileRow}>
<Tile value={stats.orders} label="orders" />
<Tile value={stats.tickets.total} label="tickets sold" />
<Tile value={stats.tickets.redeemed} label="checked in" accent />
<Tile value={stats.tickets.remaining} label="remaining" />
</View>
{/* Ice */}
<View style={styles.card}>
<Text style={styles.cardTitle}>🧊 Ice</Text>
<Bar pct={stats.ice.pct} color="#4fc3f7" />
<Text style={styles.cardSub}>
{stats.ice.redeemed} of {stats.ice.total} bags handed out · {stats.ice.remaining} left · {stats.ice.ticketsSold} ice tickets sold
</Text>
</View>
{/* Ticket types */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Ticket types</Text>
{stats.types.map((t) => (
<View key={t.type} style={styles.typeRow}>
<Text style={styles.typeName}>
{(TYPE_ICON[t.type] ?? "🎫") + " " + t.type}
</Text>
<View style={styles.typeBarWrap}>
<Bar pct={t.total ? (t.redeemed / t.total) * 100 : 0} />
</View>
<Text style={styles.typeCount}>
{t.redeemed}/{t.total}
<Text style={styles.typeOrders}> · {t.count}×</Text>
</Text>
</View>
))}
</View>
{/* People breakdown */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Who's coming</Text>
<View style={styles.tileRow}>
<Tile value={stats.people.adults} label="adults (paid)" />
<Tile value={stats.people.youth} label="youth 13-16 (paid)" />
<Tile value={stats.people.kids12 + stats.people.kids9} label="kids 5-12 (free)" />
<Tile value={stats.people.kids4Free} label="under 5 (free)" />
</View>
</View>
{/* Extras + donors */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Add-ons & donors</Text>
<View style={styles.chips}>
<Text style={styles.chip}>🚗 {stats.extras.carParking} parking</Text>
<Text style={styles.chip}>🚐 {stats.extras.rvParking} RV</Text>
<Text style={styles.chip}>🏍 {stats.extras.utv} UTV</Text>
<Text style={styles.chip}>🐻 {stats.donors.members} members</Text>
<Text style={styles.chip}> {stats.donors.orders} donor orders</Text>
<Text style={styles.chip}>🎟 {stats.donors.vouchers} vouchers</Text>
</View>
</View>
{/* Operator leaderboard */}
{stats.operators.length > 0 && (
<View style={styles.card}>
<Text style={styles.cardTitle}>Gate crew leaderboard</Text>
{stats.operators.slice(0, 8).map((o, i) => (
<View key={o.name} style={styles.opRow}>
<Text style={styles.opRank}>{MEDAL[i] ?? `${i + 1}.`}</Text>
<Text style={styles.opName} numberOfLines={1}>
{o.name}
</Text>
<Text style={styles.opStat}>
{o.checkins} check-ins{o.ice ? ` · ${o.ice} ice` : ""}
{o.undos ? ` · ${o.undos} undo` : ""}
</Text>
</View>
))}
</View>
)}
{/* Comp tickets issued */}
{stats.comps.total > 0 && (
<View style={styles.card}>
<Text style={styles.cardTitle}>🎟 Comp tickets issued ({stats.comps.total})</Text>
{stats.comps.byCreator.map((c) => (
<View key={c.name} style={styles.opRow}>
<Text style={styles.opName} numberOfLines={1}>
{c.name}
</Text>
<Text style={styles.opStat}>{c.count} issued</Text>
</View>
))}
</View>
)}
{/* Check-in timeline */}
{stats.checkinsByHour.length > 0 && (
<View style={styles.card}>
<Text style={styles.cardTitle}>Check-ins by hour</Text>
<View style={styles.spark}>
{stats.checkinsByHour.map((h) => (
<View key={h.hour} style={styles.sparkCol}>
<Text style={styles.sparkVal}>{h.count}</Text>
<View style={[styles.sparkBar, { height: 6 + (h.count / maxHour) * 80 }]} />
<Text style={styles.sparkLabel}>{h.hour.slice(11)}h</Text>
</View>
))}
</View>
{peakHour && (
<Text style={styles.cardSub}>Busiest hour: {peakHour.count} checked in around {peakHour.hour.slice(11)}:00</Text>
)}
</View>
)}
<Text style={styles.stamp}>Updated {new Date(stats.generatedAt).toLocaleTimeString()} · pull to refresh</Text>
</ScrollView>
) : null}
</SafeAreaView>
);
}
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 },
});

113
app/components/SideMenu.tsx Normal file
View file

@ -0,0 +1,113 @@
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";
// 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: "Banquet 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 (
<View pointerEvents={visible ? "auto" : "none"} style={StyleSheet.absoluteFill}>
<Animated.View style={[styles.scrim, { opacity: fade }]}>
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.panel, { width: panelW, transform: [{ translateX: tx }] }]}>
<View style={styles.header}>
<Text style={styles.logo}>🐻 Camp Scan</Text>
{!!operator && <Text style={styles.operator}>{operator}</Text>}
</View>
<View style={styles.items}>
{ITEMS.map((it) => {
const active = it.seg === current;
return (
<Pressable key={it.route} style={[styles.item, active && styles.itemActive]} onPress={() => go(it)}>
<Text style={styles.itemIcon}>{it.icon}</Text>
<Text style={[styles.itemText, active && styles.itemTextActive]}>{it.label}</Text>
</Pressable>
);
})}
</View>
<View style={styles.spacer} />
<Pressable
style={styles.signout}
onPress={() => {
onClose();
signOut();
}}
>
<Text style={styles.itemIcon}>🚪</Text>
<Text style={styles.signoutText}>Sign out</Text>
</Pressable>
</Animated.View>
</View>
);
}
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" },
});

View file

@ -22,6 +22,7 @@ export interface TicketView {
name: string; name: string;
email: string; email: string;
ticketType: string; ticketType: string;
createdBy: string;
total: number; total: number;
redeemed: number; redeemed: number;
remaining: number; remaining: number;
@ -35,7 +36,7 @@ export interface TicketView {
isDonor: boolean; isDonor: boolean;
donorTier: string; donorTier: string;
vouchers: number; vouchers: number;
freeUnder5: number; freeKids: number;
}; };
ages: { bracket: string; count: number; free: boolean }[]; ages: { bracket: string; count: number; free: boolean }[];
} }
@ -194,6 +195,113 @@ export interface AuditEntry {
action: "check-in" | "undo" | "ice" | "ice-undo"; 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<Stats> {
return authed<Stats>(`/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<PortalTicket> {
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;
}
// ---- Admin actions (all gated by the portal password) ----
async function adminPost<T>(path: string, password: string, extra: Record<string, unknown> = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password, ...extra }),
});
if (res.status === 401) throw new AuthError("Wrong password");
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new ApiError(body?.detail ?? body?.error ?? `Request failed (${res.status})`);
return body as T;
}
export interface AdminStatus {
tickets: { tableId: string; count: number };
audit: { tableId: string | null; count: number; enabled: boolean };
defaults: { ticketsTableId: string; auditTableId: string | null };
}
export function adminStatus(password: string): Promise<AdminStatus> {
return adminPost<AdminStatus>("/api/admin/status", password);
}
export function adminWipe(password: string): Promise<{ ok: boolean; ticketsDeleted: number; auditDeleted: number }> {
return adminPost("/api/admin/wipe", password);
}
export function adminSwitchTable(
password: string,
ticketsTableId: string,
auditTableId?: string,
): Promise<{ ok: boolean; tickets: { tableId: string }; audit: { tableId: string | null } }> {
return adminPost("/api/admin/switch-table", password, { ticketsTableId, auditTableId });
}
export interface DonorSearchResult {
name: string;
bearName: string;
email: string;
altEmail: string;
phone: string;
address: string;
lifetime: number | null;
tags: string[];
source: "master" | "transactions";
}
export function adminDonorSearch(password: string, query: string): Promise<{ results: DonorSearchResult[]; query: string }> {
return adminPost("/api/admin/donor-search", password, { query });
}
export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{ export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{
enabled: boolean; enabled: boolean;
entries: AuditEntry[]; entries: AuditEntry[];

21
app/lib/menu.tsx Normal file
View file

@ -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<MenuState>({ open: () => {}, close: () => {} });
export function MenuProvider({ children }: { children: ReactNode }) {
const [visible, setVisible] = useState(false);
return (
<Ctx.Provider value={{ open: () => setVisible(true), close: () => setVisible(false) }}>
{children}
<SideMenu visible={visible} onClose={() => setVisible(false)} />
</Ctx.Provider>
);
}
export const useMenu = () => useContext(Ctx);

View file

@ -10,6 +10,10 @@ const schema = z.object({
// Optional "2026 Ticket Audit Logs" table. If unset, audit logging is skipped. // Optional "2026 Ticket Audit Logs" table. If unset, audit logging is skipped.
NOCODB_AUDIT_TABLE_ID: z.string().optional(), 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 // 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 // disabled. Online/offline are used as a fallback when a donor is not in the
// master list. // master list.
@ -27,7 +31,17 @@ const schema = z.object({
// Disabled unless a secret is set. Returns only eligibility + tier, never // Disabled unless a secret is set. Returns only eligibility + tier, never
// names or dollar amounts. Rate-limited + CORS-restricted. // names or dollar amounts. Rate-limited + CORS-restricted.
PUBLIC_LOOKUP_SECRET: z.string().optional(), 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 // Ticket-voucher entitlement: donations on/after VOUCHER_SINCE totalling
// >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the date each year. // >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the date each year.

View file

@ -4,6 +4,7 @@ import { Mailer } from "./services/mailer.js";
import { RedeemQueue } from "./services/redeemQueue.js"; import { RedeemQueue } from "./services/redeemQueue.js";
import { AuditLogger } from "./services/audit.js"; import { AuditLogger } from "./services/audit.js";
import { DonorService } from "./services/donors.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. */ /** Shared services wired once at startup and hung off the Fastify instance. */
export interface AppContext { export interface AppContext {
@ -16,12 +17,23 @@ export interface AppContext {
} }
export function buildContext(config: Config): 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 { return {
config, config,
nocodb: new NocoDBClient(config), nocodb,
mailer: new Mailer(config), mailer: new Mailer(config),
queue: new RedeemQueue(), queue: new RedeemQueue(),
audit: new AuditLogger(config), audit,
donors: new DonorService(config), donors: new DonorService(config),
}; };
} }

View file

@ -25,6 +25,7 @@ export const COL = {
iceAccess: "Ice Access", iceAccess: "Ice Access",
paymentMethod: "Payment Method", paymentMethod: "Payment Method",
ticketType: "Ticket Type", // "" for regular; Guest/Worker/Performer/Volunteer/Speaker for portal comps 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: // Columns this system manages:
code: "Ticket Code", code: "Ticket Code",
@ -50,11 +51,17 @@ function bool(v: unknown): boolean {
} }
/** /**
* Total scannable tickets = everyone except kids 0-4 (who are free): * Total scannable (paid) tickets = adults + youth 13-16. Children 12 and under
* adults + youth (13-16) + kids 10-12 + kids 5-9. * (kids 10-12 / 5-9 / 0-4) are admitted free and not counted; charging starts
* at age 13.
*/ */
export function computeTotal(rec: NocoRecord): number { 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 { export function computeIceTotal(rec: NocoRecord): number {
@ -66,8 +73,8 @@ export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number;
return [ return [
{ bracket: "Adults", count: num(rec[COL.adults]), free: false }, { bracket: "Adults", count: num(rec[COL.adults]), free: false },
{ bracket: "Youth 13-16", count: num(rec[COL.youth]), 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 10-12", count: num(rec[COL.kids12]), free: true },
{ bracket: "Kids 5-9", count: num(rec[COL.kids9]), free: false }, { bracket: "Kids 5-9", count: num(rec[COL.kids9]), free: true },
{ bracket: "Kids 0-4", count: num(rec[COL.kids4]), free: true }, { bracket: "Kids 0-4", count: num(rec[COL.kids4]), free: true },
].filter((b) => b.count > 0); ].filter((b) => b.count > 0);
} }
@ -96,6 +103,7 @@ export interface TicketView {
name: string; name: string;
email: string; email: string;
ticketType: string; // "" for regular; Guest/Worker/... for special tickets ticketType: string; // "" for regular; Guest/Worker/... for special tickets
createdBy: string; // who issued a comp ticket
total: number; total: number;
redeemed: number; redeemed: number;
remaining: number; remaining: number;
@ -109,7 +117,7 @@ export interface TicketView {
isDonor: boolean; isDonor: boolean;
donorTier: string; donorTier: string;
vouchers: number; vouchers: number;
freeUnder5: number; freeKids: number; // children 12 & under (free admission)
}; };
ages: { bracket: string; count: number; free: boolean }[]; ages: { bracket: string; count: number; free: boolean }[];
} }
@ -124,6 +132,7 @@ export function toView(rec: NocoRecord): TicketView {
name: String(rec[COL.name] ?? ""), name: String(rec[COL.name] ?? ""),
email: String(rec[COL.email] ?? ""), email: String(rec[COL.email] ?? ""),
ticketType: String(rec[COL.ticketType] ?? ""), ticketType: String(rec[COL.ticketType] ?? ""),
createdBy: String(rec[COL.createdBy] ?? ""),
total, total,
redeemed, redeemed,
remaining: Math.max(0, total - redeemed), remaining: Math.max(0, total - redeemed),
@ -141,7 +150,7 @@ export function toView(rec: NocoRecord): TicketView {
isDonor: bool(rec[COL.isDonor]), isDonor: bool(rec[COL.isDonor]),
donorTier: String(rec[COL.donorTier] ?? ""), donorTier: String(rec[COL.donorTier] ?? ""),
vouchers: num(rec[COL.vouchers]), vouchers: num(rec[COL.vouchers]),
freeUnder5: num(rec[COL.kids4]), freeKids: freeKidsCount(rec),
}, },
ages: ageBreakdown(rec), ages: ageBreakdown(rec),
}; };

View file

@ -0,0 +1,96 @@
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<string, any>, 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;
}
const NUMBER_WORDS: Record<string, number> = { 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<string, any>): { 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 };
}

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

@ -0,0 +1,122 @@
import { timingSafeEqual } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { saveActiveTables } from "../services/state.js";
function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a || "");
const bb = Buffer.from(b || "");
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
/**
* Admin actions for the /crush33 area all gated by the same PORTAL_PASSWORD
* that unlocks the portal. POST-only so the password never lands in a URL/log.
*
* POST /api/admin/status -> current event tables + record counts
* POST /api/admin/wipe -> delete all ticket + audit records
* POST /api/admin/switch-table -> point the app at different event table(s)
* POST /api/admin/donor-search -> admin-only donor directory search (PII)
*/
export async function adminRoutes(app: FastifyInstance): Promise<void> {
const cfg = app.ctx.config;
const gate = (req: any, reply: any): boolean => {
if (!cfg.PORTAL_PASSWORD) {
reply.code(404).send({ error: "admin_disabled" });
return false;
}
const pw = (req.body ?? {}).password;
if (typeof pw !== "string" || !safeEqual(pw, cfg.PORTAL_PASSWORD)) {
reply.code(401).send({ error: "bad_password" });
return false;
}
return true;
};
const rl = { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } };
app.post("/api/admin/status", rl, async (req, reply) => {
if (!gate(req, reply)) return;
const [tickets, audit] = await Promise.all([
app.ctx.nocodb.count().catch(() => -1),
app.ctx.audit.count().catch(() => -1),
]);
return {
tickets: { tableId: app.ctx.nocodb.tableId, count: tickets },
audit: { tableId: app.ctx.audit.currentTableId, count: audit, enabled: app.ctx.audit.enabled },
// What .env would use if the override were cleared (for reference).
defaults: { ticketsTableId: cfg.NOCODB_TABLE_ID, auditTableId: cfg.NOCODB_AUDIT_TABLE_ID ?? null },
};
});
app.post("/api/admin/wipe", rl, async (req, reply) => {
if (!gate(req, reply)) return;
let ticketsDeleted = 0;
let auditDeleted = 0;
try {
ticketsDeleted = await app.ctx.nocodb.deleteAll();
} catch (e: any) {
return reply.code(502).send({ error: "wipe_failed", detail: e?.message });
}
try {
auditDeleted = await app.ctx.audit.deleteAll();
} catch {
// Audit wipe is best-effort; tickets are the important part.
}
req.log.warn({ ticketsDeleted, auditDeleted }, "admin: wiped slate");
return { ok: true, ticketsDeleted, auditDeleted };
});
app.post("/api/admin/switch-table", rl, async (req, reply) => {
if (!gate(req, reply)) return;
const b = (req.body ?? {}) as { ticketsTableId?: string; auditTableId?: string };
const ticketsTableId = String(b.ticketsTableId ?? "").trim();
const auditTableId = String(b.auditTableId ?? "").trim();
if (!ticketsTableId) {
return reply.code(400).send({ error: "missing_tickets_table" });
}
// Validate the new tickets table is reachable and has an Id primary key —
// switching to a PK-less table would make check-in updates hit every row.
const probe = await app.ctx.nocodb.probeTable(ticketsTableId);
if (!probe.ok) {
return reply.code(400).send({ error: "tickets_table_unreachable", status: probe.status });
}
if (!probe.hasIdPk) {
return reply.code(400).send({ error: "tickets_table_no_id_pk" });
}
if (auditTableId) {
const ap = await app.ctx.nocodb.probeTable(auditTableId);
if (!ap.ok) return reply.code(400).send({ error: "audit_table_unreachable", status: ap.status });
}
// Hot-swap the live clients, then persist so it survives a redeploy.
app.ctx.nocodb.setTableId(ticketsTableId);
app.ctx.audit.setTableId(auditTableId || app.ctx.audit.currentTableId);
saveActiveTables(cfg.STATE_DIR, {
ticketsTableId,
auditTableId: auditTableId || app.ctx.audit.currentTableId || undefined,
});
req.log.warn({ ticketsTableId, auditTableId }, "admin: switched event table");
return {
ok: true,
tickets: { tableId: app.ctx.nocodb.tableId },
audit: { tableId: app.ctx.audit.currentTableId },
};
});
app.post("/api/admin/donor-search", rl, async (req, reply) => {
if (!gate(req, reply)) return;
if (!app.ctx.donors.enabled) return reply.code(404).send({ error: "donors_unavailable" });
const q = String(((req.body ?? {}) as { query?: string }).query ?? "").trim();
if (q.length < 2) return { results: [], query: q };
try {
const results = await app.ctx.donors.search(q, 40);
return { results, query: q };
} catch (e: any) {
req.log.error({ err: e }, "admin: donor search failed");
return reply.code(502).send({ error: "search_failed", detail: e?.message });
}
});
}

View file

@ -22,6 +22,21 @@ export async function portalRoutes(app: FastifyInstance): Promise<void> {
reply.type("text/html").send(PAGE); 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( app.post(
"/api/portal/create-ticket", "/api/portal/create-ticket",
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, { config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
@ -29,13 +44,21 @@ export async function portalRoutes(app: FastifyInstance): Promise<void> {
const cfg = app.ctx.config; const cfg = app.ctx.config;
if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" }); 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)) { if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) {
return reply.code(401).send({ error: "bad_password" }); return reply.code(401).send({ error: "bad_password" });
} }
const name = String(b.name ?? "").trim(); const name = String(b.name ?? "").trim();
const email = String(b.email ?? "").trim(); const email = String(b.email ?? "").trim();
const type = TYPES.includes(String(b.type)) ? String(b.type) : "Guest"; 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) { if (!name || !email) {
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" }); 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<void> {
adultNames: [name], adultNames: [name],
email, email,
ticketType: type, ticketType: type,
createdBy,
counts: { adults: 1, youth: 0, kids12: 0, kids9: 0, kids4: 0 }, counts: { adults: 1, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
submissionKey: `portal:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`, submissionKey: `portal:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`,
}); });
@ -79,92 +103,350 @@ const PAGE = `<!doctype html>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f1a12" /> <meta name="theme-color" content="#0f1a12" />
<title>Camp Scan Comp Tickets</title> <title>Camp Scan Admin (crush33)</title>
<style> <style>
:root { color-scheme: dark; } :root { color-scheme: dark; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; } body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; }
.wrap { max-width: 460px; margin: 0 auto; padding: 28px 20px 64px; } a { color: #58d68d; }
header { text-align: center; margin-bottom: 22px; }
.logo { font-size: 52px; }
h1 { font-size: 22px; margin: 8px 0 2px; }
.sub { color: #9db3a4; font-size: 14px; margin: 0; }
label { display: block; font-size: 13px; color: #9db3a4; margin: 14px 0 5px; } label { display: block; font-size: 13px; color: #9db3a4; margin: 14px 0 5px; }
input, select { width: 100%; background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 14px; color: #eaf2ec; font-size: 16px; } input, select { width: 100%; background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 12px 14px; color: #eaf2ec; font-size: 15px; }
button { width: 100%; background: #25c05a; color: #06210f; font-weight: 800; font-size: 18px; border: none; padding: 15px; border-radius: 13px; margin-top: 18px; } .btn { background: #25c05a; color: #06210f; font-weight: 800; font-size: 16px; border: none; padding: 13px 18px; border-radius: 12px; cursor: pointer; }
button:disabled { opacity: 0.5; } .btn:disabled { opacity: 0.5; cursor: default; }
.msg { margin-top: 14px; font-size: 15px; font-weight: 600; text-align: center; min-height: 20px; } .btn-red { background: #e04343; color: #fff; }
.err { color: #e04343; } .btn-ghost { background: transparent; color: #eaf2ec; border: 1px solid #2e7d32; }
.ok { color: #58d68d; } .msg { margin-top: 12px; font-size: 14px; font-weight: 600; min-height: 18px; }
.result { display: none; text-align: center; margin-top: 18px; background: #16241a; border: 1px solid #24382a; border-radius: 14px; padding: 18px; } .err { color: #e04343; } .ok { color: #58d68d; }
.result img { width: 220px; height: 220px; background: #fff; border-radius: 10px; padding: 8px; } .mono { font-family: ui-monospace, Menlo, monospace; }
.result .code { font-family: ui-monospace, Menlo, monospace; font-size: 20px; letter-spacing: 2px; margin: 12px 0 4px; color: #58d68d; }
.result .who { font-size: 16px; color: #c4d6c9; } /* Unlock */
.hint { color: #6c8f74; font-size: 12px; text-align: center; margin-top: 10px; } #unlock { max-width: 420px; margin: 0 auto; padding: 48px 20px; text-align: center; }
#unlock .logo { font-size: 52px; }
#unlock h1 { font-size: 22px; margin: 8px 0 4px; }
#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; }
.top { display: flex; align-items: center; justify-content: space-between; padding: 12px 18px; border-bottom: 1px solid #24382a; }
.top .brand { font-weight: 800; font-size: 17px; }
.top .lock { color: #9db3a4; font-size: 13px; cursor: pointer; }
.layout { display: flex; align-items: flex-start; }
.side { width: 190px; flex: none; border-right: 1px solid #24382a; padding: 12px 0; min-height: calc(100vh - 50px); }
.nav { display: flex; align-items: center; gap: 10px; padding: 13px 18px; color: #9db3a4; cursor: pointer; border-left: 3px solid transparent; font-weight: 700; font-size: 15px; }
.nav .i { font-size: 18px; }
.nav.on { color: #eaf2ec; background: #16241a; border-left-color: #2e7d32; }
.main { flex: 1; padding: 22px 26px 64px; max-width: 760px; }
.sec { display: none; }
.sec.on { display: block; }
h2 { font-size: 22px; margin: 0 0 4px; }
.lead { color: #9db3a4; font-size: 14px; margin: 0 0 8px; line-height: 1.5; }
.card { background: #16241a; border: 1px solid #24382a; border-radius: 14px; padding: 16px; margin-top: 14px; }
.pills { display: flex; flex-wrap: wrap; gap: 8px; }
.pill { border: 1px solid #24382a; background: #16241a; border-radius: 999px; padding: 8px 14px; cursor: pointer; font-weight: 700; font-size: 14px; color: #9db3a4; }
.pill.on { background: #2e7d32; border-color: #2e7d32; color: #fff; }
.row { display: flex; gap: 8px; align-items: center; }
.result { display: none; text-align: center; margin-top: 16px; }
.result img { width: 200px; height: 200px; background: #fff; border-radius: 10px; padding: 8px; }
.result .code { font-size: 20px; letter-spacing: 2px; margin: 10px 0 2px; color: #58d68d; }
/* Donor cards */
.donor { background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 13px 15px; margin-top: 11px; }
.donor .h { display: flex; justify-content: space-between; align-items: center; }
.donor .nm { font-weight: 800; font-size: 16px; }
.donor .amt { color: #58d68d; font-weight: 800; }
.donor .ln { color: #9db3a4; font-size: 14px; margin-top: 3px; }
.tags { margin-top: 8px; }
.tag { display: inline-block; background: #1b5e20; color: #fff; border-radius: 6px; padding: 2px 7px; font-size: 12px; margin-right: 5px; }
.src { display: inline-block; border: 1px solid #24382a; border-radius: 6px; padding: 2px 6px; font-size: 11px; color: #9db3a4; text-transform: uppercase; margin-right: 5px; }
/* Danger */
.danger { background: #241717; border: 1px solid #8f1d1d; border-radius: 14px; padding: 16px; margin-top: 18px; }
.danger h3 { color: #ff9a9a; margin: 0 0 6px; font-size: 17px; }
.danger p, .danger li { color: #e9cfcf; font-size: 14px; line-height: 1.5; }
.danger ul { margin: 6px 0 0; padding-left: 20px; }
.status { background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 14px; }
.status .k { color: #9db3a4; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
.status .v { font-size: 14px; margin-top: 5px; }
/* Modal */
.scrim { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.72); align-items: center; justify-content: center; padding: 20px; z-index: 10; }
.scrim.on { display: flex; }
.modal { background: #1a1010; border: 2px solid #e04343; border-radius: 18px; padding: 22px; max-width: 420px; width: 100%; }
.modal .warn { font-size: 40px; text-align: center; }
.modal h3 { text-align: center; margin: 4px 0 12px; font-size: 20px; }
.modal pre { white-space: pre-wrap; color: #f0d9d9; font-size: 14px; line-height: 1.55; font-family: inherit; margin: 0; }
.modal .btn { width: 100%; margin-top: 16px; }
.modal .cancel { width: 100%; margin-top: 8px; background: transparent; border: none; color: #9db3a4; font-weight: 700; font-size: 15px; padding: 12px; cursor: pointer; }
@media (max-width: 640px) {
.side { width: 74px; }
.nav { flex-direction: column; gap: 3px; padding: 12px 4px; font-size: 11px; text-align: center; }
.main { padding: 18px 14px 48px; }
}
</style> </style>
</head> </head>
<body> <body>
<div class="wrap"> <div id="unlock">
<header>
<div class="logo">🐻</div> <div class="logo">🐻</div>
<h1>Comp Ticket Portal</h1> <h1>Admin · crush33</h1>
<p class="sub">Entry-only tickets for workers &amp; guests</p> <p class="sub">Admin-only area. Enter the shared portal password.</p>
</header> <input id="pw" type="password" autocomplete="current-password" placeholder="Portal password" />
<button class="btn" id="unlockBtn">Unlock</button>
<label>Portal password</label> <div id="unlockMsg" class="msg" style="text-align:center"></div>
<input id="pw" type="password" autocomplete="current-password" placeholder="Shared admin password" /> <a class="backlink" href="/"> Back to the scan app</a>
</div>
<div id="hub">
<div class="top">
<a class="backlink top-back" href="/"> Scanner</a>
<div class="brand">🐻 Admin · crush33</div>
<div class="lock" id="relock">Lock 🔒</div>
</div>
<div class="layout">
<div class="side">
<div class="nav on" data-sec="comp"><span class="i">🎟</span> Comp tickets</div>
<div class="nav" data-sec="donors"><span class="i">🔎</span> Donor lookup</div>
<div class="nav" data-sec="actions"><span class="i"></span> Actions</div>
</div>
<div class="main">
<!-- Comp -->
<div class="sec on" id="sec-comp">
<h2>Comp tickets</h2>
<p class="lead">Entry-only tickets for guests &amp; staff.</p>
<label>Ticket type</label> <label>Ticket type</label>
<select id="type"> <div class="pills" id="typePills">
<option>Guest</option><option>Worker</option><option>Performer</option> <span class="pill on">🎫 Guest</span><span class="pill">🛠 Worker</span><span class="pill">🎭 Performer</span><span class="pill">🙌 Volunteer</span><span class="pill">🎤 Speaker</span>
<option>Volunteer</option><option>Speaker</option> </div>
</select>
<label>Full name</label> <label>Full name</label>
<input id="name" type="text" autocomplete="off" placeholder="Attendee name" /> <input id="cName" type="text" autocomplete="off" placeholder="Attendee name" />
<label>Email</label> <label>Email</label>
<input id="email" type="email" autocomplete="off" autocapitalize="none" placeholder="Where to send the ticket" /> <input id="cEmail" type="email" autocomplete="off" autocapitalize="none" placeholder="Where to send the ticket" />
<button class="btn" id="cGo" style="width:100%;margin-top:18px">Create ticket</button>
<div id="cMsg" class="msg"></div>
<div id="cResult" class="result">
<img id="cQr" alt="Ticket QR" />
<div class="code mono" id="cCode"></div>
<div class="lead" id="cWho"></div>
<div class="lead" id="cMail"></div>
</div>
</div>
<button id="go">Create ticket</button> <!-- Donors -->
<div id="msg" class="msg"></div> <div class="sec" id="sec-donors">
<h2>Donor lookup</h2>
<p class="lead">🔒 Admin only · private donor info. Search by name, email, phone, address, bear name</p>
<div class="row">
<input id="dQ" type="text" autocomplete="off" placeholder="Search donors…" style="flex:1" />
<button class="btn" id="dGo">Search</button>
</div>
<div id="dMsg" class="msg"></div>
<div id="dResults"></div>
</div>
<div id="result" class="result"> <!-- Actions -->
<img id="qr" alt="Ticket QR" /> <div class="sec" id="sec-actions">
<div class="code" id="rcode"></div> <h2>Actions</h2>
<div class="who" id="rwho"></div> <p class="lead">Event-management tools. These change live data read the warnings.</p>
<div class="hint" id="rmail"></div> <div class="status">
<button id="another" style="background:transparent;color:#eaf2ec;border:1px solid #2e7d32;font-size:15px;">Create another</button> <div class="k">Active event table <span id="aRefresh" style="float:right;cursor:pointer"></span></div>
<div class="v mono" id="aStatus">loading</div>
</div>
<div id="aMsg" class="msg"></div>
<div class="danger">
<h3>🧹 Wipe the slate clean</h3>
<p>Permanently deletes <b>every ticket and every check-in</b> in the active event table. Use before a run-through or a fresh event.</p>
<ul><li>Does NOT affect donor data.</li><li>Cannot be undone.</li></ul>
<button class="btn btn-red" id="wipeBtn" style="width:100%">Wipe slate</button>
</div>
<div class="danger">
<h3>🔀 Switch event table</h3>
<p>Point the scanner at a <b>different NocoDB table</b> start a new event on a fresh table while keeping the current one intact.</p>
<ul><li>Create the new table first (duplicate the current one's structure in NocoDB — keep the Id column).</li><li>The current event's data is NOT deleted, just no longer shown.</li></ul>
<label>New tickets table ID</label>
<input id="swTickets" type="text" autocomplete="off" placeholder="e.g. mv1a2b3c…" />
<label>New audit table ID (optional)</label>
<input id="swAudit" type="text" autocomplete="off" placeholder="leave blank to keep current" />
<button class="btn btn-red" id="switchBtn" style="width:100%;margin-top:14px">Switch table</button>
</div>
</div>
</div>
</div>
</div>
<div class="scrim" id="scrim">
<div class="modal">
<div class="warn"></div>
<h3 id="mTitle"></h3>
<pre id="mBody"></pre>
<button class="btn btn-red" id="mConfirm"></button>
<button class="cancel" id="mCancel">Cancel</button>
</div> </div>
</div> </div>
<script> <script>
var $ = function (id) { return document.getElementById(id); }; var $ = function (id) { return document.getElementById(id); };
function setMsg(t, ok) { var m = $("msg"); m.textContent = t; m.className = "msg " + (ok ? "ok" : "err"); } var PW = "";
var counts = { tickets: "?", audit: "?", table: "?" };
var pendingAction = null;
$("go").addEventListener("click", function () { function api(path, body) {
var pw = $("pw").value, name = $("name").value.trim(), email = $("email").value.trim(), type = $("type").value; return fetch(path, { method: "POST", headers: { "Content-Type": "application/json" },
if (!pw) return setMsg("Enter the portal password."); body: JSON.stringify(Object.assign({ password: PW }, body || {})) })
if (!name || !email) return setMsg("Name and email are required."); .then(function (r) { return r.json().then(function (d) { return { s: r.status, d: d }; }); });
$("go").disabled = true; setMsg("Creating…", true); }
fetch("/api/portal/create-ticket", { function relock(m) { PW = ""; $("hub").style.display = "none"; $("unlock").style.display = "block";
method: "POST", headers: { "Content-Type": "application/json" }, $("unlockMsg").textContent = m || ""; $("unlockMsg").className = "msg err"; }
body: JSON.stringify({ password: pw, name: name, email: email, type: type })
}).then(function (r) { return r.json().then(function (d) { return { s: r.status, d: d }; }); }) // ---- Unlock ----
.then(function (x) { function unlock() {
$("go").disabled = false; var pw = $("pw").value;
if (x.s === 401) return setMsg("Wrong password."); if (!pw) { $("unlockMsg").textContent = "Enter the password."; $("unlockMsg").className = "msg err"; return; }
if (x.s !== 200 || !x.d.ok) return setMsg(x.d.detail || x.d.error || "Failed to create ticket."); $("unlockBtn").disabled = true; $("unlockMsg").textContent = "Checking…"; $("unlockMsg").className = "msg ok";
setMsg(""); fetch("/api/portal/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: pw }) })
$("qr").src = x.d.qr; $("rcode").textContent = x.d.code; .then(function (r) { return r.status; })
$("rwho").textContent = x.d.type + " · " + x.d.name; .then(function (s) {
$("rmail").textContent = x.d.emailSent ? "Emailed to " + email : "Email not sent — show/screenshot this QR."; $("unlockBtn").disabled = false;
$("result").style.display = "block"; if (s !== 200) { $("unlockMsg").textContent = "Wrong password."; $("unlockMsg").className = "msg err"; return; }
$("name").value = ""; $("email").value = ""; PW = pw; $("unlockMsg").textContent = ""; $("unlock").style.display = "none"; $("hub").style.display = "block";
loadStatus();
}) })
.catch(function () { $("go").disabled = false; setMsg("Network error."); }); .catch(function () { $("unlockBtn").disabled = false; $("unlockMsg").textContent = "Network error."; });
}
$("unlockBtn").addEventListener("click", unlock);
$("pw").addEventListener("keydown", function (e) { if (e.key === "Enter") unlock(); });
$("relock").addEventListener("click", function () { relock(""); $("unlockMsg").textContent = ""; });
// ---- Nav ----
var navs = document.querySelectorAll(".nav");
for (var i = 0; i < navs.length; i++) navs[i].addEventListener("click", function () {
var sec = this.getAttribute("data-sec");
for (var j = 0; j < navs.length; j++) navs[j].classList.toggle("on", navs[j] === this);
var secs = document.querySelectorAll(".sec");
for (var k = 0; k < secs.length; k++) secs[k].classList.toggle("on", secs[k].id === "sec-" + sec);
}.bind(navs[i]));
// ---- Comp ----
var compType = "Guest";
var pills = document.querySelectorAll("#typePills .pill");
for (var p = 0; p < pills.length; p++) pills[p].addEventListener("click", function () {
for (var q = 0; q < pills.length; q++) pills[q].classList.toggle("on", pills[q] === this);
compType = this.textContent.replace(/^[^A-Za-z]+/, "").trim();
}.bind(pills[p]));
function setC(t, ok) { $("cMsg").textContent = t; $("cMsg").className = "msg " + (ok ? "ok" : "err"); }
$("cGo").addEventListener("click", function () {
var name = $("cName").value.trim(), email = $("cEmail").value.trim();
if (!name || !email) return setC("Name and email are required.");
$("cGo").disabled = true; setC("Creating…", true);
api("/api/portal/create-ticket", { name: name, email: email, type: compType }).then(function (x) {
$("cGo").disabled = false;
if (x.s === 401) return relock("Password changed — unlock again.");
if (x.s !== 200 || !x.d.ok) return setC(x.d.detail || x.d.error || "Failed.");
setC("");
$("cQr").src = x.d.qr; $("cCode").textContent = x.d.code;
$("cWho").textContent = x.d.type + " · " + x.d.name;
$("cMail").textContent = x.d.emailSent ? "Emailed to " + email : "Email not sent — screenshot this QR.";
$("cResult").style.display = "block"; $("cName").value = ""; $("cEmail").value = "";
}).catch(function () { $("cGo").disabled = false; setC("Network error."); });
}); });
$("another").addEventListener("click", function () { $("result").style.display = "none"; $("name").focus(); });
// ---- Donors ----
function esc(s) { return String(s == null ? "" : s).replace(/[&<>]/g, function (c) { return c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;"; }); }
function money(n) { return "$" + Math.round(n).toLocaleString(); }
function searchDonors() {
var q = $("dQ").value.trim();
if (q.length < 2) { $("dMsg").textContent = "Type at least 2 characters."; $("dMsg").className = "msg err"; return; }
$("dGo").disabled = true; $("dMsg").textContent = "Searching…"; $("dMsg").className = "msg ok"; $("dResults").innerHTML = "";
api("/api/admin/donor-search", { query: q }).then(function (x) {
$("dGo").disabled = false;
if (x.s === 401) return relock("Password changed — unlock again.");
if (x.s !== 200) { $("dMsg").textContent = (x.d && (x.d.detail || x.d.error)) || "Search failed."; $("dMsg").className = "msg err"; return; }
var r = x.d.results || [];
$("dMsg").textContent = r.length ? r.length + " result" + (r.length === 1 ? "" : "s") : "No donors match “" + q + "”.";
$("dMsg").className = "msg";
var html = "";
for (var i = 0; i < r.length; i++) {
var d = r[i];
html += '<div class="donor"><div class="h"><span class="nm">' + esc(d.name || d.email || "(unnamed)") + '</span>';
if (d.lifetime != null) html += '<span class="amt">' + money(d.lifetime) + '</span>';
html += '</div>';
if (d.bearName) html += '<div class="ln">🐻 ' + esc(d.bearName) + '</div>';
if (d.email) html += '<div class="ln">✉️ ' + esc(d.email) + '</div>';
if (d.altEmail) html += '<div class="ln">✉️ ' + esc(d.altEmail) + ' (alt)</div>';
if (d.phone) html += '<div class="ln">📞 ' + esc(d.phone) + '</div>';
if (d.address) html += '<div class="ln">🏠 ' + esc(d.address) + '</div>';
html += '<div class="tags"><span class="src">' + (d.source === "master" ? "directory" : "transactions") + '</span>';
for (var t = 0; t < (d.tags || []).length; t++) html += '<span class="tag">' + esc(d.tags[t]) + '</span>';
html += '</div></div>';
}
$("dResults").innerHTML = html;
}).catch(function () { $("dGo").disabled = false; $("dMsg").textContent = "Network error."; $("dMsg").className = "msg err"; });
}
$("dGo").addEventListener("click", searchDonors);
$("dQ").addEventListener("keydown", function (e) { if (e.key === "Enter") searchDonors(); });
// ---- Actions ----
function loadStatus() {
$("aStatus").textContent = "loading…";
api("/api/admin/status", {}).then(function (x) {
if (x.s === 401) return relock("Password changed — unlock again.");
if (x.s !== 200) { $("aStatus").textContent = "error"; return; }
var t = x.d.tickets, a = x.d.audit;
counts = { tickets: t.count, audit: a.count, table: t.tableId };
$("aStatus").textContent = "tickets: " + t.tableId + " · " + t.count + " records\\naudit: " + (a.tableId || "—") + " · " + a.count + " records";
}).catch(function () { $("aStatus").textContent = "network error"; });
}
$("aRefresh").addEventListener("click", loadStatus);
function aMsg(t, ok) { $("aMsg").textContent = t; $("aMsg").className = "msg " + (ok ? "ok" : "err"); }
function openModal(title, body, confirmLabel, action) {
$("mTitle").textContent = title; $("mBody").textContent = body;
$("mConfirm").textContent = confirmLabel; pendingAction = action; $("scrim").classList.add("on");
}
function closeModal() { $("scrim").classList.remove("on"); pendingAction = null; $("mConfirm").disabled = false; }
$("mCancel").addEventListener("click", closeModal);
$("mConfirm").addEventListener("click", function () { if (pendingAction) { $("mConfirm").disabled = true; pendingAction(); } });
$("wipeBtn").addEventListener("click", function () {
openModal("Wipe the slate clean?",
"This will PERMANENTLY DELETE all data in the active event table:\\n" +
"• " + counts.tickets + " ticket records (" + counts.table + ")\\n" +
"• " + counts.audit + " check-in / audit records\\n\\n" +
"Donor data is not touched. This CANNOT be undone.",
"Yes, delete everything", doWipe);
});
function doWipe() {
api("/api/admin/wipe", {}).then(function (x) {
closeModal();
if (x.s === 401) return relock("Password changed — unlock again.");
if (x.s !== 200 || !x.d.ok) return aMsg((x.d && (x.d.detail || x.d.error)) || "Wipe failed.");
aMsg("✓ Wiped " + x.d.ticketsDeleted + " tickets and " + x.d.auditDeleted + " audit rows.", true);
loadStatus();
}).catch(function () { closeModal(); aMsg("Network error."); });
}
$("switchBtn").addEventListener("click", function () {
var t = $("swTickets").value.trim(), a = $("swAudit").value.trim();
if (!t) return aMsg("Enter the new tickets table ID.");
openModal("Switch the active event table?",
"The scanner will start using:\\n• tickets → " + t + "\\n• audit → " + (a || "unchanged") + "\\n\\n" +
"The current event (" + counts.table + ", " + counts.tickets + " records) stays intact but will no longer be shown until you switch back. New purchases and scans go to the new table.",
"Yes, switch table", function () { doSwitch(t, a); });
});
function doSwitch(t, a) {
api("/api/admin/switch-table", { ticketsTableId: t, auditTableId: a || undefined }).then(function (x) {
closeModal();
if (x.s === 401) return relock("Password changed — unlock again.");
if (x.s !== 200 || !x.d.ok) return aMsg((x.d && (x.d.detail || x.d.error)) || "Switch failed.");
aMsg("✓ Now using tickets table " + x.d.tickets.tableId + ".", true);
$("swTickets").value = ""; $("swAudit").value = ""; loadStatus();
}).catch(function () { closeModal(); aMsg("Network error."); });
}
</script> </script>
</body> </body>
</html>`; </html>`;

View file

@ -17,17 +17,21 @@ function safeEqual(a: string, b: string): boolean {
*/ */
export async function publicLookupRoutes(app: FastifyInstance): Promise<void> { export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
const cfg = app.ctx.config; 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("Access-Control-Allow-Origin", origin);
reply.header("Vary", "Origin"); reply.header("Vary", "Origin");
reply.header("Access-Control-Allow-Methods", "GET, OPTIONS"); reply.header("Access-Control-Allow-Methods", "GET, OPTIONS");
}; };
// Preflight (in case the form sends one). // Preflight (in case the form sends one).
const preflight = async (_req: any, reply: any) => { const preflight = async (req: any, reply: any) => {
cors(reply); cors(req, reply);
return reply.code(204).send(); return reply.code(204).send();
}; };
app.options("/api/public/donor-eligibility", preflight); app.options("/api/public/donor-eligibility", preflight);
@ -42,7 +46,7 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
"/api/public/donor-eligibility", "/api/public/donor-eligibility",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
cors(reply); cors(req, reply);
// Disabled unless configured. // Disabled unless configured.
if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) { if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) {
return reply.code(404).send({ error: "not_available" }); return reply.code(404).send({ error: "not_available" });
@ -65,14 +69,20 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
}, },
); );
// Ticket-voucher entitlement: how many free tickets a donor has earned from // Ticket-voucher entitlement: how many FREE tickets a donor has left. This is
// giving on/after VOUCHER_SINCE. Same secret/CORS/rate-limit as above. // the tier entitlement earned from giving on/after VOUCHER_SINCE, MINUS the
// Returns only the count (0/1/2) — no dollar amounts. // 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( app.get(
"/api/public/ticket-vouchers", "/api/public/ticket-vouchers",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
cors(reply); cors(req, reply);
if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) { if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) {
return reply.code(404).send({ error: "not_available" }); return reply.code(404).send({ error: "not_available" });
} }
@ -81,16 +91,18 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
} }
const { email } = (req.query ?? {}) as { email?: string }; const { email } = (req.query ?? {}) as { email?: string };
const addr = String(email ?? "").trim(); const addr = String(email ?? "").trim();
if (!addr) return { vouchers: 0 }; if (!addr) return { vouchers: 0, entitled: 0, used: 0, remaining: 0 };
try { try {
const cutoff = new Date(cfg.VOUCHER_SINCE); const cutoff = new Date(cfg.VOUCHER_SINCE);
const { amount } = await app.ctx.donors.amountSince(addr, cutoff); const { amount } = await app.ctx.donors.amountSince(addr, cutoff);
const vouchers = amount >= cfg.VOUCHER_TIER2_MIN ? 2 : amount >= cfg.VOUCHER_TIER1_MIN ? 1 : 0; const entitled = amount >= cfg.VOUCHER_TIER2_MIN ? 2 : amount >= cfg.VOUCHER_TIER1_MIN ? 1 : 0;
return { vouchers }; const used = await app.ctx.nocodb.vouchersUsedByEmail(addr);
const remaining = Math.max(0, entitled - used);
return { vouchers: remaining, entitled, used, remaining };
} catch { } catch {
// Fail closed — grant no vouchers rather than error. // Fail closed — grant no vouchers rather than error.
return { vouchers: 0 }; return { vouchers: 0, entitled: 0, used: 0, remaining: 0 };
} }
}, },
); );

View file

@ -37,11 +37,11 @@ const PERSONAS: Persona[] = [
name: "Family Fay", name: "Family Fay",
email: "family@test.beartaria", email: "family@test.beartaria",
adultNames: ["Family Fay", "Frank Fay"], 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, iceBags: 3,
carParking: true, carParking: true,
blurb: 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", key: "donor2",
@ -119,8 +119,8 @@ export async function testRoutes(app: FastifyInstance): Promise<void> {
// Keep the "exhausted" persona fully redeemed on every load so its state // Keep the "exhausted" persona fully redeemed on every load so its state
// is deterministic (total = scannable count from the persona's counts). // is deterministic (total = scannable count from the persona's counts).
if (p.exhaust) { if (p.exhaust) {
const { adults, youth, kids12, kids9 } = p.counts; const { adults, youth } = p.counts;
await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth + kids12 + kids9 }); await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth });
} }
cards.push({ cards.push({
code: result.code, code: result.code,

View file

@ -2,6 +2,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { normalizeCode, looksLikeCode } from "../services/code.js"; import { normalizeCode, looksLikeCode } from "../services/code.js";
import { lookupByCode, redeem, search, createTicket } from "../ticketService.js"; import { lookupByCode, redeem, search, createTicket } from "../ticketService.js";
import { renderQrPng } from "../services/qrcode.js"; import { renderQrPng } from "../services/qrcode.js";
import { computeStats } from "../services/stats.js";
import { COL } from "../fields.js"; import { COL } from "../fields.js";
async function requireStaff(req: FastifyRequest, reply: FastifyReply): Promise<void> { async function requireStaff(req: FastifyRequest, reply: FastifyReply): Promise<void> {
@ -42,6 +43,12 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
}, },
); );
// 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=). // Recent check-in audit log (all, or filtered to one code via ?code=).
app.get("/api/audit", { preHandler: requireStaff }, async (req) => { app.get("/api/audit", { preHandler: requireStaff }, async (req) => {
const code = (req.query as any)?.code ? normalizeCode(String((req.query as any).code)) : undefined; const code = (req.query as any)?.code ? normalizeCode(String((req.query as any).code)) : undefined;

View file

@ -0,0 +1,141 @@
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). Only FOOD vendors receive entry tickets:
*
* - Food: two named pass-holders (`names` = "Name Ticket 1",
* `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.
*
* 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).
*/
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);
}
function foodHandler(app: FastifyInstance) {
return async (req: any, reply: any) => {
if (!checkSecret(app, req)) {
return reply.code(401).send({ error: "unauthorized" });
}
const body = (req.body ?? {}) as Record<string, any>;
const boothName = String(body.input_text ?? "").trim();
// Pass-holder names (non-empty slots, in order).
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;
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|Food Vendor|${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<ReturnType<typeof createTicket>>;
try {
result = await createTicket(app.ctx, {
name: title,
adultNames: passHolders,
email,
address,
isDonor,
donorTier,
ticketType: "Food Vendor",
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 };
};
}
/** 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<void> {
// 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));
}

View file

@ -1,58 +1,21 @@
import { createHash, timingSafeEqual } from "node:crypto"; import { createHash } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { toBool, toNumber } from "../fields.js"; import { toBool } from "../fields.js";
import { createTicket } from "../ticketService.js"; import { createTicket } from "../ticketService.js";
import { renderQrPng } from "../services/qrcode.js"; import { renderQrPng } from "../services/qrcode.js";
import { safeEqual, nameGroup, qty, selected, addressLine, iceBagsFromPayment } from "../fluentforms.js";
function safeEqual(a: string, b: string): boolean { // Regular adult attendee name groups (Adult Ticket #1#10), in order.
const ba = Buffer.from(a || ""); const REGULAR_NAME_BASES = [
const bb = Buffer.from(b || ""); "names",
if (ba.length !== bb.length) return false; "names_1", "names_2", "names_3", "names_4", "names_5", "names_6", "names_7", "names_8", "names_9",
return timingSafeEqual(ba, bb); ];
} // 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
/** Read a FluentForms compound name field, given as a nested object // separate quantity field for them).
* (`names: {first_name,...}`) or flattened bracket keys (`names[first_name]`). */ const DONOR_NAME_BASES = ["names_Donor_1", "names_Donor_2"];
function nameGroup(body: Record<string, any>, base: string): string { // All adult names for the gate display list.
const obj = body[base]; const ADULT_NAME_BASES = [...REGULAR_NAME_BASES, ...DONOR_NAME_BASES];
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;
}
// 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"];
export async function webhookRoutes(app: FastifyInstance): Promise<void> { export async function webhookRoutes(app: FastifyInstance): Promise<void> {
const handler = async (req: any, reply: any) => { const handler = async (req: any, reply: any) => {
@ -63,30 +26,37 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
const body = (req.body ?? {}) as Record<string, any>; const body = (req.body ?? {}) as Record<string, any>;
// 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(); const email = String(body.email ?? "").trim();
if (!name) {
return reply.code(400).send({ error: "missing_fields", detail: "purchaser name is required" }); // 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);
// 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];
const title = customerName || adultNames[0];
if (!title) {
return reply.code(400).send({ error: "missing_fields", detail: "customer or attendee name is required" });
} }
// Adult attendee names (non-empty groups, in order). // Attendee counts. Adults = regular (paid) tickets + additional paid donor
const adultNames = ADULT_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean); // tickets + free voucher tickets (one per donor name provided).
// Attendee counts.
const counts = { 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), youth: qty(body.item_quantity_youth_ticket_reg) + qty(body.item_quantity_youth_ticket_donor),
kids12: qty(body.item_quantity_kids_12), kids12: qty(body.item_quantity_kids_12),
kids9: qty(body.item_quantity_kids_9), kids9: qty(body.item_quantity_kids_9),
kids4: qty(body.item_quantity_kids_4), 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
if (scannable <= 0) { // free (charging starts at 13) and are stored but not counted at the gate.
// Nothing to check in at the gate. Log the payload so we can calibrate. const scannable = counts.adults + counts.youth;
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+)" });
}
// Donor info (hidden fields from the eligibility/voucher lookups) + radio. // Donor info (hidden fields from the eligibility/voucher lookups) + radio.
const donorTier = String(body.donor_tier ?? "").trim(); const donorTier = String(body.donor_tier ?? "").trim();
@ -95,40 +65,48 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
donorTier === "donor" || donorTier === "donor" ||
toBool(body.donor_eligible) || toBool(body.donor_eligible) ||
selected(body.input_radio); // "Are you a campground donor?" 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). // Extras (best-effort from payment fields — donor variants may be free/$0).
const carParking = selected(body.payment_parking_reg) || selected(body.payment_parking_donor); 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 rvParking = selected(body.payment_rv_reg) || selected(body.payment_rv_donor);
const utv = selected(body.payment_utv_reg) || selected(body.payment_utv_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 // Ice: payment_ice is a descriptive option label whose "(N total bags)"
// ($20-$80). One ice ticket = ICE_BAGS_PER_TICKET bags. // states the bags. One ice ticket = ICE_BAGS_PER_TICKET bags.
const iceRaw = qty(body.payment_ice); const iceBags = iceBagsFromPayment(body.payment_ice, {
const iceTickets = iceRaw >= app.ctx.config.ICE_TICKET_PRICE ? Math.round(iceRaw / app.ctx.config.ICE_TICKET_PRICE) : Math.round(iceRaw); bagsPerTicket: app.ctx.config.ICE_BAGS_PER_TICKET,
const iceBags = Math.max(0, iceTickets) * app.ctx.config.ICE_BAGS_PER_TICKET; ticketPrice: app.ctx.config.ICE_TICKET_PRICE,
});
const iceAccess = iceBags > 0 || selected(body.input_radio_7); const iceAccess = iceBags > 0 || selected(body.input_radio_7);
const address = // Tickets are optional: a customer can buy ice/UTV/parking with no admission
body.address_1 && typeof body.address_1 === "object" // ticket, or buy tickets for others. Only reject a truly empty order —
? Object.values(body.address_1).filter(Boolean).join(", ") // nothing to check in, redeem, or verify at the gate.
: body.address_1 !== undefined const hasIssuable = scannable > 0 || iceBags > 0 || utv || carParking || rvParking;
? String(body.address_1) if (!hasIssuable) {
: undefined; 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" });
}
// Idempotency: prefer a stable submission id, else hash the content. const address = addressLine(body.address_1);
// 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 submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id;
const submissionKey = submissionId const submissionKey = submissionId
? `sub:${String(submissionId)}` ? `sub:${String(submissionId)}`
: "hash:" + : "hash:" +
createHash("sha256") createHash("sha256")
.update(`${email}|${name}|${JSON.stringify(counts)}`) .update(`${email}|${title}|${JSON.stringify(counts)}|${iceBags}|${carParking}|${rvParking}|${utv}`)
.digest("hex") .digest("hex")
.slice(0, 32); .slice(0, 32);
let result: Awaited<ReturnType<typeof createTicket>>; let result: Awaited<ReturnType<typeof createTicket>>;
try { try {
result = await createTicket(app.ctx, { result = await createTicket(app.ctx, {
name, name: title,
adultNames, adultNames,
email, email,
address, address,
@ -169,10 +147,11 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
const qr = await renderQrPng(result.code); const qr = await renderQrPng(result.code);
await app.ctx.mailer.sendTicket({ await app.ctx.mailer.sendTicket({
toEmail: email, toEmail: email,
toName: name, toName: purchaser,
code: result.code, code: result.code,
quantity: scannable, quantity: scannable,
qrPng: qr, qrPng: qr,
iceBags,
}); });
} catch (e: any) { } catch (e: any) {
req.log.error({ err: e, code: result.code }, "webhook: ticket created but email failed"); req.log.error({ err: e, code: result.code }, "webhook: ticket created but email failed");

View file

@ -11,19 +11,21 @@ interface Field {
} }
const FIELDS: 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. 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_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", 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 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: "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: "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_reg", req: "required", type: "quantity", desc: "Regular (non-donor) adult tickets." },
{ key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "Adult tickets (donor). Added to the regular adults." }, { 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_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_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. Counts toward the scannable 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 — 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_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: "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: "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_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." }, { key: "payment_rv_reg / _donor", req: "optional", type: "payment", desc: "RV. Flagged if either variant is selected." },
@ -54,6 +56,7 @@ const rows = FIELDS.map(
const exampleJson = esc(`{ const exampleJson = esc(`{
"id": "412", "id": "412",
"customer_name": { "first_name": "Jane", "last_name": "Bear" },
"names": { "first_name": "Jane", "last_name": "Bear" }, "names": { "first_name": "Jane", "last_name": "Bear" },
"names_1": { "first_name": "John", "last_name": "Bear" }, "names_1": { "first_name": "John", "last_name": "Bear" },
"email": "jane@example.com", "email": "jane@example.com",
@ -118,21 +121,22 @@ const PAGE = `<!doctype html>
<h2>What it does</h2> <h2>What it does</h2>
<p>On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject <b>"2026 Beartaria Campgrounds Tickets"</b>). FluentForms sends the payment receipt separately.</p> <p>On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject <b>"2026 Beartaria Campgrounds Tickets"</b>). FluentForms sends the payment receipt separately.</p>
<p><b>Scannable ticket total</b> = adults + youth (13-16) + kids 10-12 + kids 5-9. <b>Kids 0-4 are free</b> and not counted. Each adult name provided is stored and shown to gate staff on a successful scan.</p> <p><b>Scannable ticket total</b> = adults + youth (13-16). <b>Adults</b> = <code>item_quantity_adult_ticket_reg</code> (regular) + <code>item_quantity_adult_ticket_donor</code> (extra paid donor tickets) + the number of donor voucher names (<code>names_Donor_1/2</code> each filled name is one free voucher ticket). <b>Children 12 &amp; under are free</b> (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 <code>customer_name</code>.</p>
<p><b>Tickets are optional.</b> 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.</p>
<h2>Fields</h2> <h2>Fields</h2>
<table> <table>
<thead><tr><th>Key</th><th>Required</th><th>Type</th><th>Description</th></tr></thead> <thead><tr><th>Key</th><th>Required</th><th>Type</th><th>Description</th></tr></thead>
<tbody>${rows}</tbody> <tbody>${rows}</tbody>
</table> </table>
<p class="sub">Compound name fields arrive as objects (<code>names: {first_name,}</code>) or flattened <code>names[first_name]</code> keys both handled. Quantity/payment fields accept numbers, money strings ("$40.00"), or <code>{quantity}</code> objects.</p> <p class="sub">Compound name fields arrive as objects (<code>names: {first_name,}</code>) or flattened <code>names[first_name]</code> keys both handled. Quantity/payment fields accept numbers, money strings ("$40.00"), or <code>{quantity}</code> objects. Counts come from the <code>item_quantity_*</code> fields, so pure pricing line items (<code>payment_adult_reg</code>, <code>payment_youth_*</code>, <code>payment_kids_free</code>, <code>payment_donor_voucher1/2</code>, <code>custom-payment-amount</code>/Tax) are ignored the <code>vouchers</code> hidden count is authoritative for donor vouchers.</p>
<h2>Idempotency</h2> <h2>Idempotency</h2>
<p>Send a stable <code>id</code> / <code>submission_id</code>. A repeat returns <code>{"status":"duplicate"}</code> without creating a second ticket or re-emailing safe for retries and double-submits.</p> <p>Send a stable <code>id</code> / <code>submission_id</code>. A repeat returns <code>{"status":"duplicate"}</code> without creating a second ticket or re-emailing safe for retries and double-submits.</p>
<h2>Example payload</h2> <h2>Example payload</h2>
<pre><code>${exampleJson}</code></pre> <pre><code>${exampleJson}</code></pre>
<p class="sub">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).</p> <p class="sub">This issues 3 scannable tickets (2 adults + 1 youth 13-16; all four kids 12 &amp; under are free), member donor with 2 vouchers, car parking, and 6 bags of ice (2 ice tickets).</p>
<h2>Test with curl</h2> <h2>Test with curl</h2>
<pre><code>${exampleCurl}</code></pre> <pre><code>${exampleCurl}</code></pre>
@ -143,7 +147,8 @@ const PAGE = `<!doctype html>
<tbody> <tbody>
<tr><td>200</td><td><code>{"status":"created","code":"BC26-…","emailSent":true}</code></td><td>Ticket created and emailed.</td></tr> <tr><td>200</td><td><code>{"status":"created","code":"BC26-…","emailSent":true}</code></td><td>Ticket created and emailed.</td></tr>
<tr><td>200</td><td><code>{"status":"duplicate","code":"BC26-…"}</code></td><td>Same submission already processed no-op.</td></tr> <tr><td>200</td><td><code>{"status":"duplicate","code":"BC26-…"}</code></td><td>Same submission already processed no-op.</td></tr>
<tr><td>400</td><td><code>{"error":"missing_fields"}</code> / <code>"no_tickets"</code></td><td>Missing purchaser name, or zero scannable tickets.</td></tr> <tr><td>400</td><td><code>{"error":"missing_fields"}</code></td><td>No customer name and no attendee names.</td></tr>
<tr><td>400</td><td><code>{"error":"no_items"}</code></td><td>Empty order no tickets, ice, or add-ons.</td></tr>
<tr><td>401</td><td><code>{"error":"unauthorized"}</code></td><td>Missing or wrong <code>X-Webhook-Secret</code>.</td></tr> <tr><td>401</td><td><code>{"error":"unauthorized"}</code></td><td>Missing or wrong <code>X-Webhook-Secret</code>.</td></tr>
<tr><td>502</td><td><code>{"status":"created","emailSent":false,}</code></td><td>Ticket row created but the email failed re-send from the admin app.</td></tr> <tr><td>502</td><td><code>{"status":"created","emailSent":false,}</code></td><td>Ticket row created but the email failed re-send from the admin app.</td></tr>
</tbody> </tbody>
@ -158,6 +163,21 @@ const PAGE = `<!doctype html>
<li>Save, submit a test purchase, and confirm the QR email arrives.</li> <li>Save, submit a test purchase, and confirm the QR email arrives.</li>
</ol> </ol>
<h2>Vendor booth webhooks</h2>
<p class="sub"><b>Only food vendors receive entry tickets.</b> The vendor forms live on <b>vendors.beartariacampgrounds.com</b> and share the same <code>X-Webhook-Secret</code>. For a food booth, each <b>named</b> person gets one entry pass; the booth name (<code>input_text</code>) becomes the ticket title, and the ticket is tagged with a <b>Food Vendor</b> 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.</p>
<table>
<thead><tr><th>Form</th><th>Endpoint</th><th>Result</th></tr></thead>
<tbody>
<tr><td>Vendor Fee Food 2026</td><td><code>POST /vendor-webhook/food</code></td><td>🍔 up to 2 passes (<code>names</code> + <code>names_1</code>), Food Vendor ticket + QR email</td></tr>
<tr><td>Vendor Fee Non-Food 2026</td><td><code>POST /vendor-webhook/non-food</code></td><td>No ticket acknowledged only (<code>{"status":"ignored"}</code>). You can leave this form's webhook unconfigured.</td></tr>
</tbody>
</table>
<p class="sub">Relevant food keys: <code>input_text</code> (Booth Name), <code>names</code> / <code>names_1</code> (pass-holders), <code>email</code>, <code>address_1</code>, <code>donor_tier</code> / <code>donor_eligible</code> / <code>input_radio</code> (donor), <code>payment_method</code>. Same idempotency (<code>id</code>/<code>submission_id</code>) and response shapes as above, plus a <code>passes</code> count.</p>
<pre><code>curl -X POST https://scan.beartariacampgrounds.com/vendor-webhook/food \\
-H "Content-Type: application/json" \\
-H "X-Webhook-Secret: &lt;your WEBHOOK_SECRET&gt;" \\
-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"}'</code></pre>
<footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer> <footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer>
</div> </div>
</body> </body>

View file

@ -9,12 +9,14 @@ import { loadConfig } from "./config.js";
import { buildContext } from "./context.js"; import { buildContext } from "./context.js";
import { authRoutes } from "./routes/auth.js"; import { authRoutes } from "./routes/auth.js";
import { webhookRoutes } from "./routes/webhook.js"; import { webhookRoutes } from "./routes/webhook.js";
import { vendorWebhookRoutes } from "./routes/vendorWebhook.js";
import { ticketRoutes } from "./routes/tickets.js"; import { ticketRoutes } from "./routes/tickets.js";
import { testRoutes } from "./routes/test.js"; import { testRoutes } from "./routes/test.js";
import { installRoutes } from "./routes/install.js"; import { installRoutes } from "./routes/install.js";
import { webhookDocRoutes } from "./routes/webhookDoc.js"; import { webhookDocRoutes } from "./routes/webhookDoc.js";
import { publicLookupRoutes } from "./routes/publicLookup.js"; import { publicLookupRoutes } from "./routes/publicLookup.js";
import { portalRoutes } from "./routes/portal.js"; import { portalRoutes } from "./routes/portal.js";
import { adminRoutes } from "./routes/admin.js";
export async function build() { export async function build() {
const config = loadConfig(); const config = loadConfig();
@ -32,12 +34,14 @@ export async function build() {
await app.register(authRoutes); await app.register(authRoutes);
await app.register(webhookRoutes); await app.register(webhookRoutes);
await app.register(vendorWebhookRoutes);
await app.register(ticketRoutes); await app.register(ticketRoutes);
await app.register(testRoutes); await app.register(testRoutes);
await app.register(installRoutes); await app.register(installRoutes);
await app.register(webhookDocRoutes); await app.register(webhookDocRoutes);
await app.register(publicLookupRoutes); await app.register(publicLookupRoutes);
await app.register(portalRoutes); await app.register(portalRoutes);
await app.register(adminRoutes);
// Serve the exported Expo web build (if present) with SPA fallback. // Serve the exported Expo web build (if present) with SPA fallback.
const webDir = config.WEB_DIR ?? join(process.cwd(), "web"); const webDir = config.WEB_DIR ?? join(process.cwd(), "web");

View file

@ -34,7 +34,7 @@ export interface AuditRow extends AuditEntry {
export class AuditLogger { export class AuditLogger {
private readonly base: string; private readonly base: string;
private readonly token: string; private readonly token: string;
private readonly tableId: string | null; private tableId: string | null;
constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_AUDIT_TABLE_ID">) { constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_AUDIT_TABLE_ID">) {
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, ""); this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
@ -46,10 +46,56 @@ export class AuditLogger {
return this.tableId !== null; 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 { private get url(): string {
return `${this.base}/api/v2/tables/${this.tableId}/records`; return `${this.base}/api/v2/tables/${this.tableId}/records`;
} }
/** Total audit row count (cheap — reads pageInfo). */
async count(): Promise<number> {
if (!this.tableId) return 0;
const url = new URL(this.url);
url.searchParams.set("limit", "1");
const res = await fetch(url.toString(), {
headers: { "xc-token": this.token, "Content-Type": "application/json" },
});
if (!res.ok) return 0;
const body: any = await res.json().catch(() => ({}));
return body?.pageInfo?.totalRows ?? (body?.list?.length ?? 0);
}
/** Delete every audit row in the current table. Returns the count deleted. */
async deleteAll(): Promise<number> {
if (!this.tableId) return 0;
let total = 0;
for (;;) {
const url = new URL(this.url);
url.searchParams.set("limit", "1000");
url.searchParams.set("fields", "Id");
const res = await fetch(url.toString(), {
headers: { "xc-token": this.token, "Content-Type": "application/json" },
});
if (!res.ok) break;
const body: any = await res.json().catch(() => ({}));
const list = body?.list ?? [];
if (!list.length) break;
await fetch(this.url, {
method: "DELETE",
headers: { "xc-token": this.token, "Content-Type": "application/json" },
body: JSON.stringify(list.map((r: any) => ({ Id: r.Id }))),
});
total += list.length;
}
return total;
}
async log(entry: AuditEntry): Promise<void> { async log(entry: AuditEntry): Promise<void> {
if (!this.tableId) return; if (!this.tableId) return;
const sign = entry.people >= 0 ? "+" : ""; const sign = entry.people >= 0 ? "+" : "";
@ -79,6 +125,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<AuditRow[]> {
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. */ /** Recent entries, newest first, optionally filtered to one code. */
async recent(opts: { code?: string; limit?: number } = {}): Promise<AuditRow[]> { async recent(opts: { code?: string; limit?: number } = {}): Promise<AuditRow[]> {
if (!this.tableId) return []; if (!this.tableId) return [];

View file

@ -1,5 +1,17 @@
import type { Config } from "../config.js"; 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 { export interface DonorLookup {
found: boolean; found: boolean;
email: string; email: string;
@ -157,6 +169,67 @@ export class DonorService {
}; };
} }
/**
* Admin-only free-text donor search across the master list + transaction
* tables. Matches the query (substring, case-insensitive) against any
* name / email / phone / address / bear-name column each table exposes
* columns are discovered from a sample row so it adapts to the schema.
* Results are de-duped by email (then name). PRIVACY: gate this to admins.
*/
async search(rawQuery: string, limit = 40): Promise<DonorSearchResult[]> {
const q = rawQuery.trim();
if (!q || !this.enabled) return [];
const tables: { id: string | null; source: "master" | "transactions" }[] = [
{ id: this.masterId, source: "master" },
{ id: this.onlineId, source: "transactions" },
{ id: this.offlineId, source: "transactions" },
];
const out = new Map<string, DonorSearchResult>();
for (const t of tables) {
if (!t.id || out.size >= limit) continue;
let rows: any[];
try {
rows = await this.searchTable(t.id, q, limit);
} catch {
continue; // a table without matching columns / transient error — skip
}
for (const r of rows) {
const res = mapDonorRow(r, t.source);
const key = (res.email || res.name || JSON.stringify(r)).toLowerCase();
const existing = out.get(key);
// Prefer the master-list record (richer) when the same donor appears twice.
if (!existing || (existing.source === "transactions" && res.source === "master")) {
out.set(key, existing ? { ...res, lifetime: res.lifetime ?? existing.lifetime } : res);
}
if (out.size >= limit) break;
}
}
return [...out.values()].slice(0, limit);
}
private colCache = new Map<string, string[]>();
/** Discover the text columns worth searching (name/contact) from a sample row. */
private async searchableColumns(tableId: string): Promise<string[]> {
const cached = this.colCache.get(tableId);
if (cached) return cached;
const sample = await this.list(tableId, "", 1);
const keys = sample.length ? Object.keys(sample[0]) : [];
const want = /name|email|phone|mobile|cell|address|street|city|state|zip|postal|province|country|bear/i;
const skip = /[(),]/; // field names with filter-grammar chars can't be queried
const cols = keys.filter((k) => want.test(k) && !skip.test(k));
this.colCache.set(tableId, cols);
return cols;
}
private async searchTable(tableId: string, q: string, limit: number): Promise<any[]> {
const cols = await this.searchableColumns(tableId);
if (!cols.length) return [];
const esc = q.replace(/[(),]/g, " ");
const where = cols.map((c) => `(${c},like,%${esc}%)`).join("~or");
return this.list(tableId, where, limit);
}
/** /**
* Total Paid donations for an email on/after `cutoff`, summed from the * Total Paid donations for an email on/after `cutoff`, summed from the
* transaction tables (the only dated source). Used for ticket-voucher * 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; 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). // Count a transaction unless it's explicitly not paid (refunded/failed/pending).
function isPaid(row: any): boolean { function isPaid(row: any): boolean {
const s = String(row["Payment Status"] ?? "").trim(); const s = String(row["Payment Status"] ?? "").trim();

View file

@ -9,6 +9,43 @@ export interface TicketEmail {
code: string; code: string;
quantity: number; quantity: number;
qrPng: Buffer; 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 <strong>${qty} ${w}</strong> 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 <strong>${bags} bag${bags === 1 ? "" : "s"} of ice</strong>.` : "";
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 { export class MailerSendError extends Error {
@ -94,8 +131,7 @@ function esc(s: string): string {
function renderHtml(mail: TicketEmail): string { function renderHtml(mail: TicketEmail): string {
const name = esc(mail.toName || ""); const name = esc(mail.toName || "");
const qty = mail.quantity; const { lead, footer } = purchaseSummary(mail);
const ticketWord = qty === 1 ? "ticket" : "tickets";
return `<!doctype html> return `<!doctype html>
<html> <html>
<body style="margin:0;padding:0;background:#0f1a12;font-family:Arial,Helvetica,sans-serif;color:#0f1a12;"> <body style="margin:0;padding:0;background:#0f1a12;font-family:Arial,Helvetica,sans-serif;color:#0f1a12;">
@ -108,9 +144,7 @@ function renderHtml(mail: TicketEmail): string {
<tr><td style="padding:24px;"> <tr><td style="padding:24px;">
<p style="margin:0 0 12px;font-size:16px;">Hi ${name || "there"},</p> <p style="margin:0 0 12px;font-size:16px;">Hi ${name || "there"},</p>
<p style="margin:0 0 16px;font-size:15px;line-height:1.5;"> <p style="margin:0 0 16px;font-size:15px;line-height:1.5;">
Thank you for your purchase! This email is your ticket for Thank you for your purchase! ${lead}
<strong>${qty} ${ticketWord}</strong> to the 2026 Beartaria Campgrounds event.
Show the QR code below at the gate.
</p> </p>
<div style="text-align:center;margin:20px 0;"> <div style="text-align:center;margin:20px 0;">
<img src="cid:qrcode" alt="Ticket QR code" width="280" height="280" <img src="cid:qrcode" alt="Ticket QR code" width="280" height="280"
@ -121,8 +155,7 @@ function renderHtml(mail: TicketEmail): string {
${esc(mail.code)} ${esc(mail.code)}
</p> </p>
<p style="margin:0;font-size:13px;color:#777;line-height:1.5;"> <p style="margin:0;font-size:13px;color:#777;line-height:1.5;">
Each ticket admits one entry. This code is good for all ${qty} ${ticketWord} on one purchase ${footer}
gate staff will check people in against it. See you there!
</p> </p>
</td></tr> </td></tr>
</table> </table>
@ -134,17 +167,16 @@ function renderHtml(mail: TicketEmail): string {
} }
function renderText(mail: TicketEmail): string { function renderText(mail: TicketEmail): string {
const qty = mail.quantity; const { lead, footer } = purchaseSummaryText(mail);
const ticketWord = qty === 1 ? "ticket" : "tickets";
return [ return [
`Hi ${mail.toName || "there"},`, `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}`, `Your ticket code: ${mail.code}`,
"", "",
"Show this code (or the QR code in the HTML version of this email) at the gate.", "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!", "See you there!",
"Beartaria Campgrounds · beartariacampgrounds.com", "Beartaria Campgrounds · beartariacampgrounds.com",

View file

@ -8,16 +8,24 @@ import { COL, type NocoRecord } from "../fields.js";
export class NocoDBClient { export class NocoDBClient {
private readonly base: string; private readonly base: string;
private readonly token: string; private readonly token: string;
private readonly tableId: string; private _tableId: string;
constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_TABLE_ID">) { constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_TABLE_ID">) {
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, ""); this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
this.token = cfg.NOCODB_API_TOKEN; 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 { private get recordsUrl(): string {
return `${this.base}/api/v2/tables/${this.tableId}/records`; return `${this.base}/api/v2/tables/${this._tableId}/records`;
} }
private async request(url: string, init: RequestInit = {}): Promise<any> { private async request(url: string, init: RequestInit = {}): Promise<any> {
@ -76,6 +84,22 @@ export class NocoDBClient {
return this.list(`(${COL.name},like,%${q}%)~or(${COL.email},like,%${q}%)`, limit); 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<NocoRecord[]> {
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<number> {
const rows = await this.findByEmail(email);
return rows.reduce((sum, r) => sum + (Number(r[COL.vouchers]) || 0), 0);
}
async create(fields: Record<string, unknown>): Promise<NocoRecord> { async create(fields: Record<string, unknown>): Promise<NocoRecord> {
const body = await this.request(this.recordsUrl, { const body = await this.request(this.recordsUrl, {
method: "POST", method: "POST",
@ -102,6 +126,67 @@ export class NocoDBClient {
return (Array.isArray(body) ? body[0] : body) as NocoRecord; return (Array.isArray(body) ? body[0] : body) as NocoRecord;
} }
/** Fetch every record in the table, paginating. */
async all(): Promise<NocoRecord[]> {
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;
}
/** Total record count in the current table (cheap — reads pageInfo). */
async count(): Promise<number> {
const url = new URL(this.recordsUrl);
url.searchParams.set("limit", "1");
const body = await this.request(url.toString());
return body?.pageInfo?.totalRows ?? (body?.list?.length ?? 0);
}
/** Delete every record in the current table (paginated bulk delete). Returns
* the number deleted. Used by the admin "wipe slate" action. */
async deleteAll(): Promise<number> {
let total = 0;
for (;;) {
const rows = await this.list("", 1000);
if (!rows.length) break;
const ids = rows.map((r) => ({ Id: (r as any).Id }));
await this.request(this.recordsUrl, { method: "DELETE", body: JSON.stringify(ids) });
total += rows.length;
}
return total;
}
/** Reachability + primary-key probe for a candidate table id (admin switch).
* Returns { ok, hasIdPk }. hasIdPk is false only if rows exist without an Id. */
async probeTable(tableId: string): Promise<{ ok: boolean; hasIdPk: boolean; status: number }> {
const url = new URL(`${this.base}/api/v2/tables/${tableId}/records`);
url.searchParams.set("limit", "1");
try {
const res = await fetch(url.toString(), {
headers: { "xc-token": this.token, "Content-Type": "application/json" },
});
if (!res.ok) return { ok: false, hasIdPk: false, status: res.status };
const body: any = await res.json().catch(() => ({}));
const list = body?.list ?? [];
const hasIdPk = list.length === 0 || "Id" in list[0];
return { ok: true, hasIdPk, status: 200 };
} catch {
return { ok: false, hasIdPk: false, status: 0 };
}
}
/** Cheap connectivity probe for healthchecks. */ /** Cheap connectivity probe for healthchecks. */
async ping(): Promise<boolean> { async ping(): Promise<boolean> {
const url = new URL(this.recordsUrl); const url = new URL(this.recordsUrl);

View file

@ -0,0 +1,32 @@
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join } from "node:path";
/**
* Tiny persisted state, stored as JSON on a mounted volume (STATE_DIR). Used for
* the admin "switch event table" action so the choice survives a redeploy
* otherwise the app would revert to the .env table IDs on every restart.
*/
export interface ActiveTables {
ticketsTableId: string;
auditTableId?: string;
}
const FILE = "active-tables.json";
export function loadActiveTables(dir: string): ActiveTables | null {
try {
const raw = readFileSync(join(dir, FILE), "utf8");
const parsed = JSON.parse(raw);
if (parsed && typeof parsed.ticketsTableId === "string" && parsed.ticketsTableId) {
return { ticketsTableId: parsed.ticketsTableId, auditTableId: parsed.auditTableId || undefined };
}
} catch {
// No override or unreadable — fall back to .env config.
}
return null;
}
export function saveActiveTables(dir: string, tables: ActiveTables): void {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, FILE), JSON.stringify(tables, null, 2), "utf8");
}

View file

@ -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<Stats> {
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<string, { count: number; total: number; redeemed: number }>();
const compByCreator = new Map<string, number>();
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<string, { checkins: number; ice: number; undos: number }>();
const hourMap = new Map<string, number>();
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;
}

View file

@ -41,6 +41,17 @@ export class FakeNocoDB {
); );
} }
async findByEmail(email: string): Promise<NocoRecord[]> {
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<number> {
const rows = await this.findByEmail(email);
return rows.reduce((sum, r) => sum + (Number(r[COL.vouchers]) || 0), 0);
}
async create(fields: Record<string, unknown>): Promise<NocoRecord> { async create(fields: Record<string, unknown>): Promise<NocoRecord> {
await this.delay(); await this.delay();
const rec = { Id: this.nextId++, ...fields } as NocoRecord; const rec = { Id: this.nextId++, ...fields } as NocoRecord;

View file

@ -2,16 +2,16 @@ import { describe, it, expect } from "vitest";
import { computeTotal, toView, COL } from "../fields.js"; import { computeTotal, toView, COL } from "../fields.js";
describe("computeTotal", () => { 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 = { const rec = {
Id: 1, Id: 1,
[COL.adults]: 2, [COL.adults]: 2,
[COL.youth]: 1, [COL.youth]: 1,
[COL.kids12]: 1, [COL.kids12]: 1, // free, not counted
[COL.kids9]: 1, [COL.kids9]: 1, // free, not counted
[COL.kids4]: 3, // 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", () => { it("coerces string counts and treats blanks as 0", () => {
@ -47,7 +47,7 @@ describe("toView", () => {
expect(v.extras.rvParking).toBe(false); expect(v.extras.rvParking).toBe(false);
expect(v.extras.donorTier).toBe("member"); expect(v.extras.donorTier).toBe("member");
expect(v.extras.vouchers).toBe(2); 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); expect(v.ages.find((a) => a.bracket === "Kids 0-4")?.free).toBe(true);
}); });
}); });

View file

@ -0,0 +1,98 @@
import { describe, it, expect } from "vitest";
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"];
/** Mirror the food vendor handler's pass count: one per named person, min 1. */
function passCount(body: Record<string, any>, 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("food 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("booth with no names still gets 1 pass", () => {
expect(passCount({ input_text: "Nameless Booth" }, FOOD_SLOTS)).toBe(1);
});
});
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" });
});
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");
});
});

View file

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

View file

@ -131,6 +131,7 @@ export interface WebhookInput {
adultNames?: string[]; adultNames?: string[];
email: string; email: string;
ticketType?: string; // Guest/Worker/Performer/Volunteer/Speaker for portal comps ticketType?: string; // Guest/Worker/Performer/Volunteer/Speaker for portal comps
createdBy?: string; // gate-staff name who issued a comp
address?: string; address?: string;
isDonor?: boolean; isDonor?: boolean;
donorTier?: string; 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.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n");
if (input.ticketType) fields[COL.ticketType] = input.ticketType; 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.address !== undefined) fields[COL.address] = input.address;
if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor; if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor;
if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier; if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier;

View file

@ -19,4 +19,11 @@ services:
# host.docker.internal resolves to the host gateway. # host.docker.internal resolves to the host gateway.
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "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 restart: unless-stopped
volumes:
camptickets-data:

View file

@ -14,16 +14,32 @@ ticketing backend.
GET https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<email> GET https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<email>
``` ```
Returns only the count — never names or dollar amounts: Returns the **remaining** free-ticket count — never names or dollar amounts:
```json ```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`). - `key` = the value of `PUBLIC_LOOKUP_SECRET` (set in the backend `.env`).
- `email` = the donor's email (URL-encoded). - `email` = the donor's email (URL-encoded).
- Rate-limited (30 requests / minute / IP) and CORS-restricted to - 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 > 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 > 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=<SECRET>&email=<a-real-donor-email>" curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<a-real-donor-email>"
# >= $1000 since cutoff -> {"vouchers":2} # entitled 2, none used yet -> {"vouchers":2,"entitled":2,"used":0,"remaining":2}
# >= $400 since cutoff -> {"vouchers":1} # after a checkout using 2 -> {"vouchers":0,"entitled":2,"used":2,"remaining":0}
# otherwise -> {"vouchers":0}
``` ```
Related: [`fluentforms-donor-discount.md`](./fluentforms-donor-discount.md) — the Related: [`fluentforms-donor-discount.md`](./fluentforms-donor-discount.md) — the

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

@ -0,0 +1,73 @@
#!/usr/bin/env bash
set -euo pipefail
#
# switch-event.sh — point the scanner app at a DIFFERENT NocoDB tickets (and
# optionally audit) table, e.g. to start a NEW event on a fresh table while
# keeping the old table intact for archive. Backs up backend/.env, updates it,
# and restarts the app container. The old table is never touched.
#
# Usage:
# scripts/switch-event.sh <TICKETS_TABLE_ID> [AUDIT_TABLE_ID]
#
# FIRST create the new table(s): in the NocoDB UI, DUPLICATE the current table
# with "structure only" (no records). That preserves every column AND the Id
# primary key — critical, because updates against a table with no primary key
# would hit every row. Then grab the new table id from its URL/API and pass it
# here. (The app also fail-safes: it refuses to update a row that has no Id.)
#
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
ENV_FILE="$ROOT/backend/.env"
CONTAINER="${CONTAINER:-camptickets}"
NEW_TICKETS="${1:-}"
NEW_AUDIT="${2:-}"
[ -n "$NEW_TICKETS" ] || { echo "Usage: $0 <TICKETS_TABLE_ID> [AUDIT_TABLE_ID]" >&2; exit 1; }
get() { grep -E "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2-; }
BASE_URL="$(get NOCODB_BASE_URL)"
TOKEN="$(get NOCODB_API_TOKEN)"
CUR_TICKETS="$(get NOCODB_TABLE_ID)"
CUR_AUDIT="$(get NOCODB_AUDIT_TABLE_ID)"
# Validate a table is reachable and (if it has rows) exposes an Id primary key.
check() {
local table="$1" tmp http
tmp="$(mktemp)"
http="$(curl -s -o "$tmp" -w '%{http_code}' -H "xc-token: $TOKEN" \
"$BASE_URL/api/v2/tables/$table/records?limit=1")"
if [ "$http" != "200" ]; then
echo "$table not reachable (HTTP $http)"; rm -f "$tmp"; return 1
fi
if ! python3 -c 'import sys,json; l=json.load(open(sys.argv[1]))["list"]; sys.exit(0 if (not l or "Id" in l[0]) else 1)' "$tmp"; then
echo "$table has rows without an Id primary key — refusing"; rm -f "$tmp"; return 1
fi
rm -f "$tmp"; echo "$table reachable"
}
echo "Validating new table(s) on $BASE_URL ..."
check "$NEW_TICKETS" || exit 1
[ -n "$NEW_AUDIT" ] && { check "$NEW_AUDIT" || exit 1; }
BK="$ENV_FILE.bak.$(date +%Y%m%d-%H%M%S)"
cp "$ENV_FILE" "$BK"
echo "Backed up env -> $BK"
echo "Switching tables:"
echo " tickets: $CUR_TICKETS -> $NEW_TICKETS"
sed -i -E "s|^NOCODB_TABLE_ID=.*|NOCODB_TABLE_ID=$NEW_TICKETS|" "$ENV_FILE"
if [ -n "$NEW_AUDIT" ]; then
echo " audit: $CUR_AUDIT -> $NEW_AUDIT"
sed -i -E "s|^NOCODB_AUDIT_TABLE_ID=.*|NOCODB_AUDIT_TABLE_ID=$NEW_AUDIT|" "$ENV_FILE"
else
echo " audit: unchanged ($CUR_AUDIT) — pass a second arg to switch it too"
fi
echo "Restarting $CONTAINER ..."
( cd "$ROOT" && docker compose up -d --force-recreate >/dev/null )
sleep 3
echo "Now active:"
echo " NOCODB_TABLE_ID=$(get NOCODB_TABLE_ID)"
echo " NOCODB_AUDIT_TABLE_ID=$(get NOCODB_AUDIT_TABLE_ID)"
echo "Old tickets table $CUR_TICKETS kept intact. (env backup: $BK)"

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

@ -0,0 +1,57 @@
#!/usr/bin/env bash
set -euo pipefail
#
# wipe-slate.sh — clear ALL ticket + audit records from the tables the scanner
# app currently uses, for a clean event run-through. Leaves the table SCHEMAS
# intact and does NOT touch donor data. Reads NocoDB creds from backend/.env.
#
# Usage:
# scripts/wipe-slate.sh # prompts for confirmation
# scripts/wipe-slate.sh --yes # skip the prompt (for automation)
#
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/../backend/.env}"
get() { grep -E "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2-; }
BASE_URL="$(get NOCODB_BASE_URL)"
TOKEN="$(get NOCODB_API_TOKEN)"
TICKETS="$(get NOCODB_TABLE_ID)"
AUDIT="$(get NOCODB_AUDIT_TABLE_ID)"
[ -n "$BASE_URL" ] && [ -n "$TOKEN" ] && [ -n "$TICKETS" ] || {
echo "Missing NocoDB config in $ENV_FILE" >&2; exit 1; }
YES=0
case "${1:-}" in -y|--yes) YES=1;; esac
count() {
curl -s -H "xc-token: $TOKEN" "$BASE_URL/api/v2/tables/$1/records?limit=1" \
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("pageInfo",{}).get("totalRows",0))'
}
echo "Target: $BASE_URL"
echo " tickets ($TICKETS): $(count "$TICKETS") records"
[ -n "$AUDIT" ] && echo " audit ($AUDIT): $(count "$AUDIT") records"
if [ "$YES" -ne 1 ]; then
read -rp "Delete ALL of the above? This cannot be undone. [y/N] " ans
case "$ans" in y|Y|yes|YES) ;; *) echo "aborted"; exit 1;; esac
fi
wipe() {
local label="$1" table="$2" total=0 ids n
while :; do
ids="$(curl -s -H "xc-token: $TOKEN" "$BASE_URL/api/v2/tables/$table/records?limit=1000&fields=Id" \
| python3 -c 'import sys,json;print(json.dumps([{"Id":r["Id"]} for r in json.load(sys.stdin)["list"]]))')"
n="$(printf '%s' "$ids" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')"
[ "$n" -eq 0 ] && break
curl -s -o /dev/null -X DELETE -H "xc-token: $TOKEN" -H "Content-Type: application/json" \
"$BASE_URL/api/v2/tables/$table/records" --data "$ids"
total=$((total + n))
done
echo " $label: deleted $total"
}
wipe "tickets" "$TICKETS"
[ -n "$AUDIT" ] && wipe "audit" "$AUDIT"
echo "Done — slate is clean."