Compare commits

..

22 commits
v0.0.6 ... 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
251edfce42 CI: upload APK to the Forgejo release via API instead of forgejo-release action
All checks were successful
Build Android APK / build-apk (push) Successful in 40m23s
The build succeeds but publishing failed with "$FORGEJO_PATH: ambiguous
redirect" — the moving actions/forgejo-release@v2 tag updated to a broken
version. Replace it with direct Forgejo API calls (create release, delete any
prior same-named asset, upload the APK) using the built-in token, so the last
step is under our control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 05:54:36 +00:00
848c8c7ee8 compose: read env from backend/.env (single source of truth)
Was ./.env, which required a manual copy from backend/.env and could go stale
(e.g. a changed PORTAL_PASSWORD not taking effect). Point env_file straight at
backend/.env so there's one file to edit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 03:53:44 +00:00
4b78c82b35 Add /crush33 comp-ticket portal + ticket-type badge on scan
Some checks failed
Build Android APK / build-apk (push) Failing after 1h11m4s
Portal (/crush33): password-gated page (PORTAL_PASSWORD) for admins to create
entry-only tickets from just name + email, with a category
(Guest/Worker/Performer/Volunteer/Speaker). Creates a 1-admission ticket, emails
the QR, and shows the QR on-screen. New Ticket Type column.

Scanner: shows a prominent TYPE badge (🎭 PERFORMER, 🛠️ WORKER, …) on the
confirm + success screens and in admin, so staff can see it's a special ticket.
Added Worker + Performer personas to /test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 03:30:08 +00:00
b7c77afe02 Rework webhook + model for the 2026 Tickets form
New form schema: up to 10 named adults, youth 13-16, kids 10-12 / 5-9 / 0-4,
donor tier/vouchers (from the lookups), parking/RV/UTV/ice payment fields.
- Scannable total = adults + youth + kids 10-12 + kids 5-9 (kids 0-4 free).
- Store + display adult names on a good scan; show donor tier, UTV, vouchers.
- Ice: payment_ice = 1-4 tickets ($20 each), 1 ticket = 3 bags.
- New NocoDB 2026 schema (with Id PK); webhook parses compound names,
  quantity/payment fields (nested objects or money strings).
- Our webhook keeps sending the QR ticket email (FluentForms sends the receipt);
  from address is now info@beartariacampgrounds.com.
Updated /test personas, /webhook-doc, tests, and the app display.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 03:23:06 +00:00
718d1515b0 docs: dedicated ticket-voucher lookup page under fluent
Move the ticket-voucher API into its own standalone doc
(docs/fluentforms-ticket-vouchers.md) — endpoint, 0/1/2 rules, config, form
snippet, and curl test — so it's easy to find and hand off for the lookup. The
donor-discount doc now links to it instead of duplicating the section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 06:36:39 +00:00
0b4ad1c99f Add ticket-voucher entitlement lookup + document both public APIs
GET /api/public/ticket-vouchers?key=&email= returns 0/1/2 free tickets based on
donations on/after VOUCHER_SINCE (default 2025-09-04): >= $400 -> 1, >= $1000 -> 2.
Same secret/CORS/rate-limit as donor-eligibility; returns only the count. Cutoff
and thresholds are env-configurable. Documented both lookup APIs (discount +
vouchers) in docs/fluentforms-donor-discount.md with form snippets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 06:27:32 +00:00
42043957e0 Add bear + QR app icon
Some checks failed
Build Android APK / build-apk (push) Failing after 35m46s
A green icon where a QR code's finder squares form a bear face — brown ears on
top, cream snout in the center. Wired as the main icon, the Android adaptive
icon (foreground + #0f1a12 background), and the web/PWA icons + favicon.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 16:42:35 +00:00
49 changed files with 2982 additions and 286 deletions

View file

@ -17,8 +17,17 @@ NOCODB_DONORS_TABLE_ID=
NOCODB_DONOR_ONLINE_TABLE_ID= NOCODB_DONOR_ONLINE_TABLE_ID=
NOCODB_DONOR_OFFLINE_TABLE_ID= NOCODB_DONOR_OFFLINE_TABLE_ID=
# Ice bags granted when a purchase includes ice but the webhook sends only a boolean # Ice: form sells 1-4 ice tickets at $20 each; one ticket = 3 bags. The webhook
ICE_BAGS_DEFAULT=3 # reads payment_ice as a ticket count (1-4) or a dollar total ($20-$80).
ICE_TICKET_PRICE=20
ICE_BAGS_PER_TICKET=3
# Ticket-voucher entitlement (donor free tickets). Donations on/after
# VOUCHER_SINCE totalling >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the
# date each year. NOTE: with no donations after the cutoff, everyone gets 0.
VOUCHER_SINCE=2025-09-04
VOUCHER_TIER1_MIN=400
VOUCHER_TIER2_MIN=1000
# Serve /test with sample QR codes (seeds test personas into the CURRENT table). # Serve /test with sample QR codes (seeds test personas into the CURRENT table).
# Keep this false/empty in production — only enable when pointed at a TEST table. # Keep this false/empty in production — only enable when pointed at a TEST table.
@ -26,7 +35,7 @@ ENABLE_TEST_PAGE=false
# MailerSend # MailerSend
MAILERSEND_API_TOKEN= MAILERSEND_API_TOKEN=
MAIL_FROM_EMAIL=tickets@beartariacampgrounds.com MAIL_FROM_EMAIL=info@beartariacampgrounds.com
MAIL_FROM_NAME=Beartaria Campgrounds MAIL_FROM_NAME=Beartaria Campgrounds
# Shared secret FluentForms sends in the X-Webhook-Secret header (long random string) # Shared secret FluentForms sends in the X-Webhook-Secret header (long random string)

View file

@ -139,14 +139,30 @@ jobs:
cp app/build/outputs/apk/release/app-release.apk \ cp app/build/outputs/apk/release/app-release.apk \
"$GITHUB_WORKSPACE/artifacts/camp-scan-${{ steps.ver.outputs.tag }}.apk" "$GITHUB_WORKSPACE/artifacts/camp-scan-${{ steps.ver.outputs.tag }}.apk"
- name: Publish Forgejo release with APK - name: Publish APK to Forgejo release
uses: actions/forgejo-release@v2 env:
with: TOKEN: ${{ secrets.GITHUB_TOKEN }}
direction: upload TAG: ${{ steps.ver.outputs.tag }}
url: https://git.mowden.top run: |
repo: Beartaria/CampgroundTickets set -eu
tag: ${{ steps.ver.outputs.tag }} API="https://git.mowden.top/api/v1/repos/Beartaria/CampgroundTickets"
token: ${{ secrets.GITHUB_TOKEN }} APK="$GITHUB_WORKSPACE/artifacts/camp-scan-${TAG}.apk"
release-dir: artifacts AUTH="Authorization: token ${TOKEN}"
release-notes: "Camp Scan ${{ steps.ver.outputs.tag }} — install/update via Obtainium." # Create the release for this tag (ignore failure if it already exists).
override: true curl -sS -X POST "$API/releases" -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"tag_name\":\"${TAG}\",\"name\":\"Camp Scan ${TAG}\",\"body\":\"Install/update via Obtainium.\"}" \
-o /dev/null -w "create release: %{http_code}\n" || true
# Look up the release id by tag.
REL_ID=$(curl -sS "$API/releases/tags/${TAG}" -H "$AUTH" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
echo "release id: ${REL_ID}"
test -n "$REL_ID"
# Remove a same-named asset from a prior run, then upload the APK.
EXISTING=$(curl -sS "$API/releases/${REL_ID}/assets" -H "$AUTH" \
| tr '}' '\n' | grep -F "camp-scan-${TAG}.apk" | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*' || true)
if [ -n "${EXISTING:-}" ]; then
curl -sS -X DELETE "$API/releases/${REL_ID}/assets/${EXISTING}" -H "$AUTH" -o /dev/null -w "delete old asset: %{http_code}\n"
fi
curl -sS -f -X POST "$API/releases/${REL_ID}/assets?name=camp-scan-${TAG}.apk" \
-H "$AUTH" -F "attachment=@${APK};type=application/vnd.android.package-archive" \
-o /dev/null -w "upload apk: %{http_code}\n"
echo "Published camp-scan-${TAG}.apk"

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,14 +2,19 @@
"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",
"newArchEnabled": true, "newArchEnabled": true,
"icon": "./assets/icon.png",
"android": { "android": {
"package": "top.mowden.campscan", "package": "top.mowden.campscan",
"versionCode": 1, "versionCode": 3,
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0f1a12"
},
"permissions": [ "permissions": [
"android.permission.CAMERA", "android.permission.CAMERA",
"android.permission.VIBRATE" "android.permission.VIBRATE"

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 }} />
@ -201,11 +201,15 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti
}, [ticket.redeemed, showHistory]); }, [ticket.redeemed, showHistory]);
const tags: string[] = []; const tags: string[] = [];
if (ticket.extras.carParking) tags.push("🚗 Car"); const e = ticket.extras;
if (ticket.extras.rvParking) tags.push("🚐 RV"); if (ticket.ticketType) tags.push(`🎫 ${ticket.ticketType}`);
if (ticket.extras.iceAccess) tags.push("🧊 Ice"); if (e.donorTier === "member") tags.push("🐻 Member");
if (ticket.extras.isDonor) tags.push("⭐ Donor"); else if (e.isDonor) tags.push("⭐ Donor");
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} free`); if (e.carParking) tags.push("🚗 Car");
if (e.rvParking) tags.push("🚐 RV");
if (e.utv) tags.push("🏍️ UTV");
if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total}`);
if (e.freeKids > 0) tags.push(`👶 ${e.freeKids} free kids`);
return ( return (
<View style={styles.card}> <View style={styles.card}>
@ -272,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}>
@ -242,6 +235,7 @@ export default function ScannerScreen() {
{phase === "success" && ticket && ( {phase === "success" && ticket && (
<ResultOverlay status="success" onDismiss={resume}> <ResultOverlay status="success" onDismiss={resume}>
<Text style={styles.bigIcon}></Text> <Text style={styles.bigIcon}></Text>
<TypeBadge type={ticket.ticketType} />
{isIce ? ( {isIce ? (
<> <>
<Text style={styles.bigTitle}> <Text style={styles.bigTitle}>
@ -259,6 +253,8 @@ 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} />
<ExtrasRow ticket={ticket} /> <ExtrasRow ticket={ticket} />
</> </>
)} )}
@ -345,11 +341,14 @@ function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticke
function ExtrasRow({ ticket }: { ticket: TicketView }) { function ExtrasRow({ ticket }: { ticket: TicketView }) {
const tags: string[] = []; const tags: string[] = [];
if (ticket.extras.carParking) tags.push("🚗 Car parking"); const e = ticket.extras;
if (ticket.extras.rvParking) tags.push("🚐 RV parking"); if (e.donorTier === "member") tags.push("🐻 Member");
if (ticket.extras.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`); else if (e.isDonor) tags.push("⭐ Donor");
if (ticket.extras.isDonor) tags.push("⭐ Donor"); if (e.carParking) tags.push("🚗 Car parking");
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} under 4 (free)`); if (e.rvParking) tags.push("🚐 RV parking");
if (e.utv) tags.push("🏍️ UTV/ATV");
if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`);
if (e.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}>
@ -362,6 +361,79 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) {
); );
} }
const TYPE_ICON: Record<string, string> = {
Guest: "🎫",
Worker: "🛠️",
Performer: "🎭",
Volunteer: "🙌",
Speaker: "🎤",
"Food Vendor": "🍔",
Vendor: "🛒",
};
function TypeBadge({ type }: { type: string }) {
if (!type) return null;
return (
<View style={styles.typeBadge}>
<Text style={styles.typeBadgeText}>
{(TYPE_ICON[type] ?? "🎫") + " " + type.toUpperCase()}
</Text>
</View>
);
}
function AdultNames({ names }: { names: string[] }) {
if (!names.length) return null;
return (
<View style={styles.namesBox}>
{names.map((n, i) => (
<Text key={i} style={styles.nameLine}>
{n}
</Text>
))}
</View>
);
}
/**
* 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,
@ -388,11 +460,14 @@ function ConfirmCard({
return ( return (
<ScrollView style={styles.card} contentContainerStyle={styles.cardContent}> <ScrollView style={styles.card} contentContainerStyle={styles.cardContent}>
<Text style={styles.cardName}>{ticket.name}</Text> <Text style={styles.cardName}>{ticket.name}</Text>
<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>
<Text style={styles.cardSub}>{redeemed} already redeemed</Text> <Text style={styles.cardSub}>{redeemed} already redeemed</Text>
{!isIce && <AdultNames names={ticket.adultNames} />}
{!isIce && <ExtrasRow ticket={ticket} />} {!isIce && <ExtrasRow ticket={ticket} />}
{isIce && total === 0 && <Text style={styles.exhausted}>This ticket did not prepay for ice.</Text>} {isIce && total === 0 && <Text style={styles.exhausted}>This ticket did not prepay for ice.</Text>}
@ -435,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" },
@ -489,6 +566,33 @@ const styles = StyleSheet.create({
donorFigureDivider: { width: 1, alignSelf: "stretch", backgroundColor: "rgba(255,255,255,0.35)", marginVertical: 8 }, donorFigureDivider: { width: 1, alignSelf: "stretch", backgroundColor: "rgba(255,255,255,0.35)", marginVertical: 8 },
donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 18 }, donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 18 },
typeBadge: {
backgroundColor: "rgba(255,255,255,0.22)",
borderRadius: 999,
paddingHorizontal: 18,
paddingVertical: 8,
marginTop: 10,
},
typeBadgeText: { color: "#fff", fontSize: 20, fontWeight: "900", letterSpacing: 1 },
namesBox: { marginTop: 12, alignItems: "center", gap: 3 },
nameLine: { color: "#fff", fontSize: 18, fontWeight: "600", textAlign: "center" },
party: {
alignSelf: "stretch",
backgroundColor: "#1d2a1f",
borderWidth: 2,
borderColor: theme.warn,
borderRadius: 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 },
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 B

After

Width:  |  Height:  |  Size: 4.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 773 B

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 112 KiB

Before After
Before After

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

@ -21,16 +21,22 @@ export interface TicketView {
code: string; code: string;
name: string; name: string;
email: string; email: string;
ticketType: string;
createdBy: string;
total: number; total: number;
redeemed: number; redeemed: number;
remaining: number; remaining: number;
ice: ResourceCount; ice: ResourceCount;
adultNames: string[];
extras: { extras: {
carParking: boolean; carParking: boolean;
rvParking: boolean; rvParking: boolean;
utv: boolean;
iceAccess: boolean; iceAccess: boolean;
isDonor: boolean; isDonor: boolean;
freeUnder4: number; donorTier: string;
vouchers: number;
freeKids: number;
}; };
ages: { bracket: string; count: number; free: boolean }[]; ages: { bracket: string; count: number; free: boolean }[];
} }
@ -189,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);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 B

After

Width:  |  Height:  |  Size: 4.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 773 B

After

Width:  |  Height:  |  Size: 38 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Before After
Before After

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.
@ -17,15 +21,33 @@ const schema = z.object({
NOCODB_DONOR_ONLINE_TABLE_ID: z.string().optional(), NOCODB_DONOR_ONLINE_TABLE_ID: z.string().optional(),
NOCODB_DONOR_OFFLINE_TABLE_ID: z.string().optional(), NOCODB_DONOR_OFFLINE_TABLE_ID: z.string().optional(),
// Prepaid ice bags granted when a purchase includes ice but the webhook only // Ice: the form sells 1-4 ice "tickets" at $20 each; one ticket = 3 bags.
// sends a boolean (not an explicit bag count). // The webhook reads payment_ice as either a ticket count (1-4) or a dollar
ICE_BAGS_DEFAULT: z.coerce.number().default(3), // total ($20-$80) and stores bags = tickets * ICE_BAGS_PER_TICKET.
ICE_TICKET_PRICE: z.coerce.number().default(20),
ICE_BAGS_PER_TICKET: z.coerce.number().default(3),
// Public donor-eligibility lookup (for the FluentForms checkout discount). // Public donor-eligibility lookup (for the FluentForms checkout discount).
// 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
// >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the date each year.
VOUCHER_SINCE: z.string().default("2025-09-04"),
VOUCHER_TIER1_MIN: z.coerce.number().default(400),
VOUCHER_TIER2_MIN: z.coerce.number().default(1000),
// Serve GET /test with sample QR codes. Seeds test personas into the current // Serve GET /test with sample QR codes. Seeds test personas into the current
// NocoDB table, so keep this OFF in production (only enable against a TEST table). // NocoDB table, so keep this OFF in production (only enable against a TEST table).
@ -40,6 +62,8 @@ const schema = z.object({
WEBHOOK_SECRET: z.string().min(1), WEBHOOK_SECRET: z.string().min(1),
EVENT_PIN: z.string().min(1), EVENT_PIN: z.string().min(1),
// Shared password for the /crush33 comp-ticket portal (workers/guests).
PORTAL_PASSWORD: z.string().optional(),
TOKEN_SECRET: z.string().min(16), TOKEN_SECRET: z.string().min(16),
TOKEN_TTL: z.string().default("30d"), TOKEN_TTL: z.string().default("30d"),

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

@ -1,48 +1,41 @@
/** /**
* Mapping between the NocoDB "2026 Campground Tickets" table columns, the * Mapping for the "2026 Campground Tickets" NocoDB table, matching the 2026
* webhook payload keys, and the view we return to the app. * FluentForms "Tickets 2026" schema. Change titles here if the columns differ.
*
* The 2026 table is a clone of the 2025 submission table (per-purchase record
* with age-bracket headcounts, parking/ice flags, donor flag) PLUS four columns
* this system adds: Ticket Code, Redeemed, SubmissionKey, LastScanAt.
*
* If the real column titles differ, change them here in one place.
*/ */
export const COL = { export const COL = {
id: "Id", id: "Id",
name: "Title", // first column in the 2025 table holds the purchaser name name: "Title", // purchaser full name
adultNames: "Adult Names", // newline-separated list of adult attendee names
email: "Email Address", email: "Email Address",
address: "Address", address: "Address",
isDonor: "Is Donor", isDonor: "Is Donor",
donorTier: "Donor Tier", // member / donor / ""
vouchers: "Vouchers",
// Attendee counts by group:
adults: "Adults",
youth: "Youth 13-16",
kids12: "Kids 10-12",
kids9: "Kids 5-9",
kids4: "Kids 0-4", // free — NOT counted toward the scannable total
carParking: "Car Parking", carParking: "Car Parking",
rvParking: "RV Parking", rvParking: "RV Parking",
utv: "UTV",
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
createdBy: "Created By", // gate-staff name who issued a comp ticket (portal)
// Columns this system adds to the table: // Columns this system manages:
code: "Ticket Code", code: "Ticket Code",
redeemed: "Redeemed", redeemed: "Redeemed",
submissionKey: "SubmissionKey", submissionKey: "SubmissionKey",
lastScanAt: "LastScanAt", lastScanAt: "LastScanAt",
iceTotal: "Ice Total", // prepaid ice bags iceTotal: "Ice Total",
iceRedeemed: "Ice Redeemed", // bags picked up iceRedeemed: "Ice Redeemed",
} as const; } as const;
/** Age-bracket columns, in order. */
export const AGE_COLUMNS = [
"Ages 0-3",
"Ages 4-7",
"Ages 8-12",
"Ages 13-17",
"Ages 18-25",
"Ages 26-45",
"Ages 46-64",
"Ages 65+",
] as const;
/** Age brackets admitted free and NOT counted as redeemable tickets. */
export const FREE_AGE_COLUMNS: readonly string[] = ["Ages 0-3"];
export type NocoRecord = Record<string, unknown> & { Id: number }; export type NocoRecord = Record<string, unknown> & { Id: number };
function num(v: unknown): number { function num(v: unknown): number {
@ -57,23 +50,46 @@ function bool(v: unknown): boolean {
return false; return false;
} }
/** Total redeemable tickets = sum of age brackets minus the free ones. */ /**
* Total scannable (paid) tickets = adults + youth 13-16. Children 12 and under
* (kids 10-12 / 5-9 / 0-4) are admitted free and not counted; charging starts
* at age 13.
*/
export function computeTotal(rec: NocoRecord): number { export function computeTotal(rec: NocoRecord): number {
let total = 0; return num(rec[COL.adults]) + num(rec[COL.youth]);
for (const col of AGE_COLUMNS) {
if (FREE_AGE_COLUMNS.includes(col)) continue;
total += num(rec[col]);
}
return total;
} }
/** Per-bracket breakdown for display. */ /** 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 {
return num(rec[COL.iceTotal]);
}
/** Per-group breakdown for display. */
export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number; free: boolean }[] { export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number; free: boolean }[] {
return AGE_COLUMNS.map((col) => ({ return [
bracket: col.replace(/^Ages /, ""), { bracket: "Adults", count: num(rec[COL.adults]), free: false },
count: num(rec[col]), { bracket: "Youth 13-16", count: num(rec[COL.youth]), free: false },
free: FREE_AGE_COLUMNS.includes(col), { bracket: "Kids 10-12", count: num(rec[COL.kids12]), free: true },
})).filter((b) => b.count > 0); { bracket: "Kids 5-9", count: num(rec[COL.kids9]), free: true },
{ bracket: "Kids 0-4", count: num(rec[COL.kids4]), free: true },
].filter((b) => b.count > 0);
}
/** Adult names stored as a newline-separated list. */
export function parseAdultNames(rec: NocoRecord): string[] {
const raw = rec[COL.adultNames];
if (Array.isArray(raw)) return raw.map((x) => String(x)).filter(Boolean);
if (typeof raw === "string") {
return raw
.split(/\r?\n/)
.map((s) => s.trim())
.filter(Boolean);
}
return [];
} }
export interface ResourceCount { export interface ResourceCount {
@ -86,24 +102,26 @@ export interface TicketView {
code: string; code: string;
name: string; name: string;
email: string; email: string;
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;
ice: ResourceCount; ice: ResourceCount;
adultNames: string[];
extras: { extras: {
carParking: boolean; carParking: boolean;
rvParking: boolean; rvParking: boolean;
utv: boolean;
iceAccess: boolean; iceAccess: boolean;
isDonor: boolean; isDonor: boolean;
freeUnder4: number; donorTier: string;
vouchers: number;
freeKids: number; // children 12 & under (free admission)
}; };
ages: { bracket: string; count: number; free: boolean }[]; ages: { bracket: string; count: number; free: boolean }[];
} }
export function computeIceTotal(rec: NocoRecord): number {
return num(rec[COL.iceTotal]);
}
export function toView(rec: NocoRecord): TicketView { export function toView(rec: NocoRecord): TicketView {
const total = computeTotal(rec); const total = computeTotal(rec);
const redeemed = num(rec[COL.redeemed]); const redeemed = num(rec[COL.redeemed]);
@ -113,6 +131,8 @@ export function toView(rec: NocoRecord): TicketView {
code: String(rec[COL.code] ?? ""), code: String(rec[COL.code] ?? ""),
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] ?? ""),
createdBy: String(rec[COL.createdBy] ?? ""),
total, total,
redeemed, redeemed,
remaining: Math.max(0, total - redeemed), remaining: Math.max(0, total - redeemed),
@ -121,12 +141,16 @@ export function toView(rec: NocoRecord): TicketView {
redeemed: iceRedeemed, redeemed: iceRedeemed,
remaining: Math.max(0, iceTotal - iceRedeemed), remaining: Math.max(0, iceTotal - iceRedeemed),
}, },
adultNames: parseAdultNames(rec),
extras: { extras: {
carParking: bool(rec[COL.carParking]), carParking: bool(rec[COL.carParking]),
rvParking: bool(rec[COL.rvParking]), rvParking: bool(rec[COL.rvParking]),
utv: bool(rec[COL.utv]),
iceAccess: bool(rec[COL.iceAccess]), iceAccess: bool(rec[COL.iceAccess]),
isDonor: bool(rec[COL.isDonor]), isDonor: bool(rec[COL.isDonor]),
freeUnder4: num(rec["Ages 0-3"]), donorTier: String(rec[COL.donorTier] ?? ""),
vouchers: num(rec[COL.vouchers]),
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

@ -0,0 +1,452 @@
import { timingSafeEqual } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { createTicket } from "../ticketService.js";
import { renderQrPng, renderQrDataUrl } from "../services/qrcode.js";
const TYPES = ["Guest", "Worker", "Performer", "Volunteer", "Speaker"];
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);
}
/**
* /crush33 password-gated comp-ticket portal for admins. Creates entry-only
* tickets (1 admission, no demographics/ice) with a category (Guest/Worker/)
* that shows on the scanner. Password checked server-side per request.
*/
export async function portalRoutes(app: FastifyInstance): Promise<void> {
app.get("/crush33", async (_req, reply) => {
reply.type("text/html").send(PAGE);
});
// Password check only (for the in-app portal to gate its form).
app.post(
"/api/portal/verify",
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
async (req, reply) => {
const cfg = app.ctx.config;
if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" });
const b = (req.body ?? {}) as { password?: string };
if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) {
return reply.code(401).send({ error: "bad_password" });
}
return { ok: true, types: TYPES };
},
);
app.post(
"/api/portal/create-ticket",
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
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;
name?: string;
email?: string;
type?: string;
createdBy?: string;
};
if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) {
return reply.code(401).send({ error: "bad_password" });
}
const name = String(b.name ?? "").trim();
const email = String(b.email ?? "").trim();
const type = TYPES.includes(String(b.type)) ? String(b.type) : "Guest";
// Who issued it — from the in-app portal (signed-in gate staff) or header.
const createdBy = String(b.createdBy ?? req.headers["x-operator"] ?? "").slice(0, 80).trim();
if (!name || !email) {
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" });
}
let result: Awaited<ReturnType<typeof createTicket>>;
try {
result = await createTicket(app.ctx, {
name,
adultNames: [name],
email,
ticketType: type,
createdBy,
counts: { adults: 1, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
submissionKey: `portal:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`,
});
} catch (e: any) {
req.log.error({ err: e }, "portal: create failed");
return reply.code(502).send({ error: "db_error", detail: e?.message });
}
// Email the QR (best-effort — the portal also shows it on-screen).
let emailSent = false;
if (!app.ctx.mailer.isBlockedRecipient(email)) {
try {
const png = await renderQrPng(result.code);
await app.ctx.mailer.sendTicket({ toEmail: email, toName: name, code: result.code, quantity: 1, qrPng: png });
emailSent = true;
} catch (e: any) {
req.log.error({ err: e, code: result.code }, "portal: email failed");
}
}
const qr = await renderQrDataUrl(result.code);
return { ok: true, code: result.code, type, name, emailSent, qr };
},
);
}
const PAGE = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f1a12" />
<title>Camp Scan Admin (crush33)</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; }
a { color: #58d68d; }
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: 12px 14px; color: #eaf2ec; font-size: 15px; }
.btn { background: #25c05a; color: #06210f; font-weight: 800; font-size: 16px; border: none; padding: 13px 18px; border-radius: 12px; cursor: pointer; }
.btn:disabled { opacity: 0.5; cursor: default; }
.btn-red { background: #e04343; color: #fff; }
.btn-ghost { background: transparent; color: #eaf2ec; border: 1px solid #2e7d32; }
.msg { margin-top: 12px; font-size: 14px; font-weight: 600; min-height: 18px; }
.err { color: #e04343; } .ok { color: #58d68d; }
.mono { font-family: ui-monospace, Menlo, monospace; }
/* Unlock */
#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>
</head>
<body>
<div id="unlock">
<div class="logo">🐻</div>
<h1>Admin · crush33</h1>
<p class="sub">Admin-only area. Enter the shared portal password.</p>
<input id="pw" type="password" autocomplete="current-password" placeholder="Portal password" />
<button class="btn" id="unlockBtn">Unlock</button>
<div id="unlockMsg" class="msg" style="text-align:center"></div>
<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>
<div class="pills" id="typePills">
<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>
</div>
<label>Full name</label>
<input id="cName" type="text" autocomplete="off" placeholder="Attendee name" />
<label>Email</label>
<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>
<!-- Donors -->
<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>
<!-- Actions -->
<div class="sec" id="sec-actions">
<h2>Actions</h2>
<p class="lead">Event-management tools. These change live data read the warnings.</p>
<div class="status">
<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>
<script>
var $ = function (id) { return document.getElementById(id); };
var PW = "";
var counts = { tickets: "?", audit: "?", table: "?" };
var pendingAction = null;
function api(path, body) {
return fetch(path, { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.assign({ password: PW }, body || {})) })
.then(function (r) { return r.json().then(function (d) { return { s: r.status, d: d }; }); });
}
function relock(m) { PW = ""; $("hub").style.display = "none"; $("unlock").style.display = "block";
$("unlockMsg").textContent = m || ""; $("unlockMsg").className = "msg err"; }
// ---- Unlock ----
function unlock() {
var pw = $("pw").value;
if (!pw) { $("unlockMsg").textContent = "Enter the password."; $("unlockMsg").className = "msg err"; return; }
$("unlockBtn").disabled = true; $("unlockMsg").textContent = "Checking…"; $("unlockMsg").className = "msg ok";
fetch("/api/portal/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: pw }) })
.then(function (r) { return r.status; })
.then(function (s) {
$("unlockBtn").disabled = false;
if (s !== 200) { $("unlockMsg").textContent = "Wrong password."; $("unlockMsg").className = "msg err"; return; }
PW = pw; $("unlockMsg").textContent = ""; $("unlock").style.display = "none"; $("hub").style.display = "block";
loadStatus();
})
.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."); });
});
// ---- 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>
</body>
</html>`;

View file

@ -17,25 +17,36 @@ 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).
app.options("/api/public/donor-eligibility", async (_req, reply) => { 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/ticket-vouchers", preflight);
const checkSecret = (req: any): boolean => {
const { key } = (req.query ?? {}) as { key?: string };
return !!cfg.PUBLIC_LOOKUP_SECRET && !!key && safeEqual(key, cfg.PUBLIC_LOOKUP_SECRET);
};
app.get( app.get(
"/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" });
@ -57,4 +68,42 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
} }
}, },
); );
// Ticket-voucher entitlement: how many FREE tickets a donor has left. This is
// the tier entitlement earned from giving on/after VOUCHER_SINCE, MINUS the
// vouchers already consumed by their prior ticket orders (each order stores
// how many it used), so a donor can't keep claiming free tickets by
// re-submitting the form. `vouchers` is the remaining count the form should
// grant; `entitled`/`used`/`remaining` are the breakdown. No dollar amounts.
//
// To reset for testing: zero out (or delete) the "Vouchers" value on that
// donor's ticket order row(s) in NocoDB — `used` drops and `remaining` rises.
app.get(
"/api/public/ticket-vouchers",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
cors(req, reply);
if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) {
return reply.code(404).send({ error: "not_available" });
}
if (!checkSecret(req)) {
return reply.code(401).send({ error: "unauthorized" });
}
const { email } = (req.query ?? {}) as { email?: string };
const addr = String(email ?? "").trim();
if (!addr) return { vouchers: 0, entitled: 0, used: 0, remaining: 0 };
try {
const cutoff = new Date(cfg.VOUCHER_SINCE);
const { amount } = await app.ctx.donors.amountSince(addr, cutoff);
const entitled = amount >= cfg.VOUCHER_TIER2_MIN ? 2 : amount >= cfg.VOUCHER_TIER1_MIN ? 1 : 0;
const used = await app.ctx.nocodb.vouchersUsedByEmail(addr);
const remaining = Math.max(0, entitled - used);
return { vouchers: remaining, entitled, used, remaining };
} catch {
// Fail closed — grant no vouchers rather than error.
return { vouchers: 0, entitled: 0, used: 0, remaining: 0 };
}
},
);
} }

View file

@ -7,60 +7,90 @@ interface Persona {
key: string; key: string;
name: string; name: string;
email: string; email: string;
ages: Record<string, number>; adultNames?: string[];
counts: { adults: number; youth: number; kids12: number; kids9: number; kids4: number };
iceBags?: number; iceBags?: number;
carParking?: boolean; carParking?: boolean;
rvParking?: boolean; rvParking?: boolean;
utv?: boolean;
isDonor?: boolean; isDonor?: boolean;
donorTier?: string;
ticketType?: string;
exhaust?: boolean; // pre-redeem all tickets so it scans as "exhausted" exhaust?: boolean; // pre-redeem all tickets so it scans as "exhausted"
blurb: string; blurb: string;
} }
const C = (adults = 0, youth = 0, kids12 = 0, kids9 = 0, kids4 = 0) => ({ adults, youth, kids12, kids9, kids4 });
// A curated set covering the different attribute combinations to test. // A curated set covering the different attribute combinations to test.
const PERSONAS: Persona[] = [ const PERSONAS: Persona[] = [
{ {
key: "solo", key: "solo",
name: "Solo Sam", name: "Solo Sam",
email: "solo@test.beartaria", email: "solo@test.beartaria",
ages: { "Ages 18-25": 1 }, adultNames: ["Solo Sam"],
counts: C(1),
blurb: "1 ticket, no extras. Check-in mode → green, 1/1.", blurb: "1 ticket, no extras. Check-in mode → green, 1/1.",
}, },
{ {
key: "family", key: "family",
name: "Family Fay", name: "Family Fay",
email: "family@test.beartaria", email: "family@test.beartaria",
ages: { "Ages 0-3": 2, "Ages 8-12": 3, "Ages 26-45": 2 }, adultNames: ["Family Fay", "Frank Fay"],
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 under-4 free), car parking, 3 ice bags. Check-in a few at a time to test QR reuse; 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",
name: "Donor Dan", name: "Donor Dan",
email: "donor@example.test", email: "donor@example.test",
ages: { "Ages 26-45": 2 }, adultNames: ["Donor Dan", "Donna Dan"],
counts: C(2),
rvParking: true, rvParking: true,
utv: true,
isDonor: true, isDonor: true,
donorTier: "member",
blurb: blurb:
"2 tickets, RV parking, donor-flagged. Banquet mode: this test email has no real donations, so use Banquet's manual email lookup with a real donor's address to see totals.", "2 tickets, RV + UTV, donor/member. Banquet mode: this test email has no real donations — use Banquet's manual email lookup with a real donor's address.",
}, },
{ {
key: "ice", key: "ice",
name: "Ice Ike", name: "Ice Ike",
email: "ice@test.beartaria", email: "ice@test.beartaria",
ages: { "Ages 18-25": 1 }, adultNames: ["Ice Ike"],
iceBags: 3, counts: C(1),
blurb: "1 ticket + 3 ice bags. Ice mode → grab all 3 at once, then scan again → exhausted.", iceBags: 6, // 2 ice tickets
blurb: "1 ticket + 6 ice bags (2 ice tickets). Ice mode → grab bags, then scan again → exhausted.",
}, },
{ {
key: "exhausted", key: "exhausted",
name: "Done Dora", name: "Done Dora",
email: "done@test.beartaria", email: "done@test.beartaria",
ages: { "Ages 26-45": 2 }, counts: C(2),
exhaust: true, exhaust: true,
blurb: "2 tickets, already fully redeemed. Check-in mode → red 'exhausted'.", blurb: "2 tickets, already fully redeemed. Check-in mode → red 'exhausted'.",
}, },
{
key: "worker",
name: "Wanda Worker",
email: "worker@test.beartaria",
adultNames: ["Wanda Worker"],
counts: C(1),
ticketType: "Worker",
blurb: "Entry-only WORKER comp ticket. Check-in mode → green with a Worker badge.",
},
{
key: "performer",
name: "Perry Performer",
email: "performer@test.beartaria",
adultNames: ["Perry Performer"],
counts: C(1),
ticketType: "Performer",
blurb: "Entry-only PERFORMER comp ticket. Check-in mode → green with a Performer badge.",
},
]; ];
const INVALID_CODE = "BC26-0000-0000"; // not in the DB → scans as "not found" const INVALID_CODE = "BC26-0000-0000"; // not in the DB → scans as "not found"
@ -74,22 +104,23 @@ export async function testRoutes(app: FastifyInstance): Promise<void> {
for (const p of PERSONAS) { for (const p of PERSONAS) {
const result = await createTicket(app.ctx, { const result = await createTicket(app.ctx, {
name: p.name, name: p.name,
adultNames: p.adultNames,
email: p.email, email: p.email,
ages: p.ages, counts: p.counts,
iceBags: p.iceBags, iceBags: p.iceBags,
carParking: p.carParking, carParking: p.carParking,
rvParking: p.rvParking, rvParking: p.rvParking,
utv: p.utv,
isDonor: p.isDonor, isDonor: p.isDonor,
donorTier: p.donorTier,
ticketType: p.ticketType,
submissionKey: `test:${p.key}`, submissionKey: `test:${p.key}`,
}); });
// 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 (compute the total from the persona's own age counts, // is deterministic (total = scannable count from the persona's counts).
// since NocoDB's create response may not echo them back).
if (p.exhaust) { if (p.exhaust) {
const total = Object.entries(p.ages) const { adults, youth } = p.counts;
.filter(([col]) => col !== "Ages 0-3") await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth });
.reduce((s, [, n]) => s + n, 0);
await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: total });
} }
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;
@ -167,11 +174,16 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
schema: { schema: {
body: { body: {
type: "object", type: "object",
required: ["name", "ages"], required: ["name"],
properties: { properties: {
name: { type: "string", minLength: 1 }, name: { type: "string", minLength: 1 },
email: { type: "string" }, email: { type: "string" },
ages: { type: "object" }, adults: { type: "integer", minimum: 0 },
youth: { type: "integer", minimum: 0 },
kids12: { type: "integer", minimum: 0 },
kids9: { type: "integer", minimum: 0 },
kids4: { type: "integer", minimum: 0 },
adultNames: { type: "array", items: { type: "string" } },
sendEmail: { type: "boolean" }, sendEmail: { type: "boolean" },
}, },
}, },
@ -182,8 +194,15 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
const submissionKey = `manual:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`; const submissionKey = `manual:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`;
const result = await createTicket(app.ctx, { const result = await createTicket(app.ctx, {
name: b.name, name: b.name,
adultNames: b.adultNames,
email: b.email ?? "", email: b.email ?? "",
ages: b.ages, counts: {
adults: b.adults ?? 1,
youth: b.youth ?? 0,
kids12: b.kids12 ?? 0,
kids9: b.kids9 ?? 0,
kids4: b.kids4 ?? 0,
},
submissionKey, submissionKey,
}); });
if (b.sendEmail && b.email && !app.ctx.mailer.isBlockedRecipient(b.email)) { if (b.sendEmail && b.email && !app.ctx.mailer.isBlockedRecipient(b.email)) {

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,28 +1,21 @@
import { createHash, timingSafeEqual } from "node:crypto"; import { createHash } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { AGE_COLUMNS, 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
// Map webhook payload keys -> NocoDB age-column titles. Keys are what you map // separate quantity field for them).
// the FluentForms fields to in the webhook feed. const DONOR_NAME_BASES = ["names_Donor_1", "names_Donor_2"];
const AGE_KEY_TO_COL: Record<string, string> = { // All adult names for the gate display list.
ages_0_3: "Ages 0-3", const ADULT_NAME_BASES = [...REGULAR_NAME_BASES, ...DONOR_NAME_BASES];
ages_4_7: "Ages 4-7",
ages_8_12: "Ages 8-12",
ages_13_17: "Ages 13-17",
ages_18_25: "Ages 18-25",
ages_26_45: "Ages 26-45",
ages_46_64: "Ages 46-64",
ages_65: "Ages 65+",
};
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) => {
@ -31,57 +24,102 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
return reply.code(401).send({ error: "unauthorized" }); return reply.code(401).send({ error: "unauthorized" });
} }
const body = (req.body ?? {}) as Record<string, unknown>; const body = (req.body ?? {}) as Record<string, any>;
const name = String(body.name ?? "").trim();
const email = String(body.email ?? "").trim(); const email = String(body.email ?? "").trim();
if (!name || !email) {
return reply.code(400).send({ error: "missing_fields", detail: "name and email are 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" });
} }
// Build age-bracket counts from whichever keys were provided. // Attendee counts. Adults = regular (paid) tickets + additional paid donor
const ages: Record<string, number> = {}; // tickets + free voucher tickets (one per donor name provided).
for (const [key, col] of Object.entries(AGE_KEY_TO_COL)) { const counts = {
if (body[key] !== undefined && body[key] !== null && body[key] !== "") { adults:
ages[col] = toNumber(body[key]); qty(body.item_quantity_adult_ticket_reg) +
} qty(body.item_quantity_adult_ticket_donor) +
} voucherTickets,
const anyAge = AGE_COLUMNS.some((c) => (ages[c] ?? 0) > 0); youth: qty(body.item_quantity_youth_ticket_reg) + qty(body.item_quantity_youth_ticket_donor),
if (!anyAge) { kids12: qty(body.item_quantity_kids_12),
return reply.code(400).send({ error: "no_tickets", detail: "no age-bracket counts provided" }); kids9: qty(body.item_quantity_kids_9),
kids4: qty(body.item_quantity_kids_4),
};
// Paid/scannable admissions = adults + youth 13-16. Children 12 & under are
// free (charging starts at 13) and are stored but not counted at the gate.
const scannable = counts.adults + counts.youth;
// Donor info (hidden fields from the eligibility/voucher lookups) + radio.
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?"
// Vouchers consumed in this order = the free voucher tickets actually taken
// (donor names filled), which is what the ticket-voucher lookup subtracts.
const vouchers = voucherTickets;
// Extras (best-effort from payment fields — donor variants may be free/$0).
const carParking = selected(body.payment_parking_reg) || selected(body.payment_parking_donor);
const rvParking = selected(body.payment_rv_reg) || selected(body.payment_rv_donor);
const utv = selected(body.payment_utv_reg) || selected(body.payment_utv_donor);
// Ice: payment_ice is a descriptive option label whose "(N total bags)"
// states the bags. One ice ticket = ICE_BAGS_PER_TICKET bags.
const iceBags = iceBagsFromPayment(body.payment_ice, {
bagsPerTicket: app.ctx.config.ICE_BAGS_PER_TICKET,
ticketPrice: app.ctx.config.ICE_TICKET_PRICE,
});
const iceAccess = iceBags > 0 || selected(body.input_radio_7);
// Tickets are optional: a customer can buy ice/UTV/parking with no admission
// ticket, or buy tickets for others. Only reject a truly empty order —
// nothing to check in, redeem, or verify at the gate.
const hasIssuable = scannable > 0 || iceBags > 0 || utv || carParking || rvParking;
if (!hasIssuable) {
req.log.warn({ body }, "webhook: submission has nothing to issue");
return reply.code(400).send({ error: "no_items", detail: "no tickets, ice, or add-ons in submission" });
} }
// Idempotency key: prefer a stable submission id, else hash the content. const address = addressLine(body.address_1);
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id;
// Idempotency: prefer a stable submission id, else hash the content
// (include ice/extras so distinct add-on-only orders don't collide).
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id;
const submissionKey = submissionId const submissionKey = submissionId
? `sub:${String(submissionId)}` ? `sub:${String(submissionId)}`
: "hash:" + : "hash:" +
createHash("sha256") createHash("sha256")
.update(`${email}|${name}|${JSON.stringify(ages)}`) .update(`${email}|${title}|${JSON.stringify(counts)}|${iceBags}|${carParking}|${rvParking}|${utv}`)
.digest("hex") .digest("hex")
.slice(0, 32); .slice(0, 32);
// Ice: prefer an explicit bag count; else grant the default when a boolean
// ice option is truthy; else 0.
let iceBags = 0;
if (body.ice_bags !== undefined && body.ice_bags !== null && body.ice_bags !== "") {
iceBags = toNumber(body.ice_bags);
} else if (body.ice_access !== undefined && toBool(body.ice_access)) {
iceBags = app.ctx.config.ICE_BAGS_DEFAULT;
}
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,
email, email,
address: body.address !== undefined ? String(body.address) : undefined, address,
isDonor: body.is_donor !== undefined ? toBool(body.is_donor) : undefined, isDonor,
carParking: body.car_parking !== undefined ? toBool(body.car_parking) : undefined, donorTier,
rvParking: body.rv_parking !== undefined ? toBool(body.rv_parking) : undefined, vouchers,
iceAccess: body.ice_access !== undefined ? toBool(body.ice_access) : undefined, counts,
carParking,
rvParking,
utv,
iceAccess,
iceBags, iceBags,
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined, paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
ages,
submissionKey, submissionKey,
}); });
} catch (e: any) { } catch (e: any) {
@ -93,9 +131,13 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
return { status: "duplicate", code: result.code }; return { status: "duplicate", code: result.code };
} }
// Send the ticket email. If it fails, the row already exists — report 502 // Send the ticket QR email (FluentForms sends the receipt separately). If it
// so the failure is visible in FluentForms' delivery log; the ticket can be // fails, the row already exists — report 502 so it's visible in the feed
// re-sent later via POST /api/tickets/:code/resend-email. // log; re-send later via POST /api/tickets/:code/resend-email.
if (!email) {
req.log.warn({ code: result.code }, "webhook: ticket created but no email to send to");
return { status: "created", code: result.code, emailSent: false, emailSkipped: "no_email" };
}
if (app.ctx.mailer.isBlockedRecipient(email)) { if (app.ctx.mailer.isBlockedRecipient(email)) {
req.log.warn({ email }, "webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send"); req.log.warn({ email }, "webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send");
return { status: "created", code: result.code, emailSent: false, emailSkipped: "trial_restriction" }; return { status: "created", code: result.code, emailSent: false, emailSkipped: "trial_restriction" };
@ -103,14 +145,13 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
try { try {
const qr = await renderQrPng(result.code); const qr = await renderQrPng(result.code);
const quantity = // redeemable total for the email copy
AGE_COLUMNS.filter((c) => c !== "Ages 0-3").reduce((s, c) => s + (ages[c] ?? 0), 0);
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, 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,29 +11,28 @@ interface Field {
} }
const FIELDS: Field[] = [ const FIELDS: Field[] = [
{ key: "name", req: "required", type: "text", desc: "Purchaser's full name." }, { 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: "email", req: "required", type: "email", desc: "Purchaser's email — the QR ticket is sent here." }, { 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: "submission_id", { 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." },
req: "optional", { key: "email", req: "optional", type: "email", desc: "Purchaser email — the QR ticket is sent here (FluentForms sends the receipt separately)." },
type: "text/number", { key: "address_1", req: "optional", type: "address (compound)", desc: "Mailing address object; joined into one line." },
desc: "Form entry/submission ID. Used for idempotency so retries or double-submits don't create duplicate tickets. If omitted, a hash of name+email+counts is used instead. (Aliases: submissionId, entry_id.)", { key: "item_quantity_adult_ticket_reg", req: "required", type: "quantity", desc: "Regular (non-donor) adult tickets." },
}, { key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "ADDITIONAL paid donor adult tickets bought beyond the free vouchers. Added to the adult total; does NOT include the voucher tickets (those come from names_Donor_1/2)." },
{ key: "ages_0_3", req: "optional", type: "number", desc: "Headcount ages 03. Admitted free — NOT counted toward redeemable tickets." }, { key: "item_quantity_youth_ticket_reg / _donor", req: "optional", type: "quantity", desc: "Youth 13-16 tickets (regular + donor)." },
{ key: "ages_4_7", req: "optional", type: "number", desc: "Headcount ages 47." }, { key: "item_quantity_kids_12", req: "optional", type: "quantity", desc: "Kids 10-12. FREE — stored but NOT counted toward the scannable ticket total." },
{ key: "ages_8_12", req: "optional", type: "number", desc: "Headcount ages 812." }, { key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. FREE — stored but NOT counted toward the scannable ticket total." },
{ key: "ages_13_17", req: "optional", type: "number", desc: "Headcount ages 1317." }, { key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE — stored but NOT counted toward the scannable ticket total." },
{ key: "ages_18_25", req: "optional", type: "number", desc: "Headcount ages 1825." }, { key: "donor_tier", req: "optional", type: "hidden", desc: "member / donor / empty (from the donor-eligibility lookup)." },
{ key: "ages_26_45", req: "optional", type: "number", desc: "Headcount ages 2645." }, { key: "donor_eligible", req: "optional", type: "hidden", desc: "true / false (from the donor-eligibility lookup)." },
{ key: "ages_46_64", req: "optional", type: "number", desc: "Headcount ages 4664." }, { 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: "ages_65", req: "optional", type: "number", desc: "Headcount ages 65+." }, { key: "input_radio", req: "optional", type: "choice", desc: "'Are you a campground donor?' — also used as a donor signal." },
{ key: "ice_bags", req: "optional", type: "number", desc: "Prepaid ice bags. If omitted and ice_access is truthy, defaults to the configured amount (3)." }, { key: "payment_parking_reg / _donor", req: "optional", type: "payment", desc: "Car parking. Flagged if either variant is selected." },
{ key: "ice_access", req: "optional", type: "yes/no", desc: "Whether they bought ice access. Accepts 1/0, true/false, yes/no." }, { key: "payment_rv_reg / _donor", req: "optional", type: "payment", desc: "RV. Flagged if either variant is selected." },
{ key: "car_parking", req: "optional", type: "yes/no", desc: "Car parking pass." }, { key: "payment_utv_reg / _donor", req: "optional", type: "payment", desc: "ATV/UTV. Flagged if either variant is selected." },
{ key: "rv_parking", req: "optional", type: "yes/no", desc: "RV parking pass." }, { key: "payment_ice", req: "optional", type: "payment", desc: "Ice tickets (1-4 at $20 each). One ice ticket = 3 bags; stored as bags = tickets × 3. Accepts a ticket count (1-4) or a dollar total ($20-$80)." },
{ key: "is_donor", req: "optional", type: "yes/no", desc: "Donor flag." }, { key: "payment_method", req: "optional", type: "payment", desc: "Payment method label." },
{ key: "address", req: "optional", type: "text", desc: "Mailing address." }, { key: "id / submission_id", req: "optional", type: "text", desc: "Entry/submission id for idempotency (retries won't duplicate). Falls back to a content hash." },
{ key: "payment_method", req: "optional", type: "text", desc: "Payment method label." },
]; ];
function esc(s: string): string { function esc(s: string): string {
@ -48,28 +47,35 @@ export async function webhookDocRoutes(app: FastifyInstance): Promise<void> {
const rows = FIELDS.map( const rows = FIELDS.map(
(f) => `<tr> (f) => `<tr>
<td><code>${f.key}</code></td> <td><code>${esc(f.key)}</code></td>
<td class="${f.req === "required" ? "req" : "opt"}">${f.req}</td> <td class="${f.req === "required" ? "req" : "opt"}">${f.req}</td>
<td>${f.type}</td> <td>${esc(f.type)}</td>
<td>${esc(f.desc)}</td> <td>${esc(f.desc)}</td>
</tr>`, </tr>`,
).join(""); ).join("");
const exampleJson = esc(`{ const exampleJson = esc(`{
"name": "Jane Bear", "id": "412",
"customer_name": { "first_name": "Jane", "last_name": "Bear" },
"names": { "first_name": "Jane", "last_name": "Bear" },
"names_1": { "first_name": "John", "last_name": "Bear" },
"email": "jane@example.com", "email": "jane@example.com",
"submission_id": "12345", "item_quantity_adult_ticket_reg": 2,
"ages_0_3": 2, "item_quantity_adult_ticket_donor": 0,
"ages_8_12": 3, "item_quantity_youth_ticket_reg": 1,
"ages_26_45": 2, "item_quantity_kids_9": 2,
"car_parking": "yes", "item_quantity_kids_4": 2,
"ice_access": "yes" "donor_tier": "member",
"vouchers": 2,
"payment_parking_reg": "$40.00",
"payment_ice": 2,
"payment_method": "stripe"
}`); }`);
const exampleCurl = esc(`curl -X POST ${WEBHOOK_URL} \\ const exampleCurl = esc(`curl -X POST ${WEBHOOK_URL} \\
-H "Content-Type: application/json" \\ -H "Content-Type: application/json" \\
-H "X-Webhook-Secret: <your WEBHOOK_SECRET>" \\ -H "X-Webhook-Secret: <your WEBHOOK_SECRET>" \\
-d '{"name":"Jane Bear","email":"jane@example.com","submission_id":"12345","ages_26_45":2,"ice_access":"yes"}'`); -d @submission.json`);
const PAGE = `<!doctype html> const PAGE = `<!doctype html>
<html lang="en"> <html lang="en">
@ -82,7 +88,7 @@ const PAGE = `<!doctype html>
: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; line-height: 1.55; } body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.55; }
.wrap { max-width: 820px; margin: 0 auto; padding: 28px 20px 64px; } .wrap { max-width: 900px; margin: 0 auto; padding: 28px 20px 64px; }
h1 { font-size: 26px; margin: 0 0 4px; } h1 { font-size: 26px; margin: 0 0 4px; }
h2 { font-size: 20px; margin: 32px 0 10px; border-bottom: 1px solid #24382a; padding-bottom: 6px; } h2 { font-size: 20px; margin: 32px 0 10px; border-bottom: 1px solid #24382a; padding-bottom: 6px; }
.sub { color: #9db3a4; margin: 0 0 8px; } .sub { color: #9db3a4; margin: 0 0 8px; }
@ -104,31 +110,33 @@ const PAGE = `<!doctype html>
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
<h1>🐻 Camp Scan Purchase Webhook</h1> <h1>🐻 Camp Scan Purchase Webhook (Tickets 2026)</h1>
<p class="sub">How the FluentForms ticket checkout notifies the ticketing backend to create a ticket and email the QR code.</p> <p class="sub">How the FluentForms "Tickets 2026" checkout notifies the ticketing backend to create a ticket and email the QR code.</p>
<div class="kv"> <div class="kv">
<div><b>Endpoint</b>&nbsp; <span class="pill">POST</span> <code>${WEBHOOK_URL}</code></div> <div><b>Endpoint</b>&nbsp; <span class="pill">POST</span> <code>${WEBHOOK_URL}</code></div>
<div><b>Auth header</b>&nbsp; <code>X-Webhook-Secret: &lt;the shared WEBHOOK_SECRET&gt;</code></div> <div><b>Auth header</b>&nbsp; <code>X-Webhook-Secret: &lt;the shared WEBHOOK_SECRET&gt;</code></div>
<div><b>Body format</b>&nbsp; JSON (<code>application/json</code>) or form-encoded both accepted.</div> <div><b>Body format</b>&nbsp; JSON (<code>application/json</code>) or form-encoded both accepted. Send all form fields.</div>
</div> </div>
<h2>What it does</h2> <h2>What it does</h2>
<p>On a valid request the backend generates a unique ticket code, creates a row in the "2026 Campground Tickets" NocoDB table, renders a QR code, and emails it to the purchaser (subject: <b>"2026 Beartaria Campgrounds Tickets"</b>). The total number of redeemable tickets is the <b>sum of the age-bracket counts, excluding ages 03</b> (who are free).</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). <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">At least one non-zero age-bracket count is required (otherwise there are no tickets to issue). Booleans accept <code>1/0</code>, <code>true/false</code>, or <code>yes/no</code>.</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>submission_id</code>. If the backend sees the same one again it returns <code>{"status":"duplicate"}</code> without creating a second ticket or re-sending email so FluentForms retries and accidental double-submits are safe.</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 redeemable tickets (3×812 + 2×2645; the two 03 are free), with car parking and 3 ice bags.</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>
@ -139,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 name/email, or no age counts.</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>
@ -148,13 +157,27 @@ const PAGE = `<!doctype html>
<h2>FluentForms setup</h2> <h2>FluentForms setup</h2>
<ol> <ol>
<li>On the ticket form: <b>Settings &amp; Integrations Webhook Add Webhook</b>.</li> <li>On the ticket form: <b>Settings &amp; Integrations Webhook Add Webhook</b>.</li>
<li><b>Request URL:</b> <code>${WEBHOOK_URL}</code></li> <li><b>Request URL:</b> <code>${WEBHOOK_URL}</code>&nbsp; · &nbsp;<b>Method:</b> <code>POST</code>&nbsp; · &nbsp;<b>Format:</b> <code>JSON</code></li>
<li><b>Request Method:</b> <code>POST</code>&nbsp; · &nbsp;<b>Format:</b> <code>JSON</code></li>
<li><b>Request Headers:</b> add <code>X-Webhook-Secret</code> = the shared secret.</li> <li><b>Request Headers:</b> add <code>X-Webhook-Secret</code> = the shared secret.</li>
<li><b>Request Body:</b> map each form field to the keys in the table above.</li> <li><b>Request Body:</b> send <b>all fields</b> (the field names above are the FluentForms field keys).</li>
<li>Save, then 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,11 +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 { adminRoutes } from "./routes/admin.js";
export async function build() { export async function build() {
const config = loadConfig(); const config = loadConfig();
@ -31,11 +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(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;
@ -156,6 +168,87 @@ export class DonorService {
source: "transactions", source: "transactions",
}; };
} }
/**
* Admin-only free-text donor search across the master list + transaction
* tables. Matches the query (substring, case-insensitive) against any
* name / email / phone / address / bear-name column each table exposes
* columns are discovered from a sample row so it adapts to the schema.
* Results are de-duped by email (then name). PRIVACY: gate this to admins.
*/
async search(rawQuery: string, limit = 40): Promise<DonorSearchResult[]> {
const q = rawQuery.trim();
if (!q || !this.enabled) return [];
const tables: { id: string | null; source: "master" | "transactions" }[] = [
{ id: this.masterId, source: "master" },
{ id: this.onlineId, source: "transactions" },
{ id: this.offlineId, source: "transactions" },
];
const out = new Map<string, DonorSearchResult>();
for (const t of tables) {
if (!t.id || out.size >= limit) continue;
let rows: any[];
try {
rows = await this.searchTable(t.id, q, limit);
} catch {
continue; // a table without matching columns / transient error — skip
}
for (const r of rows) {
const res = mapDonorRow(r, t.source);
const key = (res.email || res.name || JSON.stringify(r)).toLowerCase();
const existing = out.get(key);
// Prefer the master-list record (richer) when the same donor appears twice.
if (!existing || (existing.source === "transactions" && res.source === "master")) {
out.set(key, existing ? { ...res, lifetime: res.lifetime ?? existing.lifetime } : res);
}
if (out.size >= limit) break;
}
}
return [...out.values()].slice(0, limit);
}
private colCache = new Map<string, string[]>();
/** Discover the text columns worth searching (name/contact) from a sample row. */
private async searchableColumns(tableId: string): Promise<string[]> {
const cached = this.colCache.get(tableId);
if (cached) return cached;
const sample = await this.list(tableId, "", 1);
const keys = sample.length ? Object.keys(sample[0]) : [];
const want = /name|email|phone|mobile|cell|address|street|city|state|zip|postal|province|country|bear/i;
const skip = /[(),]/; // field names with filter-grammar chars can't be queried
const cols = keys.filter((k) => want.test(k) && !skip.test(k));
this.colCache.set(tableId, cols);
return cols;
}
private async searchTable(tableId: string, q: string, limit: number): Promise<any[]> {
const cols = await this.searchableColumns(tableId);
if (!cols.length) return [];
const esc = q.replace(/[(),]/g, " ");
const where = cols.map((c) => `(${c},like,%${esc}%)`).join("~or");
return this.list(tableId, where, limit);
}
/**
* Total Paid donations for an email on/after `cutoff`, summed from the
* transaction tables (the only dated source). Used for ticket-voucher
* entitlement. found=false if donor tables aren't configured or there are no
* transactions for the email at all.
*/
async amountSince(rawEmail: string, cutoff: Date): Promise<{ found: boolean; amount: number }> {
const email = rawEmail.trim();
if (!email || !this.onlineId || !this.offlineId) return { found: false, amount: 0 };
const esc = email.replace(/[(),]/g, " ");
const [online, offline] = await Promise.all([
this.list(this.onlineId, `(Email,eq,${esc})`, 1000),
this.list(this.offlineId, `(Email,eq,${esc})`, 1000),
]);
const amount =
sumSince(online, "Donation Amount", "Donation Date", cutoff) +
sumSince(offline, "Donation Amount", "Donation Date", cutoff);
return { found: online.length + offline.length > 0, amount };
}
} }
function num(v: unknown): number { function num(v: unknown): number {
@ -163,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;
@ -86,14 +97,13 @@ export function fakeContext(db: FakeNocoDB): AppContext {
export async function seedTicket( export async function seedTicket(
db: FakeNocoDB, db: FakeNocoDB,
opts: { code: string; name?: string; email?: string; ages?: Record<string, number>; redeemed?: number }, opts: { code: string; name?: string; email?: string; adults?: number; redeemed?: number },
): Promise<NocoRecord> { ): Promise<NocoRecord> {
const ages = opts.ages ?? { "Ages 18-25": 2, "Ages 26-45": 3, "Ages 0-3": 1 };
return db.create({ return db.create({
[COL.code]: opts.code, [COL.code]: opts.code,
[COL.name]: opts.name ?? "Test Bear", [COL.name]: opts.name ?? "Test Bear",
[COL.email]: opts.email ?? "test@example.com", [COL.email]: opts.email ?? "test@example.com",
[COL.adults]: opts.adults ?? 5,
[COL.redeemed]: opts.redeemed ?? 0, [COL.redeemed]: opts.redeemed ?? 0,
...ages,
}); });
} }

View file

@ -2,45 +2,52 @@ 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 age brackets but excludes Ages 0-3 (free)", () => { it("sums adults + youth 13-16 only; all kids 12 & under are free", () => {
const rec = { const rec = {
Id: 1, Id: 1,
"Ages 0-3": 2, // free, not counted [COL.adults]: 2,
"Ages 4-7": 1, [COL.youth]: 1,
"Ages 18-25": 2, [COL.kids12]: 1, // free, not counted
"Ages 26-45": 1, [COL.kids9]: 1, // free, not counted
[COL.kids4]: 3, // free, not counted
}; };
expect(computeTotal(rec)).toBe(4); expect(computeTotal(rec)).toBe(3);
}); });
it("coerces string counts and treats blanks as 0", () => { it("coerces string counts and treats blanks as 0", () => {
const rec = { Id: 1, "Ages 18-25": "3", "Ages 26-45": "" } as any; const rec = { Id: 1, [COL.adults]: "3", [COL.youth]: "" } as any;
expect(computeTotal(rec)).toBe(3); expect(computeTotal(rec)).toBe(3);
}); });
}); });
describe("toView", () => { describe("toView", () => {
it("derives remaining and surfaces extras", () => { it("derives remaining and surfaces adult names, donor tier, and extras", () => {
const rec = { const rec = {
Id: 7, Id: 7,
[COL.code]: "BC26-ABCD-2345", [COL.code]: "BC26-ABCD-2345",
[COL.name]: "Jane Bear", [COL.name]: "Jane Bear",
[COL.email]: "jane@example.com", [COL.email]: "jane@example.com",
[COL.adultNames]: "Jane Bear\nJohn Bear",
[COL.redeemed]: 2, [COL.redeemed]: 2,
[COL.adults]: 2,
[COL.youth]: 3,
[COL.kids4]: 1,
[COL.carParking]: true, [COL.carParking]: true,
[COL.iceAccess]: "yes", [COL.iceAccess]: "yes",
"Ages 0-3": 1, [COL.donorTier]: "member",
"Ages 18-25": 2, [COL.vouchers]: 2,
"Ages 26-45": 3,
}; };
const v = toView(rec); const v = toView(rec);
expect(v.total).toBe(5); expect(v.total).toBe(5);
expect(v.redeemed).toBe(2); expect(v.redeemed).toBe(2);
expect(v.remaining).toBe(3); expect(v.remaining).toBe(3);
expect(v.adultNames).toEqual(["Jane Bear", "John Bear"]);
expect(v.extras.carParking).toBe(true); expect(v.extras.carParking).toBe(true);
expect(v.extras.iceAccess).toBe(true); expect(v.extras.iceAccess).toBe(true);
expect(v.extras.rvParking).toBe(false); expect(v.extras.rvParking).toBe(false);
expect(v.extras.freeUnder4).toBe(1); expect(v.extras.donorTier).toBe("member");
expect(v.ages.find((a) => a.bracket === "0-3")?.free).toBe(true); expect(v.extras.vouchers).toBe(2);
expect(v.extras.freeKids).toBe(1);
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

@ -6,7 +6,7 @@ import { COL } from "../fields.js";
describe("redeem", () => { describe("redeem", () => {
it("checks in a single walk-up (default count 1)", async () => { it("checks in a single walk-up (default count 1)", async () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-AAAA-1111", ages: { "Ages 26-45": 4 } }); await seedTicket(db, { code: "BC26-AAAA-1111", adults: 4 });
const ctx = fakeContext(db); const ctx = fakeContext(db);
const r = await redeem(ctx, "BC26-AAAA-1111", 1); const r = await redeem(ctx, "BC26-AAAA-1111", 1);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
@ -20,7 +20,7 @@ describe("redeem", () => {
it("supports group check-in and QR reuse across visits", async () => { it("supports group check-in and QR reuse across visits", async () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
// Party of 7 (2 free under-4 not counted): total 5. // Party of 7 (2 free under-4 not counted): total 5.
await seedTicket(db, { code: "BC26-FAM-0001", ages: { "Ages 0-3": 2, "Ages 26-45": 2, "Ages 8-12": 3 } }); await seedTicket(db, { code: "BC26-FAM-0001", adults: 5 });
const ctx = fakeContext(db); const ctx = fakeContext(db);
const first = await redeem(ctx, "BC26-FAM-0001", 2); // father + son const first = await redeem(ctx, "BC26-FAM-0001", 2); // father + son
@ -36,7 +36,7 @@ describe("redeem", () => {
it("rejects over-redemption without mutating", async () => { it("rejects over-redemption without mutating", async () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-BBBB-2222", ages: { "Ages 26-45": 2 } }); await seedTicket(db, { code: "BC26-BBBB-2222", adults: 2 });
const ctx = fakeContext(db); const ctx = fakeContext(db);
const r = await redeem(ctx, "BC26-BBBB-2222", 5); const r = await redeem(ctx, "BC26-BBBB-2222", 5);
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
@ -46,7 +46,7 @@ describe("redeem", () => {
it("allows negative count to undo, clamped at zero", async () => { it("allows negative count to undo, clamped at zero", async () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-CCCC-3333", ages: { "Ages 26-45": 3 }, redeemed: 2 }); await seedTicket(db, { code: "BC26-CCCC-3333", adults: 3, redeemed: 2 });
const ctx = fakeContext(db); const ctx = fakeContext(db);
const r = await redeem(ctx, "BC26-CCCC-3333", -5); const r = await redeem(ctx, "BC26-CCCC-3333", -5);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
@ -55,7 +55,7 @@ describe("redeem", () => {
it("writes an audit entry on each successful check-in and undo", async () => { it("writes an audit entry on each successful check-in and undo", async () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-AUDT-0001", ages: { "Ages 26-45": 4 } }); await seedTicket(db, { code: "BC26-AUDT-0001", adults: 4 });
const ctx = fakeContext(db); const ctx = fakeContext(db);
await redeem(ctx, "BC26-AUDT-0001", 2); await redeem(ctx, "BC26-AUDT-0001", 2);
await redeem(ctx, "BC26-AUDT-0001", -1); await redeem(ctx, "BC26-AUDT-0001", -1);
@ -67,7 +67,7 @@ describe("redeem", () => {
it("does not audit a no-op (undo when nothing redeemed)", async () => { it("does not audit a no-op (undo when nothing redeemed)", async () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-AUDT-0002", ages: { "Ages 26-45": 3 }, redeemed: 0 }); await seedTicket(db, { code: "BC26-AUDT-0002", adults: 3, redeemed: 0 });
const ctx = fakeContext(db); const ctx = fakeContext(db);
await redeem(ctx, "BC26-AUDT-0002", -2); // clamps to 0, delta 0 await redeem(ctx, "BC26-AUDT-0002", -2); // clamps to 0, delta 0
expect((ctx.audit as any).entries).toHaveLength(0); expect((ctx.audit as any).entries).toHaveLength(0);
@ -77,7 +77,7 @@ describe("redeem", () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
await seedTicket(db, { await seedTicket(db, {
code: "BC26-ICE-0003", code: "BC26-ICE-0003",
ages: { "Ages 26-45": 2 }, adults: 2,
}); });
// Give the ticket 3 prepaid ice bags. // Give the ticket 3 prepaid ice bags.
db.rows[0]["Ice Total"] = 3; db.rows[0]["Ice Total"] = 3;
@ -112,7 +112,7 @@ describe("redeem", () => {
it("surfaces db_error when the update fails", async () => { it("surfaces db_error when the update fails", async () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-DDDD-4444", ages: { "Ages 26-45": 3 } }); await seedTicket(db, { code: "BC26-DDDD-4444", adults: 3 });
db.failNext = true; db.failNext = true;
const ctx = fakeContext(db); const ctx = fakeContext(db);
const r = await redeem(ctx, "BC26-DDDD-4444", 1); const r = await redeem(ctx, "BC26-DDDD-4444", 1);
@ -122,7 +122,7 @@ describe("redeem", () => {
it("CONCURRENCY: 20 parallel single check-ins on a 5-ticket code yield exactly 5", async () => { it("CONCURRENCY: 20 parallel single check-ins on a 5-ticket code yield exactly 5", async () => {
const db = new FakeNocoDB(8); const db = new FakeNocoDB(8);
await seedTicket(db, { code: "BC26-RACE-0005", ages: { "Ages 26-45": 5 } }); await seedTicket(db, { code: "BC26-RACE-0005", adults: 5 });
const ctx = fakeContext(db); const ctx = fakeContext(db);
const results = await Promise.all( const results = await Promise.all(
@ -137,7 +137,7 @@ describe("redeem", () => {
describe("lookupByCode", () => { describe("lookupByCode", () => {
it("returns the ticket view without mutating", async () => { it("returns the ticket view without mutating", async () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-LOOK-0001", ages: { "Ages 26-45": 3 } }); await seedTicket(db, { code: "BC26-LOOK-0001", adults: 3 });
const ctx = fakeContext(db); const ctx = fakeContext(db);
const r = await lookupByCode(ctx, "BC26-LOOK-0001"); const r = await lookupByCode(ctx, "BC26-LOOK-0001");
expect(r.ok && r.found && r.ticket.remaining).toBe(3); expect(r.ok && r.found && r.ticket.remaining).toBe(3);
@ -159,7 +159,7 @@ describe("createTicket idempotency", () => {
const input = { const input = {
name: "Jane Bear", name: "Jane Bear",
email: "jane@example.com", email: "jane@example.com",
ages: { "Ages 26-45": 2 }, counts: { adults: 2, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
submissionKey: "sub:412", submissionKey: "sub:412",
}; };
const a = await createTicket(ctx, input); const a = await createTicket(ctx, input);

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

@ -128,15 +128,21 @@ export async function search(ctx: AppContext, query: string): Promise<TicketView
export interface WebhookInput { export interface WebhookInput {
name: string; name: string;
adultNames?: string[];
email: string; email: string;
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;
vouchers?: number;
counts: { adults: number; youth: number; kids12: number; kids9: number; kids4: number };
carParking?: boolean; carParking?: boolean;
rvParking?: boolean; rvParking?: boolean;
utv?: boolean;
iceAccess?: boolean; iceAccess?: boolean;
iceBags?: number; // prepaid ice bags iceBags?: number; // prepaid ice bags/tickets
paymentMethod?: string; paymentMethod?: string;
ages: Record<string, number>; // NocoDB age-column title -> count
submissionKey: string; submissionKey: string;
} }
@ -158,20 +164,31 @@ export async function createTicket(
code = generateCode(); code = generateCode();
} }
const c = input.counts;
const fields: Record<string, unknown> = { const fields: Record<string, unknown> = {
[COL.name]: input.name, [COL.name]: input.name,
[COL.email]: input.email, [COL.email]: input.email,
[COL.code]: code, [COL.code]: code,
[COL.redeemed]: 0, [COL.redeemed]: 0,
[COL.adults]: c.adults,
[COL.youth]: c.youth,
[COL.kids12]: c.kids12,
[COL.kids9]: c.kids9,
[COL.kids4]: c.kids4,
[COL.iceTotal]: input.iceBags ?? 0, [COL.iceTotal]: input.iceBags ?? 0,
[COL.iceRedeemed]: 0, [COL.iceRedeemed]: 0,
[COL.submissionKey]: input.submissionKey, [COL.submissionKey]: input.submissionKey,
...input.ages,
}; };
if (input.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n");
if (input.ticketType) fields[COL.ticketType] = input.ticketType;
if (input.createdBy) fields[COL.createdBy] = input.createdBy;
if (input.address !== undefined) fields[COL.address] = input.address; if (input.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.vouchers !== undefined) fields[COL.vouchers] = input.vouchers;
if (input.carParking !== undefined) fields[COL.carParking] = input.carParking; if (input.carParking !== undefined) fields[COL.carParking] = input.carParking;
if (input.rvParking !== undefined) fields[COL.rvParking] = input.rvParking; if (input.rvParking !== undefined) fields[COL.rvParking] = input.rvParking;
if (input.utv !== undefined) fields[COL.utv] = input.utv;
if (input.iceAccess !== undefined) fields[COL.iceAccess] = input.iceAccess; if (input.iceAccess !== undefined) fields[COL.iceAccess] = input.iceAccess;
if (input.paymentMethod !== undefined) fields[COL.paymentMethod] = input.paymentMethod; if (input.paymentMethod !== undefined) fields[COL.paymentMethod] = input.paymentMethod;

View file

@ -3,7 +3,9 @@ services:
build: . build: .
image: camptickets:latest image: camptickets:latest
container_name: camptickets container_name: camptickets
env_file: .env # Single source of truth for secrets — edit backend/.env, then
# `docker compose up -d`. (Was ./.env; consolidated to avoid a stale copy.)
env_file: backend/.env
environment: environment:
# Container always listens on 8080 internally; the host mapping below is # Container always listens on 8080 internally; the host mapping below is
# what nginx proxies to. Keep this fixed regardless of .env PORT. # what nginx proxies to. Keep this fixed regardless of .env PORT.
@ -17,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

@ -1,8 +1,26 @@
# FluentForms → donor discount lookup # FluentForms → donor lookup APIs
FluentForms has no native way to query an external database from a field. This FluentForms has no native way to query an external database from a field. These
wires it up with a small Custom JS block that calls our secret-gated endpoint wire it up with a small Custom JS block that calls a secret-gated endpoint on
and unlocks a discount when the entered email belongs to a donor/member. the ticketing backend. Two endpoints are available (same key, CORS, and rate
limit):
| Endpoint | Purpose |
|---|---|
| `GET /api/public/donor-eligibility` | Is this email a donor/member? → unlock a discount |
| `GET /api/public/ticket-vouchers` | How many free tickets has this donor earned? → 0 / 1 / 2 |
Both require `?key=<PUBLIC_LOOKUP_SECRET>`, are rate-limited (30/min/IP), and
CORS-restricted to `PUBLIC_LOOKUP_ORIGIN` (default
`https://tickets.beartariacampgrounds.com`). Neither returns names or dollar
amounts. The secret is visible in page source, so treat it as deterrence, not
security; rotate it by changing `PUBLIC_LOOKUP_SECRET` and redeploying.
---
## Donor discount lookup
Unlocks a discount when the entered email belongs to a donor/member.
## Endpoint ## Endpoint
@ -124,3 +142,11 @@ curl "https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=<SE
If member and donor get the **same** discounted price, simplify: set the donor If member and donor get the **same** discounted price, simplify: set the donor
price to show when `donor_tier` **is not** `regular`, and you can ignore the price to show when `donor_tier` **is not** `regular`, and you can ignore the
member/donor distinction. member/donor distinction.
---
## Ticket-voucher entitlement
The donor **ticket-voucher lookup** (how many free tickets a donor earned) is a
separate endpoint documented on its own page:
[`fluentforms-ticket-vouchers.md`](./fluentforms-ticket-vouchers.md).

View file

@ -0,0 +1,131 @@
# FluentForms → ticket-voucher lookup
Look up, by email, how many **free tickets** a donor has earned from their
giving. Intended for the ticket-rewards / checkout form: enter an email, call
this endpoint, and show / apply the earned vouchers.
FluentForms can't query an external database from a field natively, so this is
done with a small Custom JS block that calls a secret-gated endpoint on the
ticketing backend.
## Endpoint
```
GET https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<email>
```
Returns the **remaining** free-ticket count — never names or dollar amounts:
```json
{ "vouchers": 1, "entitled": 2, "used": 1, "remaining": 1 }
```
- `vouchers` / `remaining` — how many free tickets are **still available** (this
is what the form should grant). Use `vouchers`; `remaining` is an alias.
- `entitled` — the tier entitlement earned from giving (0/1/2).
- `used` — vouchers already consumed by this donor's prior ticket orders.
- `key` = the value of `PUBLIC_LOOKUP_SECRET` (set in the backend `.env`).
- `email` = the donor's email (URL-encoded).
- Rate-limited (30 requests / minute / IP) and CORS-restricted to
`PUBLIC_LOOKUP_ORIGIN` (`tickets.` + `vendors.beartariacampgrounds.com`).
### Vouchers decrement as they're used
`remaining = entitled used`, where `used` is the sum of the **Vouchers**
column across every ticket order placed with that email. Each checkout stores
the vouchers it applied, so the next lookup returns fewer — a donor can't keep
claiming free tickets by re-submitting the form. Once `used ≥ entitled`,
`vouchers` is `0`.
**To reset for testing:** in NocoDB, zero out (or delete) the **Vouchers**
value on that donor's ticket order row(s). `used` drops and `remaining` rises on
the next lookup — no redeploy needed.
> The secret is visible in page source, so treat it as **deterrence, not
> security** — it only gates a 0/1/2 count. Rotate it by changing
> `PUBLIC_LOOKUP_SECRET` and redeploying.
## Rules
Donations are summed for the email across the online + offline transaction
tables, counting only **Paid** rows dated **on/after `VOUCHER_SINCE`**:
| Total since the cutoff | Vouchers |
|---|---|
| ≥ $1000 | 2 |
| ≥ $400 | 1 |
| otherwise | 0 |
Configurable in the backend `.env`:
| Var | Default | Meaning |
|---|---|---|
| `VOUCHER_SINCE` | `2025-09-04` | Only donations on/after this date count. Bump each year. |
| `VOUCHER_TIER1_MIN` | `400` | Dollar total for 1 voucher |
| `VOUCHER_TIER2_MIN` | `1000` | Dollar total for 2 vouchers |
The count comes from the **dated transaction tables** (the donor master-list
rollups have no dates), so donations must exist in those tables for the window.
## Form snippet
Add a **Custom HTML** element to the form and paste this, setting `KEY` to your
`PUBLIC_LOOKUP_SECRET` (and `EMAIL_SELECTOR` if the email field isn't named
`email`). It shows the earned count on email blur and writes it into a hidden
field `free_tickets` you can use for conditional logic or to cap a quantity.
```html
<div id="voucher-status" style="margin:6px 0;font-size:14px;font-weight:600;"></div>
<script>
(function () {
var API = "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers";
var KEY = "REPLACE_WITH_PUBLIC_LOOKUP_SECRET";
var EMAIL_SELECTOR = 'input[name="email"]';
var statusEl = document.getElementById("voucher-status");
var last = "";
function show(n) {
statusEl.textContent = n > 0
? "🎟️ You've earned " + n + " free ticket" + (n > 1 ? "s" : "") + "!"
: "";
statusEl.style.color = "#1b7f3b";
// Optional: write the count into a hidden field named "free_tickets".
// Uses the native setter so FluentForms' Vue model registers the change.
var el = document.querySelector('input[name="free_tickets"]');
if (el) {
var d = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), "value");
(d && d.set ? d.set : function (v) { el.value = v; }).call(el, String(n));
el.dispatchEvent(new Event("input", { bubbles: true }));
el.dispatchEvent(new Event("change", { bubbles: true }));
}
}
function check(email) {
if (!email || email === last) return;
last = email;
fetch(API + "?key=" + encodeURIComponent(KEY) + "&email=" + encodeURIComponent(email))
.then(function (r) { return r.json(); })
.then(function (d) { show(d && d.vouchers ? d.vouchers : 0); })
.catch(function () { show(0); });
}
function bind() {
var el = document.querySelector(EMAIL_SELECTOR);
if (!el) { return setTimeout(bind, 500); } // form may render late
el.addEventListener("blur", function () { check(el.value.trim().toLowerCase()); });
}
bind();
})();
</script>
```
## Test
```
curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<a-real-donor-email>"
# entitled 2, none used yet -> {"vouchers":2,"entitled":2,"used":0,"remaining":2}
# after a checkout using 2 -> {"vouchers":0,"entitled":2,"used":2,"remaining":0}
```
Related: [`fluentforms-donor-discount.md`](./fluentforms-donor-discount.md) — the
companion donor-discount eligibility lookup (same key / CORS / rate limit).

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