From 3583c2e15fe0a71d57c36fe50a67267719cc2664 Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 8 Jul 2026 16:26:06 +0000 Subject: [PATCH 01/37] CI: cache Android SDK/NDK, Gradle, and npm across APK builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Persist three Docker volumes into the build job (android-sdk, gradle home, npm cache) and enable the Gradle build cache, so repeat builds skip the ~2GB NDK download, Maven dependency resolution, and unchanged native/Kotlin compilation — cutting builds from ~1h to a few minutes after the first run. The runner must whitelist these volumes via config.yaml (container.valid_volumes); deploy-runner.sh now writes that config, pre-creates the volumes, and starts the daemon with --config. Requires re-running deploy-runner.sh on the runner host before the next tag. Co-Authored-By: Claude Fable 5 --- .forgejo/workflows/build-apk.yml | 42 ++++++++++++++++++++++++-------- runner/README.md | 24 ++++++++++++++++++ runner/config.yaml | 9 +++++++ runner/docker-compose.yml | 5 +++- scripts/deploy-runner.sh | 21 +++++++++++++++- 5 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 runner/config.yaml diff --git a/.forgejo/workflows/build-apk.yml b/.forgejo/workflows/build-apk.yml index 5879490..c51c8d6 100644 --- a/.forgejo/workflows/build-apk.yml +++ b/.forgejo/workflows/build-apk.yml @@ -11,14 +11,27 @@ jobs: runs-on: docker container: image: node:22-bookworm + # Persistent caches across runs (Docker named volumes). These must be + # allowed in the runner's config.yaml `container.valid_volumes` — the + # deploy-runner.sh script sets that up. First run populates them (~1h); + # later runs reuse the SDK/NDK, Gradle deps + build cache, and npm cache, + # dropping the build to a few minutes. + volumes: + - camptickets-android-sdk:/opt/android-sdk + - camptickets-gradle:/root/.gradle + - camptickets-npm:/root/.npm env: ANDROID_HOME: /opt/android-sdk ANDROID_SDK_ROOT: /opt/android-sdk + GRADLE_USER_HOME: /root/.gradle # Force IPv4 for all JVMs (Gradle launcher, daemon, Kotlin/CMake workers). # The runner host has no working IPv6 route, so Maven Central (which has # AAAA records) was unreachable — this makes Java ignore AAAA and use IPv4. JAVA_TOOL_OPTIONS: -Djava.net.preferIPv4Stack=true - GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false + # org.gradle.caching enables the local build cache (persisted in + # GRADLE_USER_HOME), so unchanged native/Kotlin tasks are restored instead + # of recompiled. + GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false -Dorg.gradle.caching=true steps: - name: Checkout uses: actions/checkout@v4 @@ -36,16 +49,25 @@ jobs: apt-get install -y --no-install-recommends openjdk-17-jdk-headless unzip wget git echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV" - - name: Install Android SDK + - name: Install Android SDK (cached) run: | - set -eux - mkdir -p "$ANDROID_HOME/cmdline-tools" - cd /tmp - wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdtools.zip - unzip -q cmdtools.zip -d "$ANDROID_HOME/cmdline-tools" - mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" + set -eu + # Skip the whole install when the cache volume already has the SDK. + # (NDK + CMake are auto-installed by Gradle into the same volume on + # the first build, so they persist too.) + if [ ! -x "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" ]; then + echo "Installing Android command-line tools..." + mkdir -p "$ANDROID_HOME/cmdline-tools" + cd /tmp + wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -O cmdtools.zip + unzip -q cmdtools.zip -d "$ANDROID_HOME/cmdline-tools" + mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" + else + echo "Android SDK found in cache volume — skipping download." + fi export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" - yes | sdkmanager --licenses >/dev/null || true + yes | sdkmanager --licenses >/dev/null 2>&1 || true + # sdkmanager is a no-op for packages already present in the volume. sdkmanager --install "platform-tools" \ "platforms;android-36" "platforms;android-35" \ "build-tools;36.0.0" "build-tools;35.0.0" >/dev/null @@ -73,7 +95,7 @@ jobs: CAMPSCAN_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | chmod +x ./gradlew - ./gradlew assembleRelease --init-script ../../ci/signing.gradle --no-daemon + ./gradlew assembleRelease --init-script ../../ci/signing.gradle --no-daemon --build-cache mkdir -p "$GITHUB_WORKSPACE/artifacts" cp app/build/outputs/apk/release/app-release.apk \ "$GITHUB_WORKSPACE/artifacts/camp-scan-${{ steps.ver.outputs.tag }}.apk" diff --git a/runner/README.md b/runner/README.md index 5113651..0420be2 100644 --- a/runner/README.md +++ b/runner/README.md @@ -30,6 +30,27 @@ The runner advertises the `docker` label; the build workflow (`.forgejo/workflow 2. Push a tag: `git tag v0.1.0 && git push origin v0.1.0`. 3. The runner builds `camp-scan-v0.1.0.apk` and attaches it to a Forgejo release; Obtainium picks it up. +## Build caching (fast repeat builds) + +The first APK build takes ~1h (downloads Gradle + the ~2GB Android NDK, resolves +all Maven deps, compiles native modules). After that it should drop to a few +minutes because three persistent Docker volumes are reused across runs: + +| Volume | Holds | +|---|---| +| `camptickets-android-sdk` → `/opt/android-sdk` | SDK, NDK, CMake | +| `camptickets-gradle` → `/root/.gradle` | Gradle dist, Maven deps, local build cache | +| `camptickets-npm` → `/root/.npm` | npm download cache | + +For this to work the runner must **allow** these volumes via `config.yaml` +(`container.valid_volumes`) — `deploy-runner.sh` writes that config, pre-creates +the volumes, and starts the daemon with `--config /data/config.yaml`. If you set +the runner up by hand, copy `config.yaml` next to the compose file and add +`--config /data/config.yaml` to the daemon command. + +To force a clean rebuild, remove the volumes: +`docker volume rm camptickets-android-sdk camptickets-gradle camptickets-npm`. + ## Notes - The runner I initially registered on the app host has been removed. If Forgejo @@ -37,3 +58,6 @@ The runner advertises the `docker` label; the build workflow (`.forgejo/workflow *Settings → Actions → Runners*. - `DOCKER_GID` must match the roomy server's docker socket group, or the runner can't reach the Docker daemon. +- After updating to a caching-enabled runner, re-run `deploy-runner.sh` (or + `docker compose up -d` in the runner dir) so the new `config.yaml` + volumes + take effect, then push a fresh tag. diff --git a/runner/config.yaml b/runner/config.yaml new file mode 100644 index 0000000..56d51d8 --- /dev/null +++ b/runner/config.yaml @@ -0,0 +1,9 @@ +# Forgejo runner config. The important bit is valid_volumes: it whitelists the +# named Docker volumes the build-apk workflow mounts as persistent caches +# (Android SDK/NDK, Gradle home, npm cache). Without these listed, the runner +# rejects the workflow's `volumes:` and the job fails. +container: + valid_volumes: + - camptickets-android-sdk + - camptickets-gradle + - camptickets-npm diff --git a/runner/docker-compose.yml b/runner/docker-compose.yml index 670aff4..e5020d2 100644 --- a/runner/docker-compose.yml +++ b/runner/docker-compose.yml @@ -11,6 +11,9 @@ services: - "${DOCKER_GID:-988}" volumes: - ./data:/data + # config.yaml allows the build workflow to mount the persistent cache + # volumes (valid_volumes); without it those mounts are rejected. + - ./config.yaml:/data/config.yaml:ro # Runner spawns job containers via the host Docker daemon. - /var/run/docker.sock:/var/run/docker.sock env_file: .env @@ -31,4 +34,4 @@ services: --name "${RUNNER_NAME:-camptickets-runner}" \ --labels "docker:docker://node:22-bookworm" fi - exec forgejo-runner daemon + exec forgejo-runner daemon --config /data/config.yaml diff --git a/scripts/deploy-runner.sh b/scripts/deploy-runner.sh index 31255d7..ee5b16d 100755 --- a/scripts/deploy-runner.sh +++ b/scripts/deploy-runner.sh @@ -61,6 +61,24 @@ mkdir -p "$INSTALL_DIR/data" # The runner image runs as uid/gid 1000 and must own its config dir. chown -R 1000:1000 "$INSTALL_DIR/data" +# Runner config: allow the build workflow to mount the persistent cache volumes +# (Android SDK/NDK, Gradle home, npm cache) so APK builds don't redownload and +# recompile everything each run. Without valid_volumes listed, the runner +# rejects the workflow's `volumes:` and the job fails. +cat > "$INSTALL_DIR/config.yaml" <<'CONFIG' +container: + valid_volumes: + - camptickets-android-sdk + - camptickets-gradle + - camptickets-npm +CONFIG +chown 1000:1000 "$INSTALL_DIR/config.yaml" + +# Pre-create the named cache volumes (idempotent). +docker volume create camptickets-android-sdk >/dev/null +docker volume create camptickets-gradle >/dev/null +docker volume create camptickets-npm >/dev/null + cat > "$INSTALL_DIR/docker-compose.yml" < Date: Wed, 8 Jul 2026 16:31:25 +0000 Subject: [PATCH 02/37] Fix banquet mode ignored on web: use latest onScan in camera loop The web scanner's camera loop starts once in useEffect and closed over the mount-time onScan handler, so switching modes (e.g. to Banquet) kept invoking the original ticket-check-in handler. Route onScan through a ref updated each render so the loop always calls the current handler. Native was unaffected. Co-Authored-By: Claude Fable 5 --- app/components/QRScanner.web.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/components/QRScanner.web.tsx b/app/components/QRScanner.web.tsx index 06f1f64..43ee6d5 100644 --- a/app/components/QRScanner.web.tsx +++ b/app/components/QRScanner.web.tsx @@ -10,11 +10,16 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { const streamRef = useRef(null); const rafRef = useRef(null); const activeRef = useRef(active); + // Route onScan through a ref so the long-lived camera loop (started once in + // useEffect) always calls the LATEST handler — otherwise a mode switch + // (e.g. to Banquet) keeps hitting the mount-time handler. + const onScanRef = useRef(onScan); const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 }); const [error, setError] = useState(null); const [starting, setStarting] = useState(true); activeRef.current = active; + onScanRef.current = onScan; async function start() { setError(null); @@ -45,7 +50,7 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { const now = Date.now(); if (!(data === lastScan.current.code && now - lastScan.current.at < 3000)) { lastScan.current = { code: data, at: now }; - onScan(data); + onScanRef.current(data); } } } catch { From 0e8fe3bb9a48677657f1e8af55c0b09351ed37e7 Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 8 Jul 2026 16:45:23 +0000 Subject: [PATCH 03/37] 4-digit auto-submit PIN + operator name in audit logs Login: fixed-length 4-digit PIN that auto-submits on the 4th digit (no submit button to scroll to on small iPhone screens) and clears on a wrong PIN. Compact, vertically-centered keypad so it fits without scrolling. Operator tracking: after PIN auth, staff enter their name (new /operator screen, persisted per device). The name is sent as X-Operator on every authed request and recorded on each check-in/undo/ice audit entry (new Operator column), so logs show who did what. Shown in the scanner header and the admin audit view. Co-Authored-By: Claude Fable 5 --- app/app/_layout.tsx | 20 +++- app/app/admin.tsx | 1 + app/app/index.tsx | 10 +- app/app/login.tsx | 174 ++++++++++++++++++---------------- app/app/operator.tsx | 96 +++++++++++++++++++ app/lib/api.ts | 21 +++- app/lib/auth.tsx | 33 +++++-- app/lib/storage.ts | 42 ++++++++ backend/src/routes/tickets.ts | 3 +- backend/src/services/audit.ts | 11 ++- backend/src/ticketService.ts | 2 + 11 files changed, 311 insertions(+), 102 deletions(-) create mode 100644 app/app/operator.tsx diff --git a/app/app/_layout.tsx b/app/app/_layout.tsx index c7c7dca..358a7a4 100644 --- a/app/app/_layout.tsx +++ b/app/app/_layout.tsx @@ -18,16 +18,26 @@ export default function RootLayout() { } function AuthGate() { - const { ready, signedIn } = useAuth(); + const { ready, signedIn, operator } = useAuth(); const router = useRouter(); const segments = useSegments(); useEffect(() => { if (!ready) return; - const onLogin = segments[0] === "login"; - if (!signedIn && !onLogin) router.replace("/login"); - if (signedIn && onLogin) router.replace("/"); - }, [ready, signedIn, segments, router]); + const route = segments[0]; + const onLogin = route === "login"; + const onOperator = route === "operator"; + if (!signedIn) { + if (!onLogin) router.replace("/login"); + return; + } + // Signed in via PIN — require an operator name before using the app. + if (!operator) { + if (!onOperator) router.replace("/operator"); + return; + } + if (onLogin || onOperator) router.replace("/"); + }, [ready, signedIn, operator, segments, router]); if (!ready) { return ( diff --git a/app/app/admin.tsx b/app/app/admin.tsx index 8b5633e..fd9c121 100644 --- a/app/app/admin.tsx +++ b/app/app/admin.tsx @@ -33,6 +33,7 @@ function AuditList({ entries }: { entries: AuditEntry[] }) { {fmtTime(e.at)} · {e.action} · {e.remainingAfter} left + {e.operator ? ` · ${e.operator}` : ""} diff --git a/app/app/index.tsx b/app/app/index.tsx index cc043f6..f845805 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -19,7 +19,7 @@ const MODES: { key: Mode; label: string; icon: string }[] = [ ]; export default function ScannerScreen() { - const { signOut } = useAuth(); + const { signOut, operator } = useAuth(); const [mode, setMode] = useState("tickets"); const [phase, setPhase] = useState("scanning"); const [ticket, setTicket] = useState(null); @@ -155,7 +155,10 @@ export default function ScannerScreen() { return ( - 🐻 Camp Scan + + 🐻 Camp Scan + {!!operator && {operator}} + router.push("/admin")} hitSlop={10}> Admin @@ -428,7 +431,8 @@ const styles = StyleSheet.create({ paddingVertical: 10, }, brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, - topActions: { flexDirection: "row", gap: 18 }, + operator: { color: theme.textDim, fontSize: 13, marginTop: 1 }, + topActions: { flexDirection: "row", gap: 18, alignItems: "center" }, link: { color: theme.textDim, fontSize: 15, fontWeight: "600" }, modeBar: { flexDirection: "row", gap: 8, paddingHorizontal: 12, paddingBottom: 8 }, diff --git a/app/app/login.tsx b/app/app/login.tsx index b03a8d6..624e648 100644 --- a/app/app/login.tsx +++ b/app/app/login.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { StyleSheet, View, Text, Pressable } from "react-native"; import { SafeAreaView } from "react-native-safe-area-context"; import { AuthError } from "../lib/api"; @@ -6,115 +6,125 @@ import { useAuth } from "../lib/auth"; import { primeFeedback } from "../lib/feedback"; import { theme } from "../lib/theme"; -const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"]; +const PIN_LENGTH = 4; +// Bottom row: blank / 0 / backspace. +const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "", "0", "back"]; export default function LoginScreen() { const { signIn } = useAuth(); const [pin, setPin] = useState(""); const [error, setError] = useState(""); const [busy, setBusy] = useState(false); + const pinRef = useRef(""); + const busyRef = useRef(false); - async function press(k: string) { - // First tap unlocks web audio playback. - primeFeedback(); - setError(""); - if (k === "clear") return setPin(""); - if (k === "back") return setPin((p) => p.slice(0, -1)); - const next = (pin + k).slice(0, 12); - setPin(next); - } + const submit = useCallback( + async (value: string) => { + busyRef.current = true; + setBusy(true); + setError(""); + try { + await signIn(value); + // The auth gate in _layout navigates once signedIn flips to true. + } catch (e: any) { + setError(e instanceof AuthError ? "Incorrect PIN — try again" : (e?.message ?? "Login failed")); + pinRef.current = ""; // wrong PIN: clear and start over + setPin(""); + } finally { + busyRef.current = false; + setBusy(false); + } + }, + [signIn], + ); - async function submit() { - if (!pin) return; - setBusy(true); - setError(""); - try { - await signIn(pin); - // The auth gate in _layout navigates once signedIn flips to true. - } catch (e: any) { - setError(e instanceof AuthError ? "Incorrect PIN" : (e?.message ?? "Login failed")); - setPin(""); - } finally { - setBusy(false); - } - } + const press = useCallback( + (k: string) => { + if (busyRef.current || k === "") return; + // First tap unlocks web audio playback. + primeFeedback(); + setError(""); + if (k === "back") { + pinRef.current = pinRef.current.slice(0, -1); + setPin(pinRef.current); + return; + } + if (pinRef.current.length >= PIN_LENGTH) return; + pinRef.current += k; + setPin(pinRef.current); + // Auto-submit as soon as the PIN is complete — no button to reach. + if (pinRef.current.length === PIN_LENGTH) submit(pinRef.current); + }, + [submit], + ); return ( - - 🐻 - Camp Scan - Enter the gate PIN + + + 🐻 + Camp Scan + {busy ? "Checking…" : "Enter the gate PIN"} + + + + {Array.from({ length: PIN_LENGTH }).map((_, i) => ( + + ))} + + + {error || " "} + + + {KEYS.map((k, i) => ( + press(k)} + disabled={k === "" || busy} + > + {k === "back" ? "⌫" : k} + + ))} + - - - {Array.from({ length: Math.max(4, pin.length) }).map((_, i) => ( - - ))} - - - {!!error && {error}} - - - {KEYS.map((k) => ( - press(k)} - > - {k === "back" ? "⌫" : k === "clear" ? "C" : k} - - ))} - - - - {busy ? "Signing in…" : "Sign in"} - ); } +const KEY = 72; +const GAP = 16; + const styles = StyleSheet.create({ - root: { flex: 1, backgroundColor: theme.bg, alignItems: "center", paddingHorizontal: 24 }, - header: { alignItems: "center", marginTop: 48 }, - logo: { fontSize: 56 }, - title: { color: theme.text, fontSize: 30, fontWeight: "800", marginTop: 8 }, - subtitle: { color: theme.textDim, fontSize: 16, marginTop: 6 }, - dots: { flexDirection: "row", gap: 14, marginTop: 32, minHeight: 18 }, - dot: { width: 14, height: 14, borderRadius: 7, backgroundColor: theme.cardBorder }, + root: { flex: 1, backgroundColor: theme.bg }, + inner: { flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: 24, paddingVertical: 12 }, + header: { alignItems: "center" }, + logo: { fontSize: 44 }, + title: { color: theme.text, fontSize: 26, fontWeight: "800", marginTop: 4 }, + subtitle: { color: theme.textDim, fontSize: 15, marginTop: 4 }, + dots: { flexDirection: "row", gap: 16, marginTop: 20 }, + dot: { width: 15, height: 15, borderRadius: 8, backgroundColor: theme.cardBorder }, dotFilled: { backgroundColor: theme.successBright }, - error: { color: theme.dangerBright, marginTop: 16, fontSize: 15, fontWeight: "600" }, + error: { color: theme.dangerBright, marginTop: 10, marginBottom: 2, fontSize: 15, fontWeight: "600", height: 20 }, + errorHidden: { opacity: 0 }, pad: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", - gap: 16, - marginTop: 28, - maxWidth: 300, + gap: GAP, + marginTop: 8, + width: KEY * 3 + GAP * 2, }, key: { - width: 84, - height: 84, - borderRadius: 42, + width: KEY, + height: KEY, + borderRadius: KEY / 2, backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, alignItems: "center", justifyContent: "center", }, - keyAlt: { backgroundColor: "transparent" }, - keyText: { color: theme.text, fontSize: 30, fontWeight: "600" }, - submit: { - marginTop: 28, - backgroundColor: theme.successBright, - paddingHorizontal: 60, - paddingVertical: 16, - borderRadius: 14, - }, - submitDisabled: { opacity: 0.4 }, - submitText: { color: "#06210f", fontSize: 20, fontWeight: "800" }, + keyAlt: { backgroundColor: "transparent", borderColor: "transparent" }, + keyText: { color: theme.text, fontSize: 28, fontWeight: "600" }, }); diff --git a/app/app/operator.tsx b/app/app/operator.tsx new file mode 100644 index 0000000..41f12c4 --- /dev/null +++ b/app/app/operator.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import { StyleSheet, View, Text, TextInput, Pressable, KeyboardAvoidingView, Platform } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { useAuth } from "../lib/auth"; +import { theme } from "../lib/theme"; + +export default function OperatorScreen() { + const { setOperator, signOut } = useAuth(); + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + + const submit = async () => { + const trimmed = name.trim(); + if (!trimmed || busy) return; + setBusy(true); + await setOperator(trimmed); + // The auth gate navigates to the scanner once operator is set. + }; + + return ( + + + + 🐻 + Who's scanning? + Your name is recorded with every check-in. + + + + + {busy ? "Starting…" : "Start scanning"} + + + signOut()} hitSlop={10}> + Sign out + + + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: theme.bg }, + flex: { flex: 1 }, + inner: { flex: 1, alignItems: "center", justifyContent: "center", paddingHorizontal: 28 }, + logo: { fontSize: 44 }, + title: { color: theme.text, fontSize: 26, fontWeight: "800", marginTop: 6 }, + subtitle: { color: theme.textDim, fontSize: 15, marginTop: 6, textAlign: "center" }, + input: { + width: "100%", + maxWidth: 340, + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + borderRadius: 14, + paddingHorizontal: 16, + paddingVertical: 16, + color: theme.text, + fontSize: 20, + marginTop: 28, + textAlign: "center", + }, + btn: { + width: "100%", + maxWidth: 340, + backgroundColor: theme.successBright, + paddingVertical: 16, + borderRadius: 14, + marginTop: 18, + alignItems: "center", + }, + btnDisabled: { opacity: 0.4 }, + btnText: { color: "#06210f", fontSize: 20, fontWeight: "800" }, + back: { marginTop: 20, padding: 8 }, + backText: { color: theme.textDim, fontSize: 15 }, +}); diff --git a/app/lib/api.ts b/app/lib/api.ts index e6c7adb..17f2bea 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -1,5 +1,5 @@ import { Platform } from "react-native"; -import { loadToken, saveToken, clearToken } from "./storage"; +import { loadToken, saveToken, clearToken, loadOperator, saveOperator, clearOperator } from "./storage"; /** * API base URL. On web the app is served from the same origin as the API, so we @@ -39,6 +39,7 @@ export class AuthError extends Error {} export class ApiError extends Error {} let cachedToken: string | null = null; +let cachedOperator: string | null = null; export async function getToken(): Promise { if (cachedToken) return cachedToken; @@ -46,6 +47,17 @@ export async function getToken(): Promise { return cachedToken; } +export async function getOperator(): Promise { + if (cachedOperator !== null) return cachedOperator; + cachedOperator = await loadOperator(); + return cachedOperator; +} + +export async function setOperator(name: string): Promise { + cachedOperator = name; + await saveOperator(name); +} + export async function login(pin: string): Promise { const res = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", @@ -68,18 +80,22 @@ export function onAuthCleared(cb: (() => void) | null): void { export async function logout(): Promise { cachedToken = null; + cachedOperator = null; await clearToken(); + await clearOperator(); onCleared?.(); } async function authed(path: string, init: RequestInit = {}): Promise { const token = await getToken(); if (!token) throw new AuthError("Not logged in"); + const operator = await getOperator(); const res = await fetch(`${API_BASE}${path}`, { ...init, headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, + ...(operator ? { "X-Operator": operator } : {}), ...(init.headers || {}), }, }); @@ -167,9 +183,10 @@ export interface AuditEntry { code: string; people: number; name: string; + operator: string; remainingAfter: number; at: string; - action: "check-in" | "undo"; + action: "check-in" | "undo" | "ice" | "ice-undo"; } export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{ diff --git a/app/lib/auth.tsx b/app/lib/auth.tsx index 4d903d5..9adc874 100644 --- a/app/lib/auth.tsx +++ b/app/lib/auth.tsx @@ -1,10 +1,19 @@ import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; -import { login as apiLogin, logout as apiLogout, getToken, onAuthCleared } from "./api"; +import { + login as apiLogin, + logout as apiLogout, + getToken, + getOperator, + setOperator as apiSetOperator, + onAuthCleared, +} from "./api"; interface AuthState { - ready: boolean; // finished the initial token load - signedIn: boolean; + ready: boolean; // finished the initial load + signedIn: boolean; // has a valid PIN token + operator: string; // gate staff name (empty until set) signIn: (pin: string) => Promise; + setOperator: (name: string) => Promise; signOut: () => Promise; } @@ -13,14 +22,19 @@ const Ctx = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const [ready, setReady] = useState(false); const [signedIn, setSignedIn] = useState(false); + const [operator, setOperatorState] = useState(""); useEffect(() => { - getToken().then((t) => { + Promise.all([getToken(), getOperator()]).then(([t, op]) => { setSignedIn(!!t); + setOperatorState(op ?? ""); setReady(true); }); // Keep state in sync when the token is cleared elsewhere (401 handling). - onAuthCleared(() => setSignedIn(false)); + onAuthCleared(() => { + setSignedIn(false); + setOperatorState(""); + }); return () => onAuthCleared(null); }, []); @@ -28,12 +42,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { await apiLogin(pin); setSignedIn(true); }; + const setOperator = async (name: string) => { + await apiSetOperator(name); + setOperatorState(name); + }; const signOut = async () => { await apiLogout(); setSignedIn(false); + setOperatorState(""); }; - return {children}; + return ( + {children} + ); } export function useAuth(): AuthState { diff --git a/app/lib/storage.ts b/app/lib/storage.ts index f881d86..fb8dd5e 100644 --- a/app/lib/storage.ts +++ b/app/lib/storage.ts @@ -2,6 +2,7 @@ import { Platform } from "react-native"; // Token persistence: localStorage on web, SecureStore on native. const KEY = "campscan.token"; +const OPERATOR_KEY = "campscan.operator"; export async function saveToken(token: string): Promise { if (Platform.OS === "web") { @@ -40,3 +41,44 @@ export async function clearToken(): Promise { const SecureStore = await import("expo-secure-store"); await SecureStore.deleteItemAsync(KEY); } + +// Operator (gate staff) name — plain persistence, not sensitive. Kept in +// localStorage on both platforms for simplicity (SecureStore is overkill here; +// on native we still use localStorage-less AsyncStorage-free approach below). +export async function saveOperator(name: string): Promise { + if (Platform.OS === "web") { + try { + window.localStorage.setItem(OPERATOR_KEY, name); + } catch { + /* ignore */ + } + return; + } + const SecureStore = await import("expo-secure-store"); + await SecureStore.setItemAsync(OPERATOR_KEY, name); +} + +export async function loadOperator(): Promise { + if (Platform.OS === "web") { + try { + return window.localStorage.getItem(OPERATOR_KEY); + } catch { + return null; + } + } + const SecureStore = await import("expo-secure-store"); + return SecureStore.getItemAsync(OPERATOR_KEY); +} + +export async function clearOperator(): Promise { + if (Platform.OS === "web") { + try { + window.localStorage.removeItem(OPERATOR_KEY); + } catch { + /* ignore */ + } + return; + } + const SecureStore = await import("expo-secure-store"); + await SecureStore.deleteItemAsync(OPERATOR_KEY); +} diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 8411718..c99c2b8 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -87,7 +87,8 @@ export async function ticketRoutes(app: FastifyInstance): Promise { count?: number; resource?: "tickets" | "ice"; }; - return redeem(app.ctx, normalizeCode(code), count ?? 1, resource ?? "tickets"); + const operator = String(req.headers["x-operator"] ?? "").slice(0, 80); + return redeem(app.ctx, normalizeCode(code), count ?? 1, resource ?? "tickets", operator); }, ); diff --git a/backend/src/services/audit.ts b/backend/src/services/audit.ts index 58b80f8..05e3b50 100644 --- a/backend/src/services/audit.ts +++ b/backend/src/services/audit.ts @@ -7,7 +7,8 @@ export const AUDIT_COL = { code: "Ticket Code", people: "People", action: "Action", - name: "Name", + name: "Name", // the ticket holder's name + operator: "Operator", // the gate staff member who performed the action remainingAfter: "Remaining After", } as const; @@ -15,6 +16,7 @@ export interface AuditEntry { code: string; people: number; // positive = checked in, negative = undo name: string; + operator: string; remainingAfter: number; at: string; // ISO action: "check-in" | "undo" | "ice" | "ice-undo"; @@ -51,7 +53,8 @@ export class AuditLogger { async log(entry: AuditEntry): Promise { if (!this.tableId) return; const sign = entry.people >= 0 ? "+" : ""; - const summary = `${entry.code} ${sign}${entry.people} (${entry.action})`; + const who = entry.operator ? ` by ${entry.operator}` : ""; + const summary = `${entry.code} ${sign}${entry.people} (${entry.action})${who}`; try { const res = await fetch(this.url, { method: "POST", @@ -63,6 +66,7 @@ export class AuditLogger { [AUDIT_COL.people]: entry.people, [AUDIT_COL.action]: entry.action, [AUDIT_COL.name]: entry.name, + [AUDIT_COL.operator]: entry.operator, [AUDIT_COL.remainingAfter]: entry.remainingAfter, }), }); @@ -94,9 +98,10 @@ export class AuditLogger { 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 "check-in" | "undo", + action: (r[AUDIT_COL.action] ?? "check-in") as "check-in" | "undo" | "ice" | "ice-undo", })); } } diff --git a/backend/src/ticketService.ts b/backend/src/ticketService.ts index 60d27e5..76fa4dc 100644 --- a/backend/src/ticketService.ts +++ b/backend/src/ticketService.ts @@ -60,6 +60,7 @@ export async function redeem( code: string, count: number, resource: Resource = "tickets", + operator = "", ): Promise { const n = Math.trunc(count); if (!Number.isFinite(n) || n === 0) { @@ -106,6 +107,7 @@ export async function redeem( code: view.code, people: delta, name: view.name, + operator, remainingAfter, at: new Date().toISOString(), action: delta >= 0 ? cfg.auditIn : cfg.auditUndo, From 34c7e23179aea6e3cb73b55e4eaef53461efe449 Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 8 Jul 2026 16:57:03 +0000 Subject: [PATCH 04/37] Make the success sound a warm ascending major arpeggio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two-note chirp with a bell-like C–E–G–C arpeggio (soft attack, gentle decay, light harmonics) for a more pleasing check-in confirmation. Co-Authored-By: Claude Fable 5 --- app/assets/sounds/success.wav | Bin 22094 -> 68396 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/app/assets/sounds/success.wav b/app/assets/sounds/success.wav index 63d656857ac8aa3540ea2fb3d13656ae37258e8a..c7e7b26ec170798376630ecb7999cb58d54ea77d 100644 GIT binary patch literal 68396 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 literal 22094 zcmWJsWpJCj7Bn+MjIkZZahRFPO_|}9nVA_=|Q0R(^m1i+GoTNd*B z=Kuf)k=3M^EM4gQXmMgz`uyyq0S^W~8<;oXK=$f%M$*gZ5dtcE7#WVb0AM@X`~31> z9V42HYG##36c79R`}c$2hCjE8u9wwU3!65J&&tN?w%fY=BN2y)Gnv=<)C(%vp%f(gJ#dPXXBe*}bX{zL)E%kFC`m3n@n`U#`G3Vl zkIP}>jo^(1!qr8Uz{{EW=hzu zU^fMVsRSK%EjHd%Es#iBht|KV99Od_zJTNn+pFHd&<^SdmH{~ zw{>HCpO`MVuY*rvQmIRW`vmu*=OzNu)@NSLUX#Yk)7HlB6u2F*m<$Xxc5&?Qmv z396J$8Q-%$XV1(2m|2*1BWX%3DqPM*)1TpuFrt5e?V0Y7+})AYvZC&BC9dpa@#CW2 zqP->mD_>rHwV|x7RdP$EF^*%&irlVktyk;+RN=}mmn+ z0)Qe95pOd>Lv-N{vG0;Lq`5K%WMyP2GbX1UPr4WTDO}52%9uzLA(8<9ID!ly)gH;a zwvvX0&@5y!a}-&`(F4U=5peiRC31d z%-fmUGVp0zlYYbkA{K?fnRHSyG7I>^nP<#Vf9uw?TN~Xqw918Ls*(>SMWxh=Yt@+z zU9HQyUMi0EmD`5-OJSk-NZK>buCOi9+Y^?hM5X_m;mrVK6sOKgs*4>OQ6F-T*-mNrR7JT?ZO!w&g?g}7>iXcJ zg1o4i@$-@gq-oOsmvK0KTWWezN9^8+Onx%!BKZh99o+7|W`3lJk`j%r@Riq7aCODlVxf z_HD#<{!i9$%4|#wMCJKrY1A&1pYGh-I;|nGCbDvT`SY?t<+w^@4Ywh)HLDXKU#iWu z%<)_Te?ea*!&st_zHnCTsKj|GBh&n8yVD9&B#DyPjtCQf5Zg$J!ZM)-Z;REhyQuiD z>si~D#@t$V6~AJ3c}4lU%AA_824btS!>`fLLIAEiOk)PAvPbRDtUT|uarSE8wV-q7QLvIEb2Cc^bfXf3I=VBwJcVv&SqrPQ%1F-ge z)v?N>m2azjH47U`TWB3gJ$7}rvEA_~umMiM&k70+&I>JwAjTd{=t^RwM5j=bI}^^v z4vd5f0>Kk$!GyDj1_0T$+mx=^ARE_dY~9}It-V-1vPw`DUOlGvP{Yp_sklM9R(;xl za(wZxhPkmBR4xWw zY?3l5I4Lb*KG#{r-Yt+0G=Th>A?A(@lSZD5O-v|G%uTwMD2hkN=0+Y71UTh%IH?=8 z2MqT50_&`S3n05_ww#Ow6$ zoLE6!#HpB(@s5OZiTK1F@eMKYk@p3|xfzUGq^s!35Qq1btw3*8HAxqAd}*~c#?^1E zEvred{ZTirQQR^`e6#zKlHG^54)$)Uf)Pe#v)3r~7;njDj22q#f9&{T{#T1n7wfu z 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 xUPC*0i42?#IS>F Date: Wed, 8 Jul 2026 17:04:52 +0000 Subject: [PATCH 05/37] CI: cap runner parallelism; play success chime immediately on scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI: limit Gradle to 2 worker JVMs (org.gradle.workers.max=2, --max-workers=2, parallel=false), cap native compiler jobs (CMAKE_BUILD_PARALLEL_LEVEL=2), and build only the arm64-v8a ABI — so the build no longer spawns a JVM/compiler per core and swamps the host (which also serves Forgejo). App: the success chime now fires the instant a scan resolves to a valid ticket (ticket/ice modes), matching Banquet, instead of after the check-in round-trip. The failure sound is unchanged. Removed the duplicate chime at check-in. Co-Authored-By: Claude Fable 5 --- .forgejo/workflows/build-apk.yml | 24 +++++++++++++++++++----- app/app/index.tsx | 5 ++++- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/.forgejo/workflows/build-apk.yml b/.forgejo/workflows/build-apk.yml index c51c8d6..c76b7a3 100644 --- a/.forgejo/workflows/build-apk.yml +++ b/.forgejo/workflows/build-apk.yml @@ -28,10 +28,18 @@ jobs: # The runner host has no working IPv6 route, so Maven Central (which has # AAAA records) was unreachable — this makes Java ignore AAAA and use IPv4. JAVA_TOOL_OPTIONS: -Djava.net.preferIPv4Stack=true - # org.gradle.caching enables the local build cache (persisted in - # GRADLE_USER_HOME), so unchanged native/Kotlin tasks are restored instead - # of recompiled. - GRADLE_OPTS: -Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false -Dorg.gradle.caching=true + # Keep the build from swamping the host: + # - workers.max=2: at most 2 concurrent Gradle worker JVMs (was one per core) + # - parallel=false: don't build projects in parallel + # - caching=true: reuse the local build cache (persisted in GRADLE_USER_HOME) + GRADLE_OPTS: >- + -Dorg.gradle.jvmargs=-Xmx3g + -Dorg.gradle.daemon=false + -Dorg.gradle.caching=true + -Dorg.gradle.parallel=false + -Dorg.gradle.workers.max=2 + # Cap the C/C++ (ninja) compiler jobs per native-build task to 2. + CMAKE_BUILD_PARALLEL_LEVEL: "2" steps: - name: Checkout uses: actions/checkout@v4 @@ -95,7 +103,13 @@ jobs: CAMPSCAN_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }} run: | chmod +x ./gradlew - ./gradlew assembleRelease --init-script ../../ci/signing.gradle --no-daemon --build-cache + # Build a single ABI (arm64-v8a covers all modern phones) to cut native + # compilation ~4x and keep the host load down. --max-workers=2 bounds + # concurrent tasks on top of the workers.max setting. + ./gradlew assembleRelease \ + --init-script ../../ci/signing.gradle \ + --no-daemon --build-cache --max-workers=2 \ + -PreactNativeArchitectures=arm64-v8a mkdir -p "$GITHUB_WORKSPACE/artifacts" cp app/build/outputs/apk/release/app-release.apk \ "$GITHUB_WORKSPACE/artifacts/camp-scan-${{ steps.ver.outputs.tag }}.apk" diff --git a/app/app/index.tsx b/app/app/index.tsx index f845805..324d545 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -102,6 +102,9 @@ export default function ScannerScreen() { const res = await lookup(raw); if (!res.ok) return showError(`Database error: ${res.detail}`); if (!res.found) return showError(`Not a valid ticket:\n${raw.slice(0, 40)}`); + // Valid ticket recognized — chime immediately so staff hear the scan + // landed, before picking a count. + feedbackSuccess(); const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining; setTicket(res.ticket); // Ice: default to grabbing all remaining bags at once. Tickets: default 1. @@ -133,7 +136,7 @@ export default function ScannerScreen() { if (res.ticket) setTicket(res.ticket); return; } - feedbackSuccess(); + // Success chime already played at scan time; just show the green result. setTicket(res.ticket); setCheckedIn(res.checkedIn); setPhase("success"); From e87be49ebd96b8748892175c145aa329171bdee3 Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 8 Jul 2026 17:26:00 +0000 Subject: [PATCH 06/37] =?UTF-8?q?Fix=20web=20camera=20stuck=20on=20"Starti?= =?UTF-8?q?ng=20camera=E2=80=A6"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getUserMedia (and the awaited play()) could hang indefinitely on iOS when the scanner mounts after navigation (not in a user-gesture context), leaving the UI stuck with no recovery. Add a 12s watchdog that surfaces an error + Retry button (Retry is a fresh gesture iOS honors), stop awaiting play() (fire and forget), and stop any prior stream before re-requesting. Co-Authored-By: Claude Fable 5 --- app/components/QRScanner.web.tsx | 36 +++++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 7 deletions(-) diff --git a/app/components/QRScanner.web.tsx b/app/components/QRScanner.web.tsx index 43ee6d5..d4594a2 100644 --- a/app/components/QRScanner.web.tsx +++ b/app/components/QRScanner.web.tsx @@ -24,17 +24,29 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { async function start() { setError(null); setStarting(true); + // Stop any previous stream before requesting a new one (Retry / remounts). + streamRef.current?.getTracks().forEach((t) => t.stop()); + streamRef.current = null; try { - const stream = await navigator.mediaDevices.getUserMedia({ - video: { facingMode: { ideal: "environment" } }, - audio: false, - }); + // Watchdog: on iOS, getUserMedia can hang indefinitely when it isn't tied + // to a user gesture (we navigate here from the name screen). If it doesn't + // resolve, surface an error + Retry button (Retry IS a fresh gesture). + const stream = await withTimeout( + navigator.mediaDevices.getUserMedia({ + video: { facingMode: { ideal: "environment" } }, + audio: false, + }), + 12000, + ); streamRef.current = stream; const video = videoRef.current; if (video) { video.srcObject = stream; video.setAttribute("playsinline", "true"); - await video.play().catch(() => {}); + video.muted = true; + // Fire-and-forget: awaiting play() can itself hang on iOS. We only need + // the stream attached; the tick loop waits for readyState. + video.play().catch(() => {}); } const detector = new BarcodeDetector({ formats: ["qr_code"] }); let busy = false; @@ -65,12 +77,22 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { setStarting(false); setError( e?.name === "NotAllowedError" - ? "Camera permission was denied. Allow camera access and reload." - : "Could not open the camera. Make sure you're on HTTPS and no other app is using it.", + ? "Camera permission was denied. Allow camera access, then tap Retry." + : e?.message === "timeout" + ? "Camera didn't start. Tap Retry." + : "Could not open the camera. Make sure you're on HTTPS and no other app is using it.", ); } } + // Reject after `ms` if the promise hasn't settled (used to un-stick getUserMedia). + function withTimeout(p: Promise, ms: number): Promise { + return Promise.race([ + p, + new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms)), + ]); + } + useEffect(() => { start(); return () => { From 8f62a84e9b2ce2dd5ea05976024fc6dbb57e9a06 Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 8 Jul 2026 17:42:28 +0000 Subject: [PATCH 07/37] CI: cap Kotlin daemon + Gradle heap so the build fits the runner host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runner host (~20GB/4-core, shared with Forgejo + databases) OOM-thrashed because the RN build spawned ~3 Kotlin daemons at -Xmx5376m each. org.gradle.jvmargs only bounds the Gradle JVM, not the forked Kotlin daemons — so write a gradle.properties to GRADLE_USER_HOME with kotlin.daemon.jvmargs=-Xmx1536m, org.gradle.jvmargs=-Xmx1536m, workers.max=2, parallel=false. Combined with the earlier single-ABI + CMAKE_BUILD_PARALLEL_LEVEL=2 caps, the build no longer swamps the host. Co-Authored-By: Claude Fable 5 --- .forgejo/workflows/build-apk.yml | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/.forgejo/workflows/build-apk.yml b/.forgejo/workflows/build-apk.yml index c76b7a3..9a8df34 100644 --- a/.forgejo/workflows/build-apk.yml +++ b/.forgejo/workflows/build-apk.yml @@ -28,17 +28,8 @@ jobs: # The runner host has no working IPv6 route, so Maven Central (which has # AAAA records) was unreachable — this makes Java ignore AAAA and use IPv4. JAVA_TOOL_OPTIONS: -Djava.net.preferIPv4Stack=true - # Keep the build from swamping the host: - # - workers.max=2: at most 2 concurrent Gradle worker JVMs (was one per core) - # - parallel=false: don't build projects in parallel - # - caching=true: reuse the local build cache (persisted in GRADLE_USER_HOME) - GRADLE_OPTS: >- - -Dorg.gradle.jvmargs=-Xmx3g - -Dorg.gradle.daemon=false - -Dorg.gradle.caching=true - -Dorg.gradle.parallel=false - -Dorg.gradle.workers.max=2 # Cap the C/C++ (ninja) compiler jobs per native-build task to 2. + # (Memory/parallelism caps live in gradle.properties — see the step below.) CMAKE_BUILD_PARALLEL_LEVEL: "2" steps: - name: Checkout @@ -57,6 +48,25 @@ jobs: apt-get install -y --no-install-recommends openjdk-17-jdk-headless unzip wget git echo "JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64" >> "$GITHUB_ENV" + - name: Cap Gradle & Kotlin memory (runner host is small) + run: | + set -eu + # The runner host (~20GB/4-core, shared with Forgejo + databases) OOM- + # thrashes if this build runs unbounded. org.gradle.jvmargs only bounds + # the Gradle JVM — the Kotlin compile daemons fork separately and + # default to a huge heap (~5GB each), so kotlin.daemon.jvmargs is the + # load-bearing cap here. Written to the persistent GRADLE_USER_HOME. + mkdir -p "$GRADLE_USER_HOME" + cat > "$GRADLE_USER_HOME/gradle.properties" <<'PROPS' + org.gradle.jvmargs=-Xmx1536m -XX:MaxMetaspaceSize=512m -Djava.net.preferIPv4Stack=true + org.gradle.daemon=false + org.gradle.parallel=false + org.gradle.caching=true + org.gradle.workers.max=2 + kotlin.daemon.jvmargs=-Xmx1536m + PROPS + echo "Wrote $GRADLE_USER_HOME/gradle.properties:"; cat "$GRADLE_USER_HOME/gradle.properties" + - name: Install Android SDK (cached) run: | set -eu From 8b202e4942446848be300cefec7e3858bfc9e17c Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 8 Jul 2026 17:51:42 +0000 Subject: [PATCH 08/37] Scanner: tappable start overlay so the camera can never silently hang MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-start on mount as before, but keep a tappable overlay until the video is actually playing (readyState>=2). If getUserMedia hangs (8s watchdog), is denied, or fails, the overlay becomes a "tap to start camera" button that retries in a real user-gesture context — instead of sitting on "Starting camera…" with no recovery. Works for both Android Chrome PWA and iOS Safari. Co-Authored-By: Claude Fable 5 --- app/components/QRScanner.web.tsx | 116 +++++++++++++++++++------------ 1 file changed, 71 insertions(+), 45 deletions(-) diff --git a/app/components/QRScanner.web.tsx b/app/components/QRScanner.web.tsx index d4594a2..db5f1d6 100644 --- a/app/components/QRScanner.web.tsx +++ b/app/components/QRScanner.web.tsx @@ -4,39 +4,48 @@ import { BarcodeDetector } from "barcode-detector/ponyfill"; import { theme } from "../lib/theme"; import type { QRScannerProps } from "./QRScanner"; -/** Web/PWA scanner using getUserMedia + the BarcodeDetector ponyfill (zxing-wasm). */ +/** + * Web/PWA scanner using getUserMedia + the BarcodeDetector ponyfill (zxing-wasm). + * + * iOS Safari only reliably grants the camera from inside a user gesture, and it + * loses that context across the app's navigation (PIN → name → scanner). So we + * auto-attempt on mount but always keep a tappable overlay: if auto-start + * doesn't produce a playing video, one tap starts it in a real gesture context. + */ export default function QRScanner({ onScan, active }: QRScannerProps) { const videoRef = useRef(null); const streamRef = useRef(null); const rafRef = useRef(null); const activeRef = useRef(active); - // Route onScan through a ref so the long-lived camera loop (started once in - // useEffect) always calls the LATEST handler — otherwise a mode switch - // (e.g. to Banquet) keeps hitting the mount-time handler. + const startingRef = useRef(false); + const playingRef = useRef(false); const onScanRef = useRef(onScan); const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 }); + const [playing, setPlaying] = useState(false); const [error, setError] = useState(null); - const [starting, setStarting] = useState(true); activeRef.current = active; onScanRef.current = onScan; async function start() { + if (startingRef.current) return; + startingRef.current = true; setError(null); - setStarting(true); - // Stop any previous stream before requesting a new one (Retry / remounts). + playingRef.current = false; + setPlaying(false); + // Drop any previous stream before requesting a new one. streamRef.current?.getTracks().forEach((t) => t.stop()); streamRef.current = null; try { - // Watchdog: on iOS, getUserMedia can hang indefinitely when it isn't tied - // to a user gesture (we navigate here from the name screen). If it doesn't - // resolve, surface an error + Retry button (Retry IS a fresh gesture). + // Watchdog: if getUserMedia neither resolves nor rejects (seen as a hung + // "Starting camera…"), reject after 8s so the overlay becomes an + // actionable "tap to start" instead of hanging forever. const stream = await withTimeout( navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: "environment" } }, audio: false, }), - 12000, + 8000, ); streamRef.current = stream; const video = videoRef.current; @@ -44,8 +53,6 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { video.srcObject = stream; video.setAttribute("playsinline", "true"); video.muted = true; - // Fire-and-forget: awaiting play() can itself hang on iOS. We only need - // the stream attached; the tick loop waits for readyState. video.play().catch(() => {}); } const detector = new BarcodeDetector({ formats: ["qr_code"] }); @@ -53,7 +60,12 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { const tick = async () => { rafRef.current = requestAnimationFrame(tick); const v = videoRef.current; - if (!v || v.readyState < 2 || busy || !activeRef.current) return; + if (!v || v.readyState < 2) return; + if (!playingRef.current) { + playingRef.current = true; + setPlaying(true); // camera is actually up + } + if (busy || !activeRef.current) return; busy = true; try { const codes = await detector.detect(v); @@ -71,28 +83,19 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { busy = false; } }; + if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current = requestAnimationFrame(tick); - setStarting(false); } catch (e: any) { - setStarting(false); setError( - e?.name === "NotAllowedError" - ? "Camera permission was denied. Allow camera access, then tap Retry." - : e?.message === "timeout" - ? "Camera didn't start. Tap Retry." - : "Could not open the camera. Make sure you're on HTTPS and no other app is using it.", + e?.name === "NotAllowedError" || e?.name === "SecurityError" + ? "Camera permission was denied. Enable camera access for this site, then tap to try again." + : "Could not open the camera. Make sure no other app is using it, then tap to try again.", ); + } finally { + startingRef.current = false; } } - // Reject after `ms` if the promise hasn't settled (used to un-stick getUserMedia). - function withTimeout(p: Promise, ms: number): Promise { - return Promise.race([ - p, - new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms)), - ]); - } - useEffect(() => { start(); return () => { @@ -102,9 +105,17 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); + // Reject after `ms` if the promise hasn't settled (un-sticks a hung getUserMedia). + function withTimeout(p: Promise, ms: number): Promise { + return Promise.race([ + p, + new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms)), + ]); + } + return ( - {/* Raw DOM video element; react-dom renders it inside the RN-Web div tree. */} + {/* Raw DOM video element; react-dom renders it inside the RN-Web tree. */} ); @@ -142,7 +160,15 @@ const styles = StyleSheet.create({ padding: 24, backgroundColor: theme.bg, }, - msg: { color: theme.text, fontSize: 16, textAlign: "center", marginBottom: 16 }, - btn: { backgroundColor: theme.primary, paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10 }, - btnText: { color: "#fff", fontSize: 16, fontWeight: "600" }, + icon: { fontSize: 44, marginBottom: 12 }, + msg: { color: theme.text, fontSize: 17, textAlign: "center", marginBottom: 12, lineHeight: 24 }, + hint: { color: theme.textDim, fontSize: 14, textAlign: "center" }, + btn: { + backgroundColor: theme.successBright, + paddingHorizontal: 24, + paddingVertical: 14, + borderRadius: 12, + marginTop: 6, + }, + btnText: { color: "#06210f", fontSize: 17, fontWeight: "800" }, }); From 83e2af6ed33885f499be0ec496f36bee90718a50 Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 8 Jul 2026 18:38:09 +0000 Subject: [PATCH 09/37] CI: install NDK+CMake via sdkmanager and self-heal corrupt cache The build failed with "Archive is not a ZIP archive" when AGP auto-downloaded NDK 27.1.12297006 mid-build (truncated archive). Install the NDK and CMake up front via sdkmanager (checksum-verified, robust) so AGP finds them already present, and wipe any incomplete ndk/cmake dir (missing source.properties) left in the cache volume by a failed run so it reinstalls cleanly. Co-Authored-By: Claude Fable 5 --- .forgejo/workflows/build-apk.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/build-apk.yml b/.forgejo/workflows/build-apk.yml index 9a8df34..3f3fd2b 100644 --- a/.forgejo/workflows/build-apk.yml +++ b/.forgejo/workflows/build-apk.yml @@ -85,10 +85,25 @@ jobs: fi export PATH="$ANDROID_HOME/cmdline-tools/latest/bin:$PATH" yes | sdkmanager --licenses >/dev/null 2>&1 || true - # sdkmanager is a no-op for packages already present in the volume. + # Self-heal a corrupt/incomplete NDK or CMake left by a failed run + # (AGP's auto-installer sometimes leaves a truncated archive -> the + # "Archive is not a ZIP archive" failure). A valid package always has + # a source.properties; if it's missing, wipe the dir so sdkmanager + # reinstalls cleanly. + NDK_VER=27.1.12297006 + CMAKE_VER=3.22.1 + for p in "ndk/$NDK_VER" "cmake/$CMAKE_VER"; do + if [ -d "$ANDROID_HOME/$p" ] && [ ! -f "$ANDROID_HOME/$p/source.properties" ]; then + echo "Removing incomplete $p"; rm -rf "$ANDROID_HOME/$p" + fi + done + # Install everything (incl. NDK + CMake) via sdkmanager, which is + # checksum-verified and robust — instead of letting Gradle/AGP auto- + # download the NDK mid-build. No-op for packages already valid. sdkmanager --install "platform-tools" \ "platforms;android-36" "platforms;android-35" \ - "build-tools;36.0.0" "build-tools;35.0.0" >/dev/null + "build-tools;36.0.0" "build-tools;35.0.0" \ + "ndk;$NDK_VER" "cmake;$CMAKE_VER" >/dev/null echo "$ANDROID_HOME/platform-tools" >> "$GITHUB_PATH" echo "$ANDROID_HOME/cmdline-tools/latest/bin" >> "$GITHUB_PATH" From d7fbb2a15429ff67bf3501c9acf00fa2815af29f Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 8 Jul 2026 21:51:44 +0000 Subject: [PATCH 10/37] Add public /install page with Obtainium button Served unauthenticated at /install: one-tap "Add to Obtainium" deep link, how to get Obtainium, direct-APK fallback to the Forgejo releases, and iPhone Add-to-Home-Screen steps. Detects the visitor's platform and shows it first. Co-Authored-By: Claude Fable 5 --- backend/src/routes/install.ts | 117 ++++++++++++++++++++++++++++++++++ backend/src/server.ts | 2 + 2 files changed, 119 insertions(+) create mode 100644 backend/src/routes/install.ts diff --git a/backend/src/routes/install.ts b/backend/src/routes/install.ts new file mode 100644 index 0000000..0bbbb58 --- /dev/null +++ b/backend/src/routes/install.ts @@ -0,0 +1,117 @@ +import type { FastifyInstance } from "fastify"; + +// Public install landing page. Points Obtainium at the Forgejo repo and gives +// iPhone PWA instructions. Served unauthenticated at /install. +const REPO_URL = "https://git.mowden.top/Beartaria/CampgroundTickets"; +const OBTAINIUM_ADD = `obtainium://add/${REPO_URL}`; +const RELEASES_URL = `${REPO_URL}/releases`; +const OBTAINIUM_GET = "https://github.com/ImranR98/Obtainium/releases/latest"; +const PWA_URL = "https://scan.beartariacampgrounds.com/"; + +export async function installRoutes(app: FastifyInstance): Promise { + app.get("/install", async (_req, reply) => { + reply.type("text/html").send(PAGE); + }); +} + +const PAGE = ` + + + + + +Install Camp Scan + + + +
+
+ +

Install Camp Scan

+

Ticket scanner for Beartaria Campgrounds gate staff

+
+ + +
+ Android +

📲 Install & auto-update via Obtainium

+

Obtainium keeps the app updated straight from our server — no Play Store needed.

+
    +
  1. Don't have Obtainium yet? Download it here and install the APK (you may need to allow "install unknown apps").
  2. +
  3. Then tap the button below — it opens Obtainium with Camp Scan ready to add:
  4. +
+ ➕ Add Camp Scan to Obtainium +

If the button doesn't open Obtainium: open Obtainium → Add App → paste ${REPO_URL} → Add.

+
— or —
+ ⬇︎ Download the APK directly +

Direct installs won't auto-update — Obtainium is recommended.

+
+ + +
+ iPhone & iPad +

🍎 Add to Home Screen

+

No App Store needed — it runs as a full-screen web app.

+
    +
  1. Open this page in Safari (not Chrome): ${PWA_URL}install
  2. +
  3. Tap the Share button, then Add to Home ScreenAdd.
  4. +
  5. Launch Camp Scan from your home screen and allow the camera.
  6. +
+ Open Camp Scan now +
+ +
+

🔑 First launch

+

Open the app, enter the gate PIN, then type your name (recorded with every check-in). You stay signed in for the event.

+
+ +
Beartaria Campgrounds · scan.beartariacampgrounds.com
+
+ + + +`; diff --git a/backend/src/server.ts b/backend/src/server.ts index cd5f362..eda5d68 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -11,6 +11,7 @@ import { authRoutes } from "./routes/auth.js"; import { webhookRoutes } from "./routes/webhook.js"; import { ticketRoutes } from "./routes/tickets.js"; import { testRoutes } from "./routes/test.js"; +import { installRoutes } from "./routes/install.js"; export async function build() { const config = loadConfig(); @@ -30,6 +31,7 @@ export async function build() { await app.register(webhookRoutes); await app.register(ticketRoutes); await app.register(testRoutes); + await app.register(installRoutes); // Serve the exported Expo web build (if present) with SPA fallback. const webDir = config.WEB_DIR ?? join(process.cwd(), "web"); From 774d00ff5b3969a068d44b6e0a0c7af85a942df7 Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 8 Jul 2026 21:58:33 +0000 Subject: [PATCH 11/37] Add public /webhook-doc page documenting the FluentForms webhook Served at /webhook-doc: endpoint + auth header, full field table (name/email, age brackets, ice, parking, donor, idempotency key), example JSON + curl, response codes, and FluentForms feed setup steps. Co-Authored-By: Claude Fable 5 --- backend/src/routes/webhookDoc.ts | 161 +++++++++++++++++++++++++++++++ backend/src/server.ts | 2 + 2 files changed, 163 insertions(+) create mode 100644 backend/src/routes/webhookDoc.ts diff --git a/backend/src/routes/webhookDoc.ts b/backend/src/routes/webhookDoc.ts new file mode 100644 index 0000000..89a1b34 --- /dev/null +++ b/backend/src/routes/webhookDoc.ts @@ -0,0 +1,161 @@ +import type { FastifyInstance } from "fastify"; + +// Public documentation page for the FluentForms → /webhook integration. +const WEBHOOK_URL = "https://scan.beartariacampgrounds.com/webhook"; + +interface Field { + key: string; + req: "required" | "optional"; + type: string; + desc: string; +} + +const FIELDS: Field[] = [ + { key: "name", req: "required", type: "text", desc: "Purchaser's full name." }, + { key: "email", req: "required", type: "email", desc: "Purchaser's email — the QR ticket is sent here." }, + { + key: "submission_id", + req: "optional", + type: "text/number", + 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: "ages_0_3", req: "optional", type: "number", desc: "Headcount ages 0–3. Admitted free — NOT counted toward redeemable tickets." }, + { key: "ages_4_7", req: "optional", type: "number", desc: "Headcount ages 4–7." }, + { key: "ages_8_12", req: "optional", type: "number", desc: "Headcount ages 8–12." }, + { key: "ages_13_17", req: "optional", type: "number", desc: "Headcount ages 13–17." }, + { key: "ages_18_25", req: "optional", type: "number", desc: "Headcount ages 18–25." }, + { key: "ages_26_45", req: "optional", type: "number", desc: "Headcount ages 26–45." }, + { key: "ages_46_64", req: "optional", type: "number", desc: "Headcount ages 46–64." }, + { key: "ages_65", req: "optional", type: "number", desc: "Headcount ages 65+." }, + { 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: "ice_access", req: "optional", type: "yes/no", desc: "Whether they bought ice access. Accepts 1/0, true/false, yes/no." }, + { key: "car_parking", req: "optional", type: "yes/no", desc: "Car parking pass." }, + { key: "rv_parking", req: "optional", type: "yes/no", desc: "RV parking pass." }, + { key: "is_donor", req: "optional", type: "yes/no", desc: "Donor flag." }, + { key: "address", req: "optional", type: "text", desc: "Mailing address." }, + { key: "payment_method", req: "optional", type: "text", desc: "Payment method label." }, +]; + +function esc(s: string): string { + return s.replace(/[&<>]/g, (c) => (c === "&" ? "&" : c === "<" ? "<" : ">")); +} + +export async function webhookDocRoutes(app: FastifyInstance): Promise { + app.get("/webhook-doc", async (_req, reply) => { + reply.type("text/html").send(PAGE); + }); +} + +const rows = FIELDS.map( + (f) => ` + ${f.key} + ${f.req} + ${f.type} + ${esc(f.desc)} + `, +).join(""); + +const exampleJson = esc(`{ + "name": "Jane Bear", + "email": "jane@example.com", + "submission_id": "12345", + "ages_0_3": 2, + "ages_8_12": 3, + "ages_26_45": 2, + "car_parking": "yes", + "ice_access": "yes" +}`); + +const exampleCurl = esc(`curl -X POST ${WEBHOOK_URL} \\ + -H "Content-Type: application/json" \\ + -H "X-Webhook-Secret: " \\ + -d '{"name":"Jane Bear","email":"jane@example.com","submission_id":"12345","ages_26_45":2,"ice_access":"yes"}'`); + +const PAGE = ` + + + + + +Camp Scan — Webhook + + + +
+

🐻 Camp Scan — Purchase Webhook

+

How the FluentForms ticket checkout notifies the ticketing backend to create a ticket and email the QR code.

+ +
+
Endpoint  POST ${WEBHOOK_URL}
+
Auth header  X-Webhook-Secret: <the shared WEBHOOK_SECRET>
+
Body format  JSON (application/json) or form-encoded — both accepted.
+
+ +

What it does

+

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: "2026 Beartaria Campgrounds Tickets"). The total number of redeemable tickets is the sum of the age-bracket counts, excluding ages 0–3 (who are free).

+ +

Fields

+ + + ${rows} +
KeyRequiredTypeDescription
+

At least one non-zero age-bracket count is required (otherwise there are no tickets to issue). Booleans accept 1/0, true/false, or yes/no.

+ +

Idempotency

+

Send a stable submission_id. If the backend sees the same one again it returns {"status":"duplicate"} without creating a second ticket or re-sending email — so FluentForms retries and accidental double-submits are safe.

+ +

Example payload

+
${exampleJson}
+

This issues 5 redeemable tickets (3×8–12 + 2×26–45; the two 0–3 are free), with car parking and 3 ice bags.

+ +

Test with curl

+
${exampleCurl}
+ +

Responses

+ + + + + + + + + +
StatusBodyMeaning
200{"status":"created","code":"BC26-…","emailSent":true}Ticket created and emailed.
200{"status":"duplicate","code":"BC26-…"}Same submission already processed — no-op.
400{"error":"missing_fields"} / "no_tickets"Missing name/email, or no age counts.
401{"error":"unauthorized"}Missing or wrong X-Webhook-Secret.
502{"status":"created","emailSent":false,…}Ticket row created but the email failed — re-send from the admin app.
+ +

FluentForms setup

+
    +
  1. On the ticket form: Settings & Integrations → Webhook → Add Webhook.
  2. +
  3. Request URL: ${WEBHOOK_URL}
  4. +
  5. Request Method: POST  ·  Format: JSON
  6. +
  7. Request Headers: add X-Webhook-Secret = the shared secret.
  8. +
  9. Request Body: map each form field to the keys in the table above.
  10. +
  11. Save, then submit a test purchase and confirm the QR email arrives.
  12. +
+ +
Beartaria Campgrounds · scan.beartariacampgrounds.com
+
+ +`; diff --git a/backend/src/server.ts b/backend/src/server.ts index eda5d68..1b662db 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -12,6 +12,7 @@ import { webhookRoutes } from "./routes/webhook.js"; import { ticketRoutes } from "./routes/tickets.js"; import { testRoutes } from "./routes/test.js"; import { installRoutes } from "./routes/install.js"; +import { webhookDocRoutes } from "./routes/webhookDoc.js"; export async function build() { const config = loadConfig(); @@ -32,6 +33,7 @@ export async function build() { await app.register(ticketRoutes); await app.register(testRoutes); await app.register(installRoutes); + await app.register(webhookDocRoutes); // Serve the exported Expo web build (if present) with SPA fallback. const webDir = config.WEB_DIR ?? join(process.cwd(), "web"); From fb2fdcb6b8d48f823a967036f0eb047a397072bd Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 8 Jul 2026 22:09:25 +0000 Subject: [PATCH 12/37] Add secret-gated public donor-eligibility lookup for checkout discount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/public/donor-eligibility?key=&email= returns only {eligible, tier} (member/donor) — no names or dollar amounts — gated by PUBLIC_LOOKUP_SECRET, rate-limited (30/min), and CORS-restricted to PUBLIC_LOOKUP_ORIGIN. Lets the FluentForms checkout unlock a donor discount by email. Docs + ready-to-paste form snippet in docs/fluentforms-donor-discount.md. Co-Authored-By: Claude Fable 5 --- backend/src/config.ts | 6 ++ backend/src/routes/publicLookup.ts | 60 +++++++++++++++++ backend/src/server.ts | 2 + docs/fluentforms-donor-discount.md | 101 +++++++++++++++++++++++++++++ 4 files changed, 169 insertions(+) create mode 100644 backend/src/routes/publicLookup.ts create mode 100644 docs/fluentforms-donor-discount.md diff --git a/backend/src/config.ts b/backend/src/config.ts index 3b98337..ab39649 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -21,6 +21,12 @@ const schema = z.object({ // sends a boolean (not an explicit bag count). ICE_BAGS_DEFAULT: z.coerce.number().default(3), + // Public donor-eligibility lookup (for the FluentForms checkout discount). + // Disabled unless a secret is set. Returns only eligibility + tier, never + // names or dollar amounts. Rate-limited + CORS-restricted. + PUBLIC_LOOKUP_SECRET: z.string().optional(), + PUBLIC_LOOKUP_ORIGIN: z.string().default("https://tickets.beartariacampgrounds.com"), + // 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). ENABLE_TEST_PAGE: z diff --git a/backend/src/routes/publicLookup.ts b/backend/src/routes/publicLookup.ts new file mode 100644 index 0000000..8c283d5 --- /dev/null +++ b/backend/src/routes/publicLookup.ts @@ -0,0 +1,60 @@ +import { timingSafeEqual } from "node:crypto"; +import type { FastifyInstance } from "fastify"; + +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); +} + +/** + * Public, secret-gated donor-eligibility lookup for the FluentForms checkout. + * The form's JS calls this on email blur to decide whether to unlock a donor + * discount. Deliberately minimal: returns only { eligible, tier } — never + * names or dollar amounts — so even with the (page-source-visible) secret it + * can't leak donor financials. Rate-limited and CORS-restricted. + */ +export async function publicLookupRoutes(app: FastifyInstance): Promise { + const cfg = app.ctx.config; + const origin = cfg.PUBLIC_LOOKUP_ORIGIN; + + const cors = (reply: any) => { + reply.header("Access-Control-Allow-Origin", origin); + reply.header("Vary", "Origin"); + reply.header("Access-Control-Allow-Methods", "GET, OPTIONS"); + }; + + // Preflight (in case the form sends one). + app.options("/api/public/donor-eligibility", async (_req, reply) => { + cors(reply); + return reply.code(204).send(); + }); + + app.get( + "/api/public/donor-eligibility", + { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, + async (req, reply) => { + cors(reply); + // Disabled unless configured. + if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) { + return reply.code(404).send({ error: "not_available" }); + } + const { key, email } = (req.query ?? {}) as { key?: string; email?: string }; + if (!key || !safeEqual(key, cfg.PUBLIC_LOOKUP_SECRET)) { + return reply.code(401).send({ error: "unauthorized" }); + } + const addr = String(email ?? "").trim(); + if (!addr) return { eligible: false, tier: null }; + + try { + const d = await app.ctx.donors.lookup(addr); + const tier = d.found ? (d.isMember ? "member" : "donor") : null; + return { eligible: d.found, tier }; + } catch { + // Fail closed — no discount rather than an error the form can't handle. + return { eligible: false, tier: null }; + } + }, + ); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 1b662db..79fba69 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -13,6 +13,7 @@ import { ticketRoutes } from "./routes/tickets.js"; import { testRoutes } from "./routes/test.js"; import { installRoutes } from "./routes/install.js"; import { webhookDocRoutes } from "./routes/webhookDoc.js"; +import { publicLookupRoutes } from "./routes/publicLookup.js"; export async function build() { const config = loadConfig(); @@ -34,6 +35,7 @@ export async function build() { await app.register(testRoutes); await app.register(installRoutes); await app.register(webhookDocRoutes); + await app.register(publicLookupRoutes); // Serve the exported Expo web build (if present) with SPA fallback. const webDir = config.WEB_DIR ?? join(process.cwd(), "web"); diff --git a/docs/fluentforms-donor-discount.md b/docs/fluentforms-donor-discount.md new file mode 100644 index 0000000..e45823a --- /dev/null +++ b/docs/fluentforms-donor-discount.md @@ -0,0 +1,101 @@ +# FluentForms → donor discount lookup + +FluentForms has no native way to query an external database from a field. This +wires it up with a small Custom JS block that calls our secret-gated endpoint +and unlocks a discount when the entered email belongs to a donor/member. + +## Endpoint + +``` +GET https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=&email= +``` + +- `key` = the value of `PUBLIC_LOOKUP_SECRET` (set in the backend `.env`). +- Returns minimal JSON — never names or dollar amounts: + - `{"eligible": true, "tier": "member"}` + - `{"eligible": true, "tier": "donor"}` + - `{"eligible": false, "tier": null}` +- Rate-limited (30/min/IP) and CORS-restricted to `PUBLIC_LOOKUP_ORIGIN` + (default `https://tickets.beartariacampgrounds.com`). + +> The secret is visible in page source, so treat this as *deterrence, not +> security*. It only gates a discount and reveals a yes/no + tier, so the blast +> radius is small. Rotate the secret by changing `PUBLIC_LOOKUP_SECRET` and +> redeploying. + +## Form setup + +1. On the ticket form add a **Custom HTML** element (or use FluentForms Pro's + custom JS). Give your email field a known name (default FF `email`). +2. Decide the discount mechanism. Two common options: + - **Coupon:** configure a coupon in the form's payment settings; the JS + auto-fills + applies it for eligible emails. + - **Conditional price:** add a hidden field (e.g. `donor_tier`) and use + FluentForms conditional logic to show a discounted payment option when it + equals `member`/`donor`. +3. Paste the snippet below into the Custom HTML element, editing the marked + constants and the `applyDiscount()` body to match your form. + +## Snippet + +```html +
+ +``` + +## Test + +``` +curl "https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=&email=adam21stevens@gmail.com" +# -> {"eligible":true,"tier":"member"} +``` From ba5cb8a9fbb269d00c9674cd273948ef970728e9 Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 8 Jul 2026 22:13:00 +0000 Subject: [PATCH 13/37] docs: tailor donor-discount snippet for FluentForms conditional pricing Hidden donor_tier field (regular/donor/member) driven by the email lookup; payment options shown via FF conditional logic. Uses the native value setter + input/change dispatch so FF's Vue model registers the programmatic change. Co-Authored-By: Claude Fable 5 --- docs/fluentforms-donor-discount.md | 84 +++++++++++++++++++----------- 1 file changed, 54 insertions(+), 30 deletions(-) diff --git a/docs/fluentforms-donor-discount.md b/docs/fluentforms-donor-discount.md index e45823a..0fe77d5 100644 --- a/docs/fluentforms-donor-discount.md +++ b/docs/fluentforms-donor-discount.md @@ -23,69 +23,89 @@ GET https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key= radius is small. Rotate the secret by changing `PUBLIC_LOOKUP_SECRET` and > redeploying. -## Form setup +## Form setup (conditional pricing) -1. On the ticket form add a **Custom HTML** element (or use FluentForms Pro's - custom JS). Give your email field a known name (default FF `email`). -2. Decide the discount mechanism. Two common options: - - **Coupon:** configure a coupon in the form's payment settings; the JS - auto-fills + applies it for eligible emails. - - **Conditional price:** add a hidden field (e.g. `donor_tier`) and use - FluentForms conditional logic to show a discounted payment option when it - equals `member`/`donor`. -3. Paste the snippet below into the Custom HTML element, editing the marked - constants and the `applyDiscount()` body to match your form. +The idea: a **hidden field** `donor_tier` holds `regular` / `donor` / `member`. +The JS sets it from the email lookup, and your payment options are shown/hidden +by FluentForms conditional logic based on its value. + +1. **Hidden field.** Add a *Hidden Field*, name it exactly `donor_tier`, default + value `regular`. +2. **Email field.** Note its name (default `email`). +3. **Payment options.** Set up two payment items (or two options of a + multiple-choice payment field) — a regular price and a discounted price — and + give each **conditional logic**: + - **Regular price:** show when `donor_tier` **is** `regular` + - **Donor price:** show when `donor_tier` **is** `donor` **OR** `donor_tier` + **is** `member` (add both rules with "match any"). +4. **Custom HTML.** Add a *Custom HTML* element and paste the snippet below, + setting `KEY` to your `PUBLIC_LOOKUP_SECRET` (and `EMAIL_SELECTOR` if your + email field isn't named `email`). + +> FluentForms is Vue-driven, so a plain `input.value = …` won't update its +> model and conditional logic won't fire. The snippet uses the native value +> setter + dispatches `input`/`change`, which is the reliable way to make FF +> notice a programmatic change. Test on your form; if conditional logic still +> doesn't react, tell me your FF version and I'll adapt. ## Snippet ```html -
+
+``` + +### Test + +``` +curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=&email=" +# -> {"vouchers":2} (>= $1000 since the cutoff) +``` + +> **Heads-up on the cutoff:** with `VOUCHER_SINCE=2025-09-04`, everyone currently +> returns `0` because the donation data in NocoDB ends **2025-05-22** — there are +> no transactions after the cutoff yet. Adjust `VOUCHER_SINCE` (or wait for new +> donations to sync) so the window matches real giving. From 718d1515b0be687c437b7ec026d7ea8ba41b06dd Mon Sep 17 00:00:00 2001 From: Hank Date: Fri, 10 Jul 2026 06:36:39 +0000 Subject: [PATCH 18/37] docs: dedicated ticket-voucher lookup page under fluent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/fluentforms-donor-discount.md | 94 +--------------------- docs/fluentforms-ticket-vouchers.md | 116 ++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 91 deletions(-) create mode 100644 docs/fluentforms-ticket-vouchers.md diff --git a/docs/fluentforms-donor-discount.md b/docs/fluentforms-donor-discount.md index 296d7f2..6872a0f 100644 --- a/docs/fluentforms-donor-discount.md +++ b/docs/fluentforms-donor-discount.md @@ -147,94 +147,6 @@ member/donor distinction. ## Ticket-voucher entitlement -Returns how many **free tickets** a donor has earned from their giving, based on -donations **on/after a cutoff date** (default `2025-09-04` — "9/4 last year"). - -``` -GET https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=&email= --> {"vouchers": 0} | {"vouchers": 1} | {"vouchers": 2} -``` - -**Rules** (donations summed on/after the cutoff): - -| Total since cutoff | Vouchers | -|---|---| -| ≥ $1000 | 2 | -| ≥ $400 | 1 | -| otherwise | 0 | - -**Config** (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 is computed from the **dated transaction tables** (online + offline) -> — the master-list rollups have no dates. Only `Paid` transactions count. - -### Form snippet - -Displays the voucher count and (optionally) sets a hidden field / caps a -quantity. Same pattern as the discount lookup — paste into a Custom HTML element -and set `KEY`. - -```html -
- -``` - -### Test - -``` -curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=&email=" -# -> {"vouchers":2} (>= $1000 since the cutoff) -``` - -> **Heads-up on the cutoff:** with `VOUCHER_SINCE=2025-09-04`, everyone currently -> returns `0` because the donation data in NocoDB ends **2025-05-22** — there are -> no transactions after the cutoff yet. Adjust `VOUCHER_SINCE` (or wait for new -> donations to sync) so the window matches real giving. +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). diff --git a/docs/fluentforms-ticket-vouchers.md b/docs/fluentforms-ticket-vouchers.md new file mode 100644 index 0000000..6f079a5 --- /dev/null +++ b/docs/fluentforms-ticket-vouchers.md @@ -0,0 +1,116 @@ +# 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=&email= +``` + +Returns only the count — never names or dollar amounts: + +```json +{ "vouchers": 0 } // or 1, or 2 +``` + +- `key` = the value of `PUBLIC_LOOKUP_SECRET` (set in the backend `.env`). +- `email` = the donor's email (URL-encoded). +- Rate-limited (30 requests / minute / IP) and CORS-restricted to + `PUBLIC_LOOKUP_ORIGIN` (default `https://tickets.beartariacampgrounds.com`). + +> 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 +
+ +``` + +## Test + +``` +curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=&email=" +# >= $1000 since cutoff -> {"vouchers":2} +# >= $400 since cutoff -> {"vouchers":1} +# otherwise -> {"vouchers":0} +``` + +Related: [`fluentforms-donor-discount.md`](./fluentforms-donor-discount.md) — the +companion donor-discount eligibility lookup (same key / CORS / rate limit). From b7c77afe02becc51d37a9142dd2009cefead357a Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 13 Jul 2026 03:23:06 +0000 Subject: [PATCH 19/37] 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 --- .env.example | 8 +- app/app/admin.tsx | 13 ++- app/app/index.tsx | 30 +++++- app/lib/api.ts | 6 +- backend/src/config.ts | 8 +- backend/src/fields.ts | 104 +++++++++++--------- backend/src/routes/test.ts | 45 +++++---- backend/src/routes/tickets.ts | 18 +++- backend/src/routes/webhook.ts | 164 +++++++++++++++++++++---------- backend/src/routes/webhookDoc.ts | 95 +++++++++--------- backend/src/test/fakeNocodb.ts | 5 +- backend/src/test/fields.test.ts | 33 ++++--- backend/src/test/redeem.test.ts | 22 ++--- backend/src/ticketService.ts | 19 +++- 14 files changed, 360 insertions(+), 210 deletions(-) diff --git a/.env.example b/.env.example index edaaaef..2fe3055 100644 --- a/.env.example +++ b/.env.example @@ -17,8 +17,10 @@ NOCODB_DONORS_TABLE_ID= NOCODB_DONOR_ONLINE_TABLE_ID= NOCODB_DONOR_OFFLINE_TABLE_ID= -# Ice bags granted when a purchase includes ice but the webhook sends only a boolean -ICE_BAGS_DEFAULT=3 +# Ice: form sells 1-4 ice tickets at $20 each; one ticket = 3 bags. The webhook +# 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 @@ -33,7 +35,7 @@ ENABLE_TEST_PAGE=false # MailerSend MAILERSEND_API_TOKEN= -MAIL_FROM_EMAIL=tickets@beartariacampgrounds.com +MAIL_FROM_EMAIL=info@beartariacampgrounds.com MAIL_FROM_NAME=Beartaria Campgrounds # Shared secret FluentForms sends in the X-Webhook-Secret header (long random string) diff --git a/app/app/admin.tsx b/app/app/admin.tsx index fd9c121..f492fc9 100644 --- a/app/app/admin.tsx +++ b/app/app/admin.tsx @@ -201,11 +201,14 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti }, [ticket.redeemed, showHistory]); const tags: string[] = []; - if (ticket.extras.carParking) tags.push("🚗 Car"); - if (ticket.extras.rvParking) tags.push("🚐 RV"); - if (ticket.extras.iceAccess) tags.push("🧊 Ice"); - if (ticket.extras.isDonor) tags.push("⭐ Donor"); - if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} free`); + const e = ticket.extras; + if (e.donorTier === "member") tags.push("🐻 Member"); + else if (e.isDonor) tags.push("⭐ Donor"); + 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.freeUnder5 > 0) tags.push(`👶 ${e.freeUnder5} free`); return ( diff --git a/app/app/index.tsx b/app/app/index.tsx index 851d9c7..297e1d9 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -259,6 +259,7 @@ export default function ScannerScreen() { {ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining + )} @@ -345,11 +346,14 @@ function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticke function ExtrasRow({ ticket }: { ticket: TicketView }) { const tags: string[] = []; - if (ticket.extras.carParking) tags.push("🚗 Car parking"); - if (ticket.extras.rvParking) tags.push("🚐 RV parking"); - if (ticket.extras.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`); - if (ticket.extras.isDonor) tags.push("⭐ Donor"); - if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} under 4 (free)`); + const e = ticket.extras; + if (e.donorTier === "member") tags.push("🐻 Member"); + else if (e.isDonor) tags.push("⭐ Donor"); + if (e.carParking) tags.push("🚗 Car parking"); + if (e.rvParking) tags.push("🚐 RV parking"); + if (e.utv) tags.push("🏍️ UTV/ATV"); + if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`); + if (e.freeUnder5 > 0) tags.push(`👶 ${e.freeUnder5} under 5 (free)`); if (!tags.length) return null; return ( @@ -362,6 +366,19 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) { ); } +function AdultNames({ names }: { names: string[] }) { + if (!names.length) return null; + return ( + + {names.map((n, i) => ( + + {n} + + ))} + + ); +} + function ConfirmCard({ ticket, isIce, @@ -393,6 +410,7 @@ function ConfirmCard({ {remaining} of {total} {unit} remaining {redeemed} already redeemed + {!isIce && } {!isIce && } {isIce && total === 0 && This ticket did not prepay for ice.} @@ -489,6 +507,8 @@ const styles = StyleSheet.create({ 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 }, + namesBox: { marginTop: 12, alignItems: "center", gap: 3 }, + nameLine: { color: "#fff", fontSize: 18, fontWeight: "600", textAlign: "center" }, 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" }, diff --git a/app/lib/api.ts b/app/lib/api.ts index 17f2bea..84dd309 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -25,12 +25,16 @@ export interface TicketView { redeemed: number; remaining: number; ice: ResourceCount; + adultNames: string[]; extras: { carParking: boolean; rvParking: boolean; + utv: boolean; iceAccess: boolean; isDonor: boolean; - freeUnder4: number; + donorTier: string; + vouchers: number; + freeUnder5: number; }; ages: { bracket: string; count: number; free: boolean }[]; } diff --git a/backend/src/config.ts b/backend/src/config.ts index b8941ef..df123e5 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -17,9 +17,11 @@ const schema = z.object({ NOCODB_DONOR_ONLINE_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 - // sends a boolean (not an explicit bag count). - ICE_BAGS_DEFAULT: z.coerce.number().default(3), + // Ice: the form sells 1-4 ice "tickets" at $20 each; one ticket = 3 bags. + // The webhook reads payment_ice as either a ticket count (1-4) or a dollar + // 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). // Disabled unless a secret is set. Returns only eligibility + tier, never diff --git a/backend/src/fields.ts b/backend/src/fields.ts index c34bff7..5c669e8 100644 --- a/backend/src/fields.ts +++ b/backend/src/fields.ts @@ -1,48 +1,39 @@ /** - * Mapping between the NocoDB "2026 Campground Tickets" table columns, the - * webhook payload keys, and the view we return to the app. - * - * 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. + * Mapping for the "2026 Campground Tickets" NocoDB table, matching the 2026 + * FluentForms "Tickets 2026" schema. Change titles here if the columns differ. */ export const COL = { 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", address: "Address", 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", rvParking: "RV Parking", + utv: "UTV", iceAccess: "Ice Access", paymentMethod: "Payment Method", - // Columns this system adds to the table: + // Columns this system manages: code: "Ticket Code", redeemed: "Redeemed", submissionKey: "SubmissionKey", lastScanAt: "LastScanAt", - iceTotal: "Ice Total", // prepaid ice bags - iceRedeemed: "Ice Redeemed", // bags picked up + iceTotal: "Ice Total", + iceRedeemed: "Ice Redeemed", } 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 & { Id: number }; function num(v: unknown): number { @@ -57,23 +48,40 @@ function bool(v: unknown): boolean { return false; } -/** Total redeemable tickets = sum of age brackets minus the free ones. */ +/** + * Total scannable tickets = everyone except kids 0-4 (who are free): + * adults + youth (13-16) + kids 10-12 + kids 5-9. + */ export function computeTotal(rec: NocoRecord): number { - let total = 0; - for (const col of AGE_COLUMNS) { - if (FREE_AGE_COLUMNS.includes(col)) continue; - total += num(rec[col]); - } - return total; + return num(rec[COL.adults]) + num(rec[COL.youth]) + num(rec[COL.kids12]) + num(rec[COL.kids9]); } -/** Per-bracket breakdown for display. */ +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 }[] { - return AGE_COLUMNS.map((col) => ({ - bracket: col.replace(/^Ages /, ""), - count: num(rec[col]), - free: FREE_AGE_COLUMNS.includes(col), - })).filter((b) => b.count > 0); + return [ + { bracket: "Adults", count: num(rec[COL.adults]), free: false }, + { bracket: "Youth 13-16", count: num(rec[COL.youth]), free: false }, + { bracket: "Kids 10-12", count: num(rec[COL.kids12]), free: false }, + { bracket: "Kids 5-9", count: num(rec[COL.kids9]), free: false }, + { bracket: "Kids 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 { @@ -90,20 +98,20 @@ export interface TicketView { redeemed: number; remaining: number; ice: ResourceCount; + adultNames: string[]; extras: { carParking: boolean; rvParking: boolean; + utv: boolean; iceAccess: boolean; isDonor: boolean; - freeUnder4: number; + donorTier: string; + vouchers: number; + freeUnder5: number; }; ages: { bracket: string; count: number; free: boolean }[]; } -export function computeIceTotal(rec: NocoRecord): number { - return num(rec[COL.iceTotal]); -} - export function toView(rec: NocoRecord): TicketView { const total = computeTotal(rec); const redeemed = num(rec[COL.redeemed]); @@ -121,12 +129,16 @@ export function toView(rec: NocoRecord): TicketView { redeemed: iceRedeemed, remaining: Math.max(0, iceTotal - iceRedeemed), }, + adultNames: parseAdultNames(rec), extras: { carParking: bool(rec[COL.carParking]), rvParking: bool(rec[COL.rvParking]), + utv: bool(rec[COL.utv]), iceAccess: bool(rec[COL.iceAccess]), isDonor: bool(rec[COL.isDonor]), - freeUnder4: num(rec["Ages 0-3"]), + donorTier: String(rec[COL.donorTier] ?? ""), + vouchers: num(rec[COL.vouchers]), + freeUnder5: num(rec[COL.kids4]), }, ages: ageBreakdown(rec), }; diff --git a/backend/src/routes/test.ts b/backend/src/routes/test.ts index 4e51d1e..4118f79 100644 --- a/backend/src/routes/test.ts +++ b/backend/src/routes/test.ts @@ -7,57 +7,68 @@ interface Persona { key: string; name: string; email: string; - ages: Record; + adultNames?: string[]; + counts: { adults: number; youth: number; kids12: number; kids9: number; kids4: number }; iceBags?: number; carParking?: boolean; rvParking?: boolean; + utv?: boolean; isDonor?: boolean; + donorTier?: string; exhaust?: boolean; // pre-redeem all tickets so it scans as "exhausted" 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. const PERSONAS: Persona[] = [ { key: "solo", name: "Solo Sam", 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.", }, { key: "family", name: "Family Fay", 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, 0, 0, 3, 2), // 2 adults + 3 kids(5-9) = 5 scannable; 2 kids 0-4 free iceBags: 3, carParking: true, 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.", + "5 tickets (2 adults + 3 kids 5-9; two 0-4 free), car parking, 3 ice bags. Check-in a few at a time to test QR reuse + see adult names; then Ice mode.", }, { key: "donor2", name: "Donor Dan", email: "donor@example.test", - ages: { "Ages 26-45": 2 }, + adultNames: ["Donor Dan", "Donna Dan"], + counts: C(2), rvParking: true, + utv: true, isDonor: true, + donorTier: "member", 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", name: "Ice Ike", email: "ice@test.beartaria", - ages: { "Ages 18-25": 1 }, - iceBags: 3, - blurb: "1 ticket + 3 ice bags. Ice mode → grab all 3 at once, then scan again → exhausted.", + adultNames: ["Ice Ike"], + counts: C(1), + iceBags: 6, // 2 ice tickets + blurb: "1 ticket + 6 ice bags (2 ice tickets). Ice mode → grab bags, then scan again → exhausted.", }, { key: "exhausted", name: "Done Dora", email: "done@test.beartaria", - ages: { "Ages 26-45": 2 }, + counts: C(2), exhaust: true, blurb: "2 tickets, already fully redeemed. Check-in mode → red 'exhausted'.", }, @@ -74,22 +85,22 @@ export async function testRoutes(app: FastifyInstance): Promise { for (const p of PERSONAS) { const result = await createTicket(app.ctx, { name: p.name, + adultNames: p.adultNames, email: p.email, - ages: p.ages, + counts: p.counts, iceBags: p.iceBags, carParking: p.carParking, rvParking: p.rvParking, + utv: p.utv, isDonor: p.isDonor, + donorTier: p.donorTier, submissionKey: `test:${p.key}`, }); // Keep the "exhausted" persona fully redeemed on every load so its state - // is deterministic (compute the total from the persona's own age counts, - // since NocoDB's create response may not echo them back). + // is deterministic (total = scannable count from the persona's counts). if (p.exhaust) { - const total = Object.entries(p.ages) - .filter(([col]) => col !== "Ages 0-3") - .reduce((s, [, n]) => s + n, 0); - await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: total }); + const { adults, youth, kids12, kids9 } = p.counts; + await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth + kids12 + kids9 }); } cards.push({ code: result.code, diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index c99c2b8..eaa2a67 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -167,11 +167,16 @@ export async function ticketRoutes(app: FastifyInstance): Promise { schema: { body: { type: "object", - required: ["name", "ages"], + required: ["name"], properties: { name: { type: "string", minLength: 1 }, 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" }, }, }, @@ -182,8 +187,15 @@ export async function ticketRoutes(app: FastifyInstance): Promise { const submissionKey = `manual:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`; const result = await createTicket(app.ctx, { name: b.name, + adultNames: b.adultNames, 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, }); if (b.sendEmail && b.email && !app.ctx.mailer.isBlockedRecipient(b.email)) { diff --git a/backend/src/routes/webhook.ts b/backend/src/routes/webhook.ts index b1352a8..9d96961 100644 --- a/backend/src/routes/webhook.ts +++ b/backend/src/routes/webhook.ts @@ -1,6 +1,6 @@ import { createHash, timingSafeEqual } from "node:crypto"; import type { FastifyInstance } from "fastify"; -import { AGE_COLUMNS, toBool, toNumber } from "../fields.js"; +import { toBool, toNumber } from "../fields.js"; import { createTicket } from "../ticketService.js"; import { renderQrPng } from "../services/qrcode.js"; @@ -11,18 +11,48 @@ function safeEqual(a: string, b: string): boolean { return timingSafeEqual(ba, bb); } -// Map webhook payload keys -> NocoDB age-column titles. Keys are what you map -// the FluentForms fields to in the webhook feed. -const AGE_KEY_TO_COL: Record = { - ages_0_3: "Ages 0-3", - 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+", -}; +/** Read a FluentForms compound name field, given as a nested object + * (`names: {first_name,...}`) or flattened bracket keys (`names[first_name]`). */ +function nameGroup(body: Record, base: string): string { + const obj = body[base]; + let first: any, middle: any, last: any; + if (obj && typeof obj === "object") { + ({ first_name: first, middle_name: middle, last_name: last } = obj); + } else { + first = body[`${base}[first_name]`]; + middle = body[`${base}[middle_name]`]; + last = body[`${base}[last_name]`]; + } + return [first, middle, last] + .map((x) => (x == null ? "" : String(x).trim())) + .filter(Boolean) + .join(" "); +} + +/** Read an item_quantity / payment field's numeric value (handles nested + * objects like {quantity} / {value} and money strings like "$40.00"). */ +function qty(v: any): number { + if (v == null || v === "") return 0; + if (typeof v === "object") return toNumber(v.quantity ?? v.value ?? v.item_quantity ?? v.amount ?? 0); + if (typeof v === "string") return toNumber(v.replace(/[^0-9.\-]/g, "")); + return toNumber(v); +} + +/** A payment/extra field counts as "selected" if it has a meaningful value. + * Donor (free) items can be $0, so a non-empty, non-"no"/"0" value also counts. */ +function selected(v: any): boolean { + if (v == null || v === "") return false; + if (typeof v === "object") { + if ("selected" in v) return toBool((v as any).selected); + return qty(v) > 0 || Object.keys(v).length > 0; + } + const s = String(v).trim().toLowerCase(); + if (!s || s === "no" || s === "0" || s === "$0" || s === "$0.00" || s === "false" || s === "none") return false; + return true; +} + +// Adult name field bases, in order (purchaser first). +const ADULT_NAME_BASES = ["names", "names_1", "names_2", "names_3", "names_4", "names_5", "names_6", "names_7", "names_8", "names_9"]; export async function webhookRoutes(app: FastifyInstance): Promise { const handler = async (req: any, reply: any) => { @@ -31,57 +61,87 @@ export async function webhookRoutes(app: FastifyInstance): Promise { return reply.code(401).send({ error: "unauthorized" }); } - const body = (req.body ?? {}) as Record; - const name = String(body.name ?? "").trim(); + const body = (req.body ?? {}) as Record; + + // Purchaser = the first adult name group; fall back to a plain `name` field. + const name = nameGroup(body, "names") || String(body.name ?? "").trim(); const email = String(body.email ?? "").trim(); - if (!name || !email) { - return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" }); + if (!name) { + return reply.code(400).send({ error: "missing_fields", detail: "purchaser name is required" }); } - // Build age-bracket counts from whichever keys were provided. - const ages: Record = {}; - for (const [key, col] of Object.entries(AGE_KEY_TO_COL)) { - if (body[key] !== undefined && body[key] !== null && body[key] !== "") { - ages[col] = toNumber(body[key]); - } - } - const anyAge = AGE_COLUMNS.some((c) => (ages[c] ?? 0) > 0); - if (!anyAge) { - return reply.code(400).send({ error: "no_tickets", detail: "no age-bracket counts provided" }); + // Adult attendee names (non-empty groups, in order). + const adultNames = ADULT_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean); + + // Attendee counts. + const counts = { + adults: qty(body.item_quantity_adult_ticket_reg) + qty(body.item_quantity_adult_ticket_donor), + youth: qty(body.item_quantity_youth_ticket_reg) + qty(body.item_quantity_youth_ticket_donor), + kids12: qty(body.item_quantity_kids_12), + kids9: qty(body.item_quantity_kids_9), + kids4: qty(body.item_quantity_kids_4), + }; + const scannable = counts.adults + counts.youth + counts.kids12 + counts.kids9; + if (scannable <= 0) { + // Nothing to check in at the gate. Log the payload so we can calibrate. + req.log.warn({ body }, "webhook: no scannable tickets in submission"); + return reply.code(400).send({ error: "no_tickets", detail: "no scannable tickets (adults/youth/kids 5+)" }); } - // Idempotency key: prefer a stable submission id, else hash the content. - const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id; + // 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?" + const vouchers = qty(body.vouchers); + + // 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 either a ticket count (1-4) or a dollar total + // ($20-$80). One ice ticket = ICE_BAGS_PER_TICKET bags. + const iceRaw = qty(body.payment_ice); + const iceTickets = iceRaw >= app.ctx.config.ICE_TICKET_PRICE ? Math.round(iceRaw / app.ctx.config.ICE_TICKET_PRICE) : Math.round(iceRaw); + const iceBags = Math.max(0, iceTickets) * app.ctx.config.ICE_BAGS_PER_TICKET; + const iceAccess = iceBags > 0 || selected(body.input_radio_7); + + const address = + body.address_1 && typeof body.address_1 === "object" + ? Object.values(body.address_1).filter(Boolean).join(", ") + : body.address_1 !== undefined + ? String(body.address_1) + : undefined; + + // 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(`${email}|${name}|${JSON.stringify(ages)}`) + .update(`${email}|${name}|${JSON.stringify(counts)}`) .digest("hex") .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>; try { result = await createTicket(app.ctx, { name, + adultNames, email, - address: body.address !== undefined ? String(body.address) : undefined, - isDonor: body.is_donor !== undefined ? toBool(body.is_donor) : undefined, - carParking: body.car_parking !== undefined ? toBool(body.car_parking) : undefined, - rvParking: body.rv_parking !== undefined ? toBool(body.rv_parking) : undefined, - iceAccess: body.ice_access !== undefined ? toBool(body.ice_access) : undefined, + address, + isDonor, + donorTier, + vouchers, + counts, + carParking, + rvParking, + utv, + iceAccess, iceBags, paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined, - ages, submissionKey, }); } catch (e: any) { @@ -93,9 +153,13 @@ export async function webhookRoutes(app: FastifyInstance): Promise { return { status: "duplicate", code: result.code }; } - // Send the ticket email. If it fails, the row already exists — report 502 - // so the failure is visible in FluentForms' delivery log; the ticket can be - // re-sent later via POST /api/tickets/:code/resend-email. + // Send the ticket QR email (FluentForms sends the receipt separately). If it + // fails, the row already exists — report 502 so it's visible in the feed + // 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)) { req.log.warn({ email }, "webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send"); return { status: "created", code: result.code, emailSent: false, emailSkipped: "trial_restriction" }; @@ -103,13 +167,11 @@ export async function webhookRoutes(app: FastifyInstance): Promise { try { 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({ toEmail: email, toName: name, code: result.code, - quantity, + quantity: scannable, qrPng: qr, }); } catch (e: any) { diff --git a/backend/src/routes/webhookDoc.ts b/backend/src/routes/webhookDoc.ts index 89a1b34..8f7bd8a 100644 --- a/backend/src/routes/webhookDoc.ts +++ b/backend/src/routes/webhookDoc.ts @@ -11,29 +11,26 @@ interface Field { } const FIELDS: Field[] = [ - { key: "name", req: "required", type: "text", desc: "Purchaser's full name." }, - { key: "email", req: "required", type: "email", desc: "Purchaser's email — the QR ticket is sent here." }, - { - key: "submission_id", - req: "optional", - type: "text/number", - 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: "ages_0_3", req: "optional", type: "number", desc: "Headcount ages 0–3. Admitted free — NOT counted toward redeemable tickets." }, - { key: "ages_4_7", req: "optional", type: "number", desc: "Headcount ages 4–7." }, - { key: "ages_8_12", req: "optional", type: "number", desc: "Headcount ages 8–12." }, - { key: "ages_13_17", req: "optional", type: "number", desc: "Headcount ages 13–17." }, - { key: "ages_18_25", req: "optional", type: "number", desc: "Headcount ages 18–25." }, - { key: "ages_26_45", req: "optional", type: "number", desc: "Headcount ages 26–45." }, - { key: "ages_46_64", req: "optional", type: "number", desc: "Headcount ages 46–64." }, - { key: "ages_65", req: "optional", type: "number", desc: "Headcount ages 65+." }, - { 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: "ice_access", req: "optional", type: "yes/no", desc: "Whether they bought ice access. Accepts 1/0, true/false, yes/no." }, - { key: "car_parking", req: "optional", type: "yes/no", desc: "Car parking pass." }, - { key: "rv_parking", req: "optional", type: "yes/no", desc: "RV parking pass." }, - { key: "is_donor", req: "optional", type: "yes/no", desc: "Donor flag." }, - { key: "address", req: "optional", type: "text", desc: "Mailing address." }, - { key: "payment_method", req: "optional", type: "text", desc: "Payment method label." }, + { key: "names", req: "required", type: "name (compound)", desc: "Purchaser / Adult #1 — object {first_name, middle_name, last_name}. Also accepts flat names[first_name] keys." }, + { key: "names_1 … names_9", req: "optional", type: "name (compound)", desc: "Additional adult attendee names (Adults #2–#10). Empty groups are ignored. Stored as the adult-name list shown at the gate." }, + { key: "email", req: "optional", type: "email", desc: "Purchaser email — the QR ticket is sent here (FluentForms sends the receipt separately)." }, + { key: "address_1", req: "optional", type: "address (compound)", desc: "Mailing address object; joined into one line." }, + { key: "item_quantity_adult_ticket_reg", req: "required", type: "quantity", desc: "Adult tickets (regular)." }, + { key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "Adult tickets (donor). Added to the regular adults." }, + { key: "item_quantity_youth_ticket_reg / _donor", req: "optional", type: "quantity", desc: "Youth 13-16 tickets (regular + donor)." }, + { key: "item_quantity_kids_12", req: "optional", type: "quantity", desc: "Kids 10-12. Counts toward the scannable total." }, + { key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. Counts toward the scannable total." }, + { key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE — NOT counted toward the scannable ticket total." }, + { key: "donor_tier", req: "optional", type: "hidden", desc: "member / donor / empty (from the donor-eligibility lookup)." }, + { key: "donor_eligible", req: "optional", type: "hidden", desc: "true / false (from the donor-eligibility lookup)." }, + { key: "vouchers", req: "optional", type: "hidden", desc: "Integer voucher count (from the ticket-voucher lookup)." }, + { key: "input_radio", req: "optional", type: "choice", desc: "'Are you a campground donor?' — also used as a donor signal." }, + { key: "payment_parking_reg / _donor", req: "optional", type: "payment", desc: "Car parking. Flagged if either variant is selected." }, + { key: "payment_rv_reg / _donor", req: "optional", type: "payment", desc: "RV. Flagged if either variant is selected." }, + { key: "payment_utv_reg / _donor", req: "optional", type: "payment", desc: "ATV/UTV. Flagged if either variant is selected." }, + { 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: "payment_method", req: "optional", type: "payment", desc: "Payment method label." }, + { key: "id / submission_id", req: "optional", type: "text", desc: "Entry/submission id for idempotency (retries won't duplicate). Falls back to a content hash." }, ]; function esc(s: string): string { @@ -48,28 +45,34 @@ export async function webhookDocRoutes(app: FastifyInstance): Promise { const rows = FIELDS.map( (f) => ` - ${f.key} + ${esc(f.key)} ${f.req} - ${f.type} + ${esc(f.type)} ${esc(f.desc)} `, ).join(""); const exampleJson = esc(`{ - "name": "Jane Bear", + "id": "412", + "names": { "first_name": "Jane", "last_name": "Bear" }, + "names_1": { "first_name": "John", "last_name": "Bear" }, "email": "jane@example.com", - "submission_id": "12345", - "ages_0_3": 2, - "ages_8_12": 3, - "ages_26_45": 2, - "car_parking": "yes", - "ice_access": "yes" + "item_quantity_adult_ticket_reg": 2, + "item_quantity_adult_ticket_donor": 0, + "item_quantity_youth_ticket_reg": 1, + "item_quantity_kids_9": 2, + "item_quantity_kids_4": 2, + "donor_tier": "member", + "vouchers": 2, + "payment_parking_reg": "$40.00", + "payment_ice": 2, + "payment_method": "stripe" }`); const exampleCurl = esc(`curl -X POST ${WEBHOOK_URL} \\ -H "Content-Type: application/json" \\ -H "X-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 = ` @@ -82,7 +85,7 @@ const PAGE = ` :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; 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; } h2 { font-size: 20px; margin: 32px 0 10px; border-bottom: 1px solid #24382a; padding-bottom: 6px; } .sub { color: #9db3a4; margin: 0 0 8px; } @@ -104,31 +107,32 @@ const PAGE = `
-

🐻 Camp Scan — Purchase Webhook

-

How the FluentForms ticket checkout notifies the ticketing backend to create a ticket and email the QR code.

+

🐻 Camp Scan — Purchase Webhook (Tickets 2026)

+

How the FluentForms "Tickets 2026" checkout notifies the ticketing backend to create a ticket and email the QR code.

Endpoint  POST ${WEBHOOK_URL}
Auth header  X-Webhook-Secret: <the shared WEBHOOK_SECRET>
-
Body format  JSON (application/json) or form-encoded — both accepted.
+
Body format  JSON (application/json) or form-encoded — both accepted. Send all form fields.

What it does

-

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: "2026 Beartaria Campgrounds Tickets"). The total number of redeemable tickets is the sum of the age-bracket counts, excluding ages 0–3 (who are free).

+

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

+

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

Fields

${rows}
KeyRequiredTypeDescription
-

At least one non-zero age-bracket count is required (otherwise there are no tickets to issue). Booleans accept 1/0, true/false, or yes/no.

+

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

Idempotency

-

Send a stable submission_id. If the backend sees the same one again it returns {"status":"duplicate"} without creating a second ticket or re-sending email — so FluentForms retries and accidental double-submits are safe.

+

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

Example payload

${exampleJson}
-

This issues 5 redeemable tickets (3×8–12 + 2×26–45; the two 0–3 are free), with car parking and 3 ice bags.

+

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

Test with curl

${exampleCurl}
@@ -139,7 +143,7 @@ const PAGE = ` 200{"status":"created","code":"BC26-…","emailSent":true}Ticket created and emailed. 200{"status":"duplicate","code":"BC26-…"}Same submission already processed — no-op. - 400{"error":"missing_fields"} / "no_tickets"Missing name/email, or no age counts. + 400{"error":"missing_fields"} / "no_tickets"Missing purchaser name, or zero scannable tickets. 401{"error":"unauthorized"}Missing or wrong X-Webhook-Secret. 502{"status":"created","emailSent":false,…}Ticket row created but the email failed — re-send from the admin app. @@ -148,11 +152,10 @@ const PAGE = `

FluentForms setup

  1. On the ticket form: Settings & Integrations → Webhook → Add Webhook.
  2. -
  3. Request URL: ${WEBHOOK_URL}
  4. -
  5. Request Method: POST  ·  Format: JSON
  6. +
  7. Request URL: ${WEBHOOK_URL}  ·  Method: POST  ·  Format: JSON
  8. Request Headers: add X-Webhook-Secret = the shared secret.
  9. -
  10. Request Body: map each form field to the keys in the table above.
  11. -
  12. Save, then submit a test purchase and confirm the QR email arrives.
  13. +
  14. Request Body: send all fields (the field names above are the FluentForms field keys).
  15. +
  16. Save, submit a test purchase, and confirm the QR email arrives.
Beartaria Campgrounds · scan.beartariacampgrounds.com
diff --git a/backend/src/test/fakeNocodb.ts b/backend/src/test/fakeNocodb.ts index 46cbd89..9a158f8 100644 --- a/backend/src/test/fakeNocodb.ts +++ b/backend/src/test/fakeNocodb.ts @@ -86,14 +86,13 @@ export function fakeContext(db: FakeNocoDB): AppContext { export async function seedTicket( db: FakeNocoDB, - opts: { code: string; name?: string; email?: string; ages?: Record; redeemed?: number }, + opts: { code: string; name?: string; email?: string; adults?: number; redeemed?: number }, ): Promise { - const ages = opts.ages ?? { "Ages 18-25": 2, "Ages 26-45": 3, "Ages 0-3": 1 }; return db.create({ [COL.code]: opts.code, [COL.name]: opts.name ?? "Test Bear", [COL.email]: opts.email ?? "test@example.com", + [COL.adults]: opts.adults ?? 5, [COL.redeemed]: opts.redeemed ?? 0, - ...ages, }); } diff --git a/backend/src/test/fields.test.ts b/backend/src/test/fields.test.ts index 2287a11..834de59 100644 --- a/backend/src/test/fields.test.ts +++ b/backend/src/test/fields.test.ts @@ -2,45 +2,52 @@ import { describe, it, expect } from "vitest"; import { computeTotal, toView, COL } from "../fields.js"; describe("computeTotal", () => { - it("sums age brackets but excludes Ages 0-3 (free)", () => { + it("sums adults + youth + kids 10-12 + kids 5-9, excluding kids 0-4 (free)", () => { const rec = { Id: 1, - "Ages 0-3": 2, // free, not counted - "Ages 4-7": 1, - "Ages 18-25": 2, - "Ages 26-45": 1, + [COL.adults]: 2, + [COL.youth]: 1, + [COL.kids12]: 1, + [COL.kids9]: 1, + [COL.kids4]: 3, // free, not counted }; - expect(computeTotal(rec)).toBe(4); + expect(computeTotal(rec)).toBe(5); }); 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); }); }); describe("toView", () => { - it("derives remaining and surfaces extras", () => { + it("derives remaining and surfaces adult names, donor tier, and extras", () => { const rec = { Id: 7, [COL.code]: "BC26-ABCD-2345", [COL.name]: "Jane Bear", [COL.email]: "jane@example.com", + [COL.adultNames]: "Jane Bear\nJohn Bear", [COL.redeemed]: 2, + [COL.adults]: 2, + [COL.youth]: 3, + [COL.kids4]: 1, [COL.carParking]: true, [COL.iceAccess]: "yes", - "Ages 0-3": 1, - "Ages 18-25": 2, - "Ages 26-45": 3, + [COL.donorTier]: "member", + [COL.vouchers]: 2, }; const v = toView(rec); expect(v.total).toBe(5); expect(v.redeemed).toBe(2); expect(v.remaining).toBe(3); + expect(v.adultNames).toEqual(["Jane Bear", "John Bear"]); expect(v.extras.carParking).toBe(true); expect(v.extras.iceAccess).toBe(true); expect(v.extras.rvParking).toBe(false); - expect(v.extras.freeUnder4).toBe(1); - expect(v.ages.find((a) => a.bracket === "0-3")?.free).toBe(true); + expect(v.extras.donorTier).toBe("member"); + expect(v.extras.vouchers).toBe(2); + expect(v.extras.freeUnder5).toBe(1); + expect(v.ages.find((a) => a.bracket === "Kids 0-4")?.free).toBe(true); }); }); diff --git a/backend/src/test/redeem.test.ts b/backend/src/test/redeem.test.ts index 72738a6..b2255e1 100644 --- a/backend/src/test/redeem.test.ts +++ b/backend/src/test/redeem.test.ts @@ -6,7 +6,7 @@ import { COL } from "../fields.js"; describe("redeem", () => { it("checks in a single walk-up (default count 1)", async () => { 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 r = await redeem(ctx, "BC26-AAAA-1111", 1); expect(r.ok).toBe(true); @@ -20,7 +20,7 @@ describe("redeem", () => { it("supports group check-in and QR reuse across visits", async () => { const db = new FakeNocoDB(); // 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 first = await redeem(ctx, "BC26-FAM-0001", 2); // father + son @@ -36,7 +36,7 @@ describe("redeem", () => { it("rejects over-redemption without mutating", async () => { 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 r = await redeem(ctx, "BC26-BBBB-2222", 5); expect(r.ok).toBe(false); @@ -46,7 +46,7 @@ describe("redeem", () => { it("allows negative count to undo, clamped at zero", async () => { 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 r = await redeem(ctx, "BC26-CCCC-3333", -5); expect(r.ok).toBe(true); @@ -55,7 +55,7 @@ describe("redeem", () => { it("writes an audit entry on each successful check-in and undo", async () => { 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); await redeem(ctx, "BC26-AUDT-0001", 2); 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 () => { 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); await redeem(ctx, "BC26-AUDT-0002", -2); // clamps to 0, delta 0 expect((ctx.audit as any).entries).toHaveLength(0); @@ -77,7 +77,7 @@ describe("redeem", () => { const db = new FakeNocoDB(); await seedTicket(db, { code: "BC26-ICE-0003", - ages: { "Ages 26-45": 2 }, + adults: 2, }); // Give the ticket 3 prepaid ice bags. db.rows[0]["Ice Total"] = 3; @@ -112,7 +112,7 @@ describe("redeem", () => { it("surfaces db_error when the update fails", async () => { 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; const ctx = fakeContext(db); 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 () => { 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 results = await Promise.all( @@ -137,7 +137,7 @@ describe("redeem", () => { describe("lookupByCode", () => { it("returns the ticket view without mutating", async () => { 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 r = await lookupByCode(ctx, "BC26-LOOK-0001"); expect(r.ok && r.found && r.ticket.remaining).toBe(3); @@ -159,7 +159,7 @@ describe("createTicket idempotency", () => { const input = { name: "Jane Bear", email: "jane@example.com", - ages: { "Ages 26-45": 2 }, + counts: { adults: 2, youth: 0, kids12: 0, kids9: 0, kids4: 0 }, submissionKey: "sub:412", }; const a = await createTicket(ctx, input); diff --git a/backend/src/ticketService.ts b/backend/src/ticketService.ts index 76fa4dc..f4e03aa 100644 --- a/backend/src/ticketService.ts +++ b/backend/src/ticketService.ts @@ -128,15 +128,19 @@ export async function search(ctx: AppContext, query: string): Promise; // NocoDB age-column title -> count submissionKey: string; } @@ -158,20 +162,29 @@ export async function createTicket( code = generateCode(); } + const c = input.counts; const fields: Record = { [COL.name]: input.name, [COL.email]: input.email, [COL.code]: code, [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.iceRedeemed]: 0, [COL.submissionKey]: input.submissionKey, - ...input.ages, }; + if (input.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n"); if (input.address !== undefined) fields[COL.address] = input.address; if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor; + if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier; + if (input.vouchers !== undefined) fields[COL.vouchers] = input.vouchers; if (input.carParking !== undefined) fields[COL.carParking] = input.carParking; 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.paymentMethod !== undefined) fields[COL.paymentMethod] = input.paymentMethod; From 4b78c82b35eee5e2c352468e0124cf749b42f4da Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 13 Jul 2026 03:30:08 +0000 Subject: [PATCH 20/37] Add /crush33 comp-ticket portal + ticket-type badge on scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/app/admin.tsx | 1 + app/app/index.tsx | 29 ++++++ app/lib/api.ts | 1 + backend/src/config.ts | 2 + backend/src/fields.ts | 3 + backend/src/routes/portal.ts | 170 +++++++++++++++++++++++++++++++++++ backend/src/routes/test.ts | 20 +++++ backend/src/server.ts | 2 + backend/src/ticketService.ts | 2 + 9 files changed, 230 insertions(+) create mode 100644 backend/src/routes/portal.ts diff --git a/app/app/admin.tsx b/app/app/admin.tsx index f492fc9..e978059 100644 --- a/app/app/admin.tsx +++ b/app/app/admin.tsx @@ -202,6 +202,7 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti const tags: string[] = []; const e = ticket.extras; + if (ticket.ticketType) tags.push(`🎫 ${ticket.ticketType}`); if (e.donorTier === "member") tags.push("🐻 Member"); else if (e.isDonor) tags.push("⭐ Donor"); if (e.carParking) tags.push("🚗 Car"); diff --git a/app/app/index.tsx b/app/app/index.tsx index 297e1d9..ab552ff 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -242,6 +242,7 @@ export default function ScannerScreen() { {phase === "success" && ticket && ( + {isIce ? ( <> @@ -366,6 +367,25 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) { ); } +const TYPE_ICON: Record = { + Guest: "🎫", + Worker: "🛠️", + Performer: "🎭", + Volunteer: "🙌", + Speaker: "🎤", +}; + +function TypeBadge({ type }: { type: string }) { + if (!type) return null; + return ( + + + {(TYPE_ICON[type] ?? "🎫") + " " + type.toUpperCase()} + + + ); +} + function AdultNames({ names }: { names: string[] }) { if (!names.length) return null; return ( @@ -405,6 +425,7 @@ function ConfirmCard({ return ( {ticket.name} + {ticket.code} {remaining} of {total} {unit} remaining @@ -507,6 +528,14 @@ const styles = StyleSheet.create({ 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 }, + 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" }, tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 }, diff --git a/app/lib/api.ts b/app/lib/api.ts index 84dd309..2123da8 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -21,6 +21,7 @@ export interface TicketView { code: string; name: string; email: string; + ticketType: string; total: number; redeemed: number; remaining: number; diff --git a/backend/src/config.ts b/backend/src/config.ts index df123e5..ef9f7f5 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -48,6 +48,8 @@ const schema = z.object({ WEBHOOK_SECRET: 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_TTL: z.string().default("30d"), diff --git a/backend/src/fields.ts b/backend/src/fields.ts index 5c669e8..c6b4cc8 100644 --- a/backend/src/fields.ts +++ b/backend/src/fields.ts @@ -24,6 +24,7 @@ export const COL = { utv: "UTV", iceAccess: "Ice Access", paymentMethod: "Payment Method", + ticketType: "Ticket Type", // "" for regular; Guest/Worker/Performer/Volunteer/Speaker for portal comps // Columns this system manages: code: "Ticket Code", @@ -94,6 +95,7 @@ export interface TicketView { code: string; name: string; email: string; + ticketType: string; // "" for regular; Guest/Worker/... for special tickets total: number; redeemed: number; remaining: number; @@ -121,6 +123,7 @@ export function toView(rec: NocoRecord): TicketView { code: String(rec[COL.code] ?? ""), name: String(rec[COL.name] ?? ""), email: String(rec[COL.email] ?? ""), + ticketType: String(rec[COL.ticketType] ?? ""), total, redeemed, remaining: Math.max(0, total - redeemed), diff --git a/backend/src/routes/portal.ts b/backend/src/routes/portal.ts new file mode 100644 index 0000000..9a1f077 --- /dev/null +++ b/backend/src/routes/portal.ts @@ -0,0 +1,170 @@ +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 { + app.get("/crush33", async (_req, reply) => { + reply.type("text/html").send(PAGE); + }); + + 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 }; + 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"; + if (!name || !email) { + return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" }); + } + + let result: Awaited>; + try { + result = await createTicket(app.ctx, { + name, + adultNames: [name], + email, + ticketType: type, + 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 = ` + + + + + +Camp Scan — Comp Tickets + + + +
+
+ +

Comp Ticket Portal

+

Entry-only tickets for workers & guests

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

What it does

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

-

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

+

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

Fields

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

Example payload

${exampleJson}
-

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

+

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

Test with curl

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

    Vendor booth webhooks

    +

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

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

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

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

    Vendor booth webhooks

    -

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

    +

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

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

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

    +

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

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

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

    +

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

    Idempotency

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

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

    What it does

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

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

    +

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

    Fields

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

    Hi ${name || "there"},

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

    Ticket QR code

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

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

    What it does

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

    -

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

    +

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

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

    Fields

    From 049f433930de3fa74fe1eecd57ce8e2043bd0a02 Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 20 Jul 2026 07:40:05 +0000 Subject: [PATCH 30/37] Fix ice bag count + default ice check-in to 1 bag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The form sends payment_ice as a descriptive label, e.g. "One Ice ticket good for one bag per day (3 total bags)". The old parser pulled the first number ("3") and treated it as 3 tickets, then multiplied by 3 bags/ticket → 9 bags for one ice ticket (18 for two). New iceBagsFromPayment reads the "(N total bags)" the label states directly, with worded-count and numeric dollar/count fallbacks for forward compatibility. 1 ice → 3 bags, 2 → 6. 5 new tests. Scanner: Ice mode now defaults the check-in count to 1 (a bag at a time) instead of all remaining bags; staff can bump it up. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/index.tsx | 5 +++-- backend/src/fluentforms.ts | 28 ++++++++++++++++++++++++++++ backend/src/routes/webhook.ts | 13 +++++++------ backend/src/test/fluentforms.test.ts | 27 ++++++++++++++++++++++++++- 4 files changed, 64 insertions(+), 9 deletions(-) diff --git a/app/app/index.tsx b/app/app/index.tsx index d3211da..b7a0b2e 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -109,8 +109,9 @@ export default function ScannerScreen() { feedbackSuccess(); const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining; setTicket(res.ticket); - // Ice: default to grabbing all remaining bags at once. Tickets: default 1. - setCount(mode === "ice" ? Math.max(1, remaining) : Math.min(1, remaining)); + // Default to 1 (people usually grab ice a bag at a time); staff can bump + // the count up. Clamp to what's left so a 0-remaining ticket stays at 0. + setCount(Math.min(1, remaining)); setPhase("confirm"); } catch (e: any) { if (e?.name === "AuthError") return router.replace("/login"); diff --git a/backend/src/fluentforms.ts b/backend/src/fluentforms.ts index de628a7..60f9e4c 100644 --- a/backend/src/fluentforms.ts +++ b/backend/src/fluentforms.ts @@ -56,6 +56,34 @@ export function addressLine(v: any): string | undefined { return undefined; } +const NUMBER_WORDS: Record = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 }; + +/** + * Total bags of ice from the `payment_ice` field. The form sends a descriptive + * option label, e.g. "One Ice ticket good for one bag per day (3 total bags)", + * so the reliable signal is the "(N total bags)" the label states. Falls back to + * a worded ticket count ("Two Ice tickets" → 2 × bagsPerTicket), then to a + * numeric dollar-total/ticket-count for forward compatibility. + */ +export function iceBagsFromPayment( + value: unknown, + opts: { bagsPerTicket: number; ticketPrice: number }, +): number { + const { bagsPerTicket, ticketPrice } = opts; + const s = typeof value === "string" ? value : ""; + // Preferred: the label states the total bags directly. + const bagsMatch = s.match(/(\d+)\s*total\s*bags/i); + if (bagsMatch) return Math.max(0, parseInt(bagsMatch[1], 10)); + // Worded ticket count: "One Ice ticket", "Two Ice tickets". + const wordMatch = s.match(/\b(one|two|three|four|five|six)\b\s+ice/i); + if (wordMatch) return NUMBER_WORDS[wordMatch[1].toLowerCase()] * bagsPerTicket; + // Numeric fallback: a dollar total (>= price) → tickets; else a small count. + const n = qty(value); + if (n <= 0) return 0; + const tickets = n >= ticketPrice ? Math.round(n / ticketPrice) : Math.round(n); + return Math.max(0, tickets) * bagsPerTicket; +} + /** Donor status from the hidden lookup fields + the "are you a donor?" radio. */ export function readDonor(body: Record): { isDonor: boolean; donorTier: string } { const donorTier = String(body.donor_tier ?? "").trim(); diff --git a/backend/src/routes/webhook.ts b/backend/src/routes/webhook.ts index 7f5595d..250c14f 100644 --- a/backend/src/routes/webhook.ts +++ b/backend/src/routes/webhook.ts @@ -3,7 +3,7 @@ import type { FastifyInstance } from "fastify"; import { toBool } from "../fields.js"; import { createTicket } from "../ticketService.js"; import { renderQrPng } from "../services/qrcode.js"; -import { safeEqual, nameGroup, qty, selected, addressLine } from "../fluentforms.js"; +import { safeEqual, nameGroup, qty, selected, addressLine, iceBagsFromPayment } from "../fluentforms.js"; // Regular adult attendee name groups (Adult Ticket #1–#10), in order. const REGULAR_NAME_BASES = [ @@ -73,11 +73,12 @@ export async function webhookRoutes(app: FastifyInstance): Promise { const carParking = selected(body.payment_parking_reg) || selected(body.payment_parking_donor); const rvParking = selected(body.payment_rv_reg) || selected(body.payment_rv_donor); const utv = selected(body.payment_utv_reg) || selected(body.payment_utv_donor); - // Ice: payment_ice is either a ticket count (1-4) or a dollar total - // ($20-$80). One ice ticket = ICE_BAGS_PER_TICKET bags. - const iceRaw = qty(body.payment_ice); - const iceTickets = iceRaw >= app.ctx.config.ICE_TICKET_PRICE ? Math.round(iceRaw / app.ctx.config.ICE_TICKET_PRICE) : Math.round(iceRaw); - const iceBags = Math.max(0, iceTickets) * app.ctx.config.ICE_BAGS_PER_TICKET; + // Ice: payment_ice is a descriptive option label whose "(N total bags)" + // states the bags. One ice ticket = ICE_BAGS_PER_TICKET bags. + const iceBags = iceBagsFromPayment(body.payment_ice, { + bagsPerTicket: app.ctx.config.ICE_BAGS_PER_TICKET, + ticketPrice: app.ctx.config.ICE_TICKET_PRICE, + }); const iceAccess = iceBags > 0 || selected(body.input_radio_7); // Tickets are optional: a customer can buy ice/UTV/parking with no admission diff --git a/backend/src/test/fluentforms.test.ts b/backend/src/test/fluentforms.test.ts index 083f3b4..9023e24 100644 --- a/backend/src/test/fluentforms.test.ts +++ b/backend/src/test/fluentforms.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from "vitest"; -import { nameGroup, qty, selected, addressLine, readDonor } from "../fluentforms.js"; +import { nameGroup, qty, selected, addressLine, readDonor, iceBagsFromPayment } from "../fluentforms.js"; + +const ICE = { bagsPerTicket: 3, ticketPrice: 20 }; // Food vendors are the only vendor tickets; two pass-holder name slots. const FOOD_SLOTS = ["names", "names_1"]; @@ -44,6 +46,29 @@ describe("food vendor pass counting", () => { }); }); +describe("iceBagsFromPayment", () => { + it("reads '(N total bags)' from the real form label", () => { + expect(iceBagsFromPayment("One Ice ticket good for one bag per day (3 total bags)", ICE)).toBe(3); + expect(iceBagsFromPayment("Two Ice tickets good for one bag per day (6 total bags)", ICE)).toBe(6); + }); + it("falls back to a worded ice-ticket count", () => { + expect(iceBagsFromPayment("Two Ice tickets", ICE)).toBe(6); // 2 × 3 + expect(iceBagsFromPayment("Four Ice tickets", ICE)).toBe(12); + }); + it("falls back to a dollar total at the ticket price", () => { + expect(iceBagsFromPayment("$40.00", ICE)).toBe(6); // 2 tickets × 3 + expect(iceBagsFromPayment(20, ICE)).toBe(3); // 1 ticket × 3 + }); + it("treats a small plain count as ticket count", () => { + expect(iceBagsFromPayment(2, ICE)).toBe(6); // 2 tickets × 3 + }); + it("is 0 for blank / no ice", () => { + expect(iceBagsFromPayment("", ICE)).toBe(0); + expect(iceBagsFromPayment(undefined, ICE)).toBe(0); + expect(iceBagsFromPayment(0, ICE)).toBe(0); + }); +}); + describe("readDonor", () => { it("treats donor_tier=member as a donor", () => { expect(readDonor({ donor_tier: "member" })).toEqual({ isDonor: true, donorTier: "member" }); From 63e00fae618851640bdbfd35116e02b484879dd8 Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 20 Jul 2026 18:48:10 +0000 Subject: [PATCH 31/37] Scanner: large Adults / Kids party panel on scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate-enforcement aid against adults signing up under a free/cheaper kid bracket. When a ticket is scanned, a prominent amber-bordered panel shows big "# ADULTS # KIDS" counts (Adults = the 18+ bracket; Kids = youth 13-16 + all under-13), plus a per-bracket detail line (e.g. "1× 13-16 · 2× 5-9") so staff can eyeball the claimed ages against the actual party. Shown on the confirm card (before check-in) and the success overlay; hidden in Ice mode. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/index.tsx | 50 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/app/app/index.tsx b/app/app/index.tsx index b7a0b2e..3766c6c 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -253,6 +253,7 @@ export default function ScannerScreen() { {ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining + @@ -394,6 +395,37 @@ function AdultNames({ names }: { names: string[] }) { ); } +/** + * Big Adults / Kids breakdown so gate staff can eyeball the party against the + * ticket — a deterrent for adults signing up under a (free/cheaper) kid bracket. + * Adults = the 18+ bracket; Kids = everyone else (youth 13-16 + all under-13). + */ +function PartyPanel({ ticket }: { ticket: TicketView }) { + const adults = ticket.ages.find((a) => a.bracket === "Adults")?.count ?? 0; + const kidBrackets = ticket.ages.filter((a) => a.bracket !== "Adults"); + const kids = kidBrackets.reduce((s, a) => s + a.count, 0); + return ( + + + + {adults} + {adults === 1 ? "ADULT" : "ADULTS"} + + + + {kids} + {kids === 1 ? "KID" : "KIDS"} + + + {kidBrackets.length > 0 && ( + + {kidBrackets.map((a) => `${a.count}× ${a.bracket.replace(/^(Kids|Youth)\s*/, "")}`).join(" · ")} + + )} + + ); +} + function ConfirmCard({ ticket, isIce, @@ -422,6 +454,7 @@ function ConfirmCard({ {ticket.name} {ticket.code} + {!isIce && } {remaining} of {total} {unit} remaining @@ -535,6 +568,23 @@ const styles = StyleSheet.create({ typeBadgeText: { color: "#fff", fontSize: 20, fontWeight: "900", letterSpacing: 1 }, namesBox: { marginTop: 12, alignItems: "center", gap: 3 }, nameLine: { color: "#fff", fontSize: 18, fontWeight: "600", textAlign: "center" }, + party: { + alignSelf: "stretch", + backgroundColor: "#1d2a1f", + borderWidth: 2, + borderColor: theme.warn, + borderRadius: 16, + paddingVertical: 18, + paddingHorizontal: 12, + marginTop: 16, + marginBottom: 2, + }, + partyRow: { flexDirection: "row", alignItems: "center", justifyContent: "center" }, + partyCell: { flex: 1, alignItems: "center" }, + partyNum: { color: theme.text, fontSize: 60, fontWeight: "900", lineHeight: 64 }, + partyLbl: { color: theme.warn, fontSize: 15, fontWeight: "800", letterSpacing: 2, marginTop: 2 }, + partyDivider: { width: 2, alignSelf: "stretch", backgroundColor: theme.cardBorder, marginVertical: 6 }, + partyDetail: { color: theme.textDim, fontSize: 14, textAlign: "center", marginTop: 12, fontWeight: "600" }, tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 }, tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" }, From 62231e9b1070896dad05b02a634afaf6c6236407 Mon Sep 17 00:00:00 2001 From: Hank Date: Tue, 21 Jul 2026 00:34:32 +0000 Subject: [PATCH 32/37] Release v0.2.0 (versionCode 2) Webhook: Tickets 2026 form (customer_name title, voucher-name ticket counting, ticketless ice/UTV orders, donor adult names); ice bag count fix; voucher decrement; vendor webhooks; free kids through 12; multi- origin lookup CORS; scanner Adults/Kids party panel + ice default 1. First versionCode bump (was stuck at 1), so this installs over v0.1.0. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/app.json b/app/app.json index 143c506..ecfabbc 100644 --- a/app/app.json +++ b/app/app.json @@ -2,7 +2,7 @@ "expo": { "name": "Camp Scan", "slug": "camptickets", - "version": "0.1.0", + "version": "0.2.0", "orientation": "portrait", "scheme": "campscan", "userInterfaceStyle": "automatic", @@ -10,7 +10,7 @@ "icon": "./assets/icon.png", "android": { "package": "top.mowden.campscan", - "versionCode": 1, + "versionCode": 2, "adaptiveIcon": { "foregroundImage": "./assets/adaptive-icon.png", "backgroundColor": "#0f1a12" From 60f090829916a04b3c7ff392864f8117b784d3af Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 22 Jul 2026 03:34:14 +0000 Subject: [PATCH 33/37] Scanner: compact 3-cell Adults / Youth / Kids party panel Split the party panel into Adults (18+) / Youth (13-16) / Kids (0-12) so the paid tickets (adults + youth) are both visible, and shrank it (36px numbers, tighter padding, single-line kid detail) so the confirm card no longer scrolls on a phone. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/index.tsx | 40 ++++++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/app/app/index.tsx b/app/app/index.tsx index 3766c6c..e624c36 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -396,30 +396,38 @@ function AdultNames({ names }: { names: string[] }) { } /** - * Big Adults / Kids breakdown so gate staff can eyeball the party against the - * ticket — a deterrent for adults signing up under a (free/cheaper) kid bracket. - * Adults = the 18+ bracket; Kids = everyone else (youth 13-16 + all under-13). + * Big Adults / Youth / Kids breakdown so gate staff can eyeball the party + * against the ticket — a deterrent for adults signing up under a (free/cheaper) + * younger bracket. Adults (18+) and Youth (13-16) are the paid tickets; Kids + * (0-12) are free. A detail line breaks the kids into their age bands. */ function PartyPanel({ ticket }: { ticket: TicketView }) { - const adults = ticket.ages.find((a) => a.bracket === "Adults")?.count ?? 0; - const kidBrackets = ticket.ages.filter((a) => a.bracket !== "Adults"); + const get = (b: string) => ticket.ages.find((a) => a.bracket === b)?.count ?? 0; + const adults = get("Adults"); + const youth = get("Youth 13-16"); + const kidBrackets = ticket.ages.filter((a) => a.bracket.startsWith("Kids")); const kids = kidBrackets.reduce((s, a) => s + a.count, 0); return ( {adults} - {adults === 1 ? "ADULT" : "ADULTS"} + ADULTS{"\n"}18+ + + + + {youth} + YOUTH{"\n"}13-16 {kids} - {kids === 1 ? "KID" : "KIDS"} + KIDS{"\n"}0-12 {kidBrackets.length > 0 && ( - {kidBrackets.map((a) => `${a.count}× ${a.bracket.replace(/^(Kids|Youth)\s*/, "")}`).join(" · ")} + kids: {kidBrackets.map((a) => `${a.count}× ${a.bracket.replace(/^Kids\s*/, "")}`).join(" · ")} )} @@ -573,18 +581,18 @@ const styles = StyleSheet.create({ backgroundColor: "#1d2a1f", borderWidth: 2, borderColor: theme.warn, - borderRadius: 16, - paddingVertical: 18, - paddingHorizontal: 12, - marginTop: 16, + borderRadius: 14, + paddingVertical: 10, + paddingHorizontal: 10, + marginTop: 10, marginBottom: 2, }, partyRow: { flexDirection: "row", alignItems: "center", justifyContent: "center" }, partyCell: { flex: 1, alignItems: "center" }, - partyNum: { color: theme.text, fontSize: 60, fontWeight: "900", lineHeight: 64 }, - partyLbl: { color: theme.warn, fontSize: 15, fontWeight: "800", letterSpacing: 2, marginTop: 2 }, - partyDivider: { width: 2, alignSelf: "stretch", backgroundColor: theme.cardBorder, marginVertical: 6 }, - partyDetail: { color: theme.textDim, fontSize: 14, textAlign: "center", marginTop: 12, fontWeight: "600" }, + partyNum: { color: theme.text, fontSize: 36, fontWeight: "900", lineHeight: 40 }, + partyLbl: { color: theme.warn, fontSize: 11, fontWeight: "800", letterSpacing: 0.5, marginTop: 1, textAlign: "center", lineHeight: 13 }, + partyDivider: { width: 1.5, height: 42, backgroundColor: theme.cardBorder }, + partyDetail: { color: theme.textDim, fontSize: 12, textAlign: "center", marginTop: 8, fontWeight: "600" }, tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 }, tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" }, From 3a3119e3240d0b33173b3e9ef8a58c802f973628 Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 23 Jul 2026 01:29:10 +0000 Subject: [PATCH 34/37] Admin hub in /crush33: sidebar, donor lookup, danger-zone actions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt the password-gated /crush33 (in-app /comp) screen into an admin hub with a left sidebar and three sections: - Comp tickets — the existing entry-only comp creator. - Donor lookup — admin-only free-text search across the donor master list + online/offline transaction tables by name / email / phone / address / bear name (columns discovered per table, deduped by email). - Actions (danger zone) — heavy warnings, red buttons, and an "are you sure" modal that spells out exactly what will happen: • Wipe slate — delete ALL ticket + audit records in the active event table (donor data untouched, irreversible). • Switch event table — repoint the app at a different NocoDB tickets/audit table to start a new event while keeping the old one intact. Backend: - New /api/admin/{status,wipe,switch-table,donor-search}, all gated by PORTAL_PASSWORD (POST-only so it never lands in a URL/log). - NocoDBClient + AuditLogger: runtime-switchable tableId, count(), deleteAll(), probeTable() (reachable + Id-PK check before switching). - DonorService.search() with adaptive column discovery. - Table switch persists across redeploys via a small state file on a new /data volume (Dockerfile creates it owned by node so it's writable); applied at startup in buildContext. Also shipped equivalent CLI scripts: scripts/wipe-slate.sh and scripts/switch-event.sh. Drawer: "Comp tickets" -> "Admin (crush33)". Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 7 +- app/app/comp.tsx | 630 ++++++++++++++++++++++++--------- app/components/SideMenu.tsx | 4 +- app/lib/api.ts | 50 +++ backend/src/config.ts | 4 + backend/src/context.ts | 16 +- backend/src/routes/admin.ts | 122 +++++++ backend/src/server.ts | 2 + backend/src/services/audit.ts | 48 ++- backend/src/services/donors.ts | 107 ++++++ backend/src/services/nocodb.ts | 55 ++- backend/src/services/state.ts | 32 ++ docker-compose.yml | 7 + scripts/switch-event.sh | 73 ++++ scripts/wipe-slate.sh | 57 +++ 15 files changed, 1042 insertions(+), 172 deletions(-) create mode 100644 backend/src/routes/admin.ts create mode 100644 backend/src/services/state.ts create mode 100755 scripts/switch-event.sh create mode 100755 scripts/wipe-slate.sh diff --git a/Dockerfile b/Dockerfile index 845477a..e5ce281 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,8 +32,11 @@ ENV WEB_DIR=/srv/web ENV PORT=8080 ENV HOST=0.0.0.0 -# Run as the non-root node user shipped in the base image. -RUN chown -R node:node /srv +# Run as the non-root node user shipped in the base image. /data is a mount +# point for the runtime state volume — create it owned by node so a fresh named +# volume inherits writable ownership. +RUN chown -R node:node /srv && mkdir -p /data && chown node:node /data +ENV STATE_DIR=/data USER node EXPOSE 8080 diff --git a/app/app/comp.tsx b/app/app/comp.tsx index 364aac0..648af1e 100644 --- a/app/app/comp.tsx +++ b/app/app/comp.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useCallback, useEffect } from "react"; import { StyleSheet, View, @@ -9,10 +9,21 @@ import { Image, KeyboardAvoidingView, Platform, + ActivityIndicator, } from "react-native"; -import { router } from "expo-router"; import { SafeAreaView } from "react-native-safe-area-context"; -import { portalVerify, portalCreate, AuthError, type PortalTicket } from "../lib/api"; +import { + portalVerify, + portalCreate, + adminStatus, + adminWipe, + adminSwitchTable, + adminDonorSearch, + AuthError, + type PortalTicket, + type AdminStatus, + type DonorSearchResult, +} from "../lib/api"; import { useAuth } from "../lib/auth"; import { useMenu } from "../lib/menu"; import { theme } from "../lib/theme"; @@ -26,18 +37,26 @@ const TYPE_ICON: Record = { Speaker: "🎤", }; -export default function CompScreen() { +type Section = "comp" | "donors" | "actions"; +const NAV: { key: Section; icon: string; label: string }[] = [ + { key: "comp", icon: "🎟️", label: "Comp\ntickets" }, + { key: "donors", icon: "🔎", label: "Donor\nlookup" }, + { key: "actions", icon: "⚠️", label: "Actions" }, +]; + +export default function AdminHub() { const { operator } = useAuth(); const { open: openMenu } = useMenu(); const [password, setPassword] = useState(""); const [unlocked, setUnlocked] = useState(false); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); + const [section, setSection] = useState
    ("comp"); - const [type, setType] = useState("Guest"); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [result, setResult] = useState(null); + const relock = useCallback(() => { + setUnlocked(false); + setError("Password changed — unlock again."); + }, []); async function unlock() { if (!password || busy) return; @@ -47,28 +66,7 @@ export default function CompScreen() { await portalVerify(password); setUnlocked(true); } catch (e: any) { - setError(e instanceof AuthError ? "Wrong password" : (e?.message ?? "Failed")); - } finally { - setBusy(false); - } - } - - async function create() { - if (!name.trim() || !email.trim() || busy) return; - setBusy(true); - setError(""); - try { - const r = await portalCreate({ password, name: name.trim(), email: email.trim(), type, createdBy: operator }); - setResult(r); - setName(""); - setEmail(""); - } catch (e: any) { - if (e instanceof AuthError) { - setUnlocked(false); // password rotated — re-gate - setError("Password changed — unlock again."); - } else { - setError(e?.message ?? "Failed to create ticket"); - } + setError(e instanceof AuthError ? "Wrong password" : e?.message ?? "Failed"); } finally { setBusy(false); } @@ -80,160 +78,468 @@ export default function CompScreen() { - Comp Tickets + Admin · crush33 - - - {!unlocked ? ( - - Entry-only tickets for workers & guests. Enter the shared portal password. - Portal password - - {!!error && {error}} - - {busy ? "Checking…" : "Unlock"} - - - ) : ( - - Ticket type - - {TYPES.map((t) => ( - setType(t)} - > - - {(TYPE_ICON[t] ?? "🎫") + " " + t} - - - ))} - + {!unlocked ? ( + + + Admin-only area. Enter the shared portal password to unlock. + Portal password + + {!!error && {error}} + + {busy ? "Checking…" : "Unlock"} + + + + ) : ( + + + {NAV.map((n) => { + const active = section === n.key; + return ( + setSection(n.key)}> + {n.icon} + {n.label} + + ); + })} + - Full name - - - Email - - - {!!error && {error}} - - {busy ? "Creating…" : `Create ${type} ticket`} - - - {result && ( - - - {result.code} - - {result.type} · {result.name} - - - {result.emailSent ? "✓ Emailed the ticket" : "Email not sent — screenshot this QR"} - - - )} - - )} - - + + + {section === "comp" && } + {section === "donors" && } + {section === "actions" && } + + + + )} ); } +/* ---------------- Comp tickets ---------------- */ + +function CompSection({ password, operator, onRelock }: { password: string; operator: string | null; onRelock: () => void }) { + const [type, setType] = useState("Guest"); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [result, setResult] = useState(null); + + async function create() { + if (!name.trim() || !email.trim() || busy) return; + setBusy(true); + setError(""); + try { + const r = await portalCreate({ password, name: name.trim(), email: email.trim(), type, createdBy: operator ?? "" }); + setResult(r); + setName(""); + setEmail(""); + } catch (e: any) { + if (e instanceof AuthError) onRelock(); + else setError(e?.message ?? "Failed to create ticket"); + } finally { + setBusy(false); + } + } + + return ( + + Comp tickets + Entry-only tickets for guests & staff. + + Ticket type + + {TYPES.map((t) => ( + setType(t)}> + {(TYPE_ICON[t] ?? "🎫") + " " + t} + + ))} + + + Full name + + Email + + + {!!error && {error}} + + {busy ? "Creating…" : `Create ${type} ticket`} + + + {result && ( + + + {result.code} + {result.type} · {result.name} + {result.emailSent ? "✓ Emailed the ticket" : "Email not sent — screenshot this QR"} + + )} + + ); +} + +/* ---------------- Donor lookup ---------------- */ + +function DonorSection({ password, onRelock }: { password: string; onRelock: () => void }) { + const [query, setQuery] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [results, setResults] = useState(null); + + async function run() { + const q = query.trim(); + if (q.length < 2 || busy) return; + setBusy(true); + setError(""); + try { + const r = await adminDonorSearch(password, q); + setResults(r.results); + } catch (e: any) { + if (e instanceof AuthError) onRelock(); + else setError(e?.message ?? "Search failed"); + } finally { + setBusy(false); + } + } + + return ( + + Donor lookup + 🔒 Admin only · private donor info. Search by name, email, phone, address, bear name… + + + + + {busy ? "…" : "Search"} + + + + {!!error && {error}} + {results !== null && !busy && results.length === 0 && No donors match “{query.trim()}”.} + + {results?.map((d, i) => ( + + + {d.name || d.email || "(unnamed)"} + {d.lifetime != null && {money(d.lifetime)}} + + {!!d.bearName && 🐻 {d.bearName}} + {!!d.email && ✉️ {d.email}} + {!!d.altEmail && ✉️ {d.altEmail} (alt)} + {!!d.phone && 📞 {d.phone}} + {!!d.address && 🏠 {d.address}} + + {d.source === "master" ? "directory" : "transactions"} + {d.tags.map((t) => ( + {t} + ))} + + + ))} + + ); +} + +/* ---------------- Actions (danger zone) ---------------- */ + +function ActionsSection({ password, onRelock }: { password: string; onRelock: () => void }) { + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(false); + const [msg, setMsg] = useState(""); + const [confirm, setConfirm] = useState(null); + const [busy, setBusy] = useState(false); + const [newTickets, setNewTickets] = useState(""); + const [newAudit, setNewAudit] = useState(""); + + const refresh = useCallback(async () => { + setLoading(true); + try { + setStatus(await adminStatus(password)); + } catch (e: any) { + if (e instanceof AuthError) onRelock(); + } finally { + setLoading(false); + } + }, [password, onRelock]); + + // Load status the first time this section renders. + useEffect(() => { + refresh(); + }, [refresh]); + + async function doWipe() { + setBusy(true); + setMsg(""); + try { + const r = await adminWipe(password); + setMsg(`✓ Wiped ${r.ticketsDeleted} tickets and ${r.auditDeleted} audit rows.`); + setConfirm(null); + refresh(); + } catch (e: any) { + if (e instanceof AuthError) onRelock(); + else setMsg(e?.message ?? "Wipe failed"); + } finally { + setBusy(false); + } + } + + async function doSwitch() { + if (!newTickets.trim()) return; + setBusy(true); + setMsg(""); + try { + const r = await adminSwitchTable(password, newTickets.trim(), newAudit.trim() || undefined); + setMsg(`✓ Now using tickets table ${r.tickets.tableId}.`); + setConfirm(null); + setNewTickets(""); + setNewAudit(""); + refresh(); + } catch (e: any) { + if (e instanceof AuthError) onRelock(); + else setMsg(e?.message ?? "Switch failed"); + } finally { + setBusy(false); + } + } + + return ( + + Actions + Event-management tools. These change live data — read the warnings. + + {/* Current status */} + + + Active event table + + {loading ? "…" : "↻"} + + + {status ? ( + <> + tickets: {status.tickets.tableId} · {status.tickets.count} records + audit: {status.audit.tableId ?? "—"} · {status.audit.count} records + + ) : ( + {loading ? "loading…" : "—"} + )} + + + {!!msg && {msg}} + + {/* Wipe slate */} + + 🧹 Wipe the slate clean + + Permanently deletes every ticket and every check-in in the active event + table. Use this to reset before a run-through or a fresh event. + + • Does NOT affect donor data. + • Cannot be undone. + { setMsg(""); setConfirm("wipe"); }}> + Wipe slate… + + + + {/* Switch table */} + + 🔀 Switch event table + + Point the scanner at a different NocoDB table — e.g. to start a new event on + a fresh table while keeping the current one intact. + + • Create the new table first (duplicate the current one's structure in NocoDB — keep the Id column). + • The current event's data is NOT deleted, just no longer shown. + New tickets table ID + + New audit table ID (optional) + + { setMsg(""); setConfirm("switch"); }} disabled={!newTickets.trim()}> + Switch table… + + + + {confirm === "wipe" && ( + setConfirm(null)} + /> + )} + {confirm === "switch" && ( + setConfirm(null)} + /> + )} + + ); +} + +function ConfirmModal({ + title, + lines, + confirmLabel, + busy, + onConfirm, + onCancel, +}: { + title: string; + lines: string[]; + confirmLabel: string; + busy: boolean; + onConfirm: () => void; + onCancel: () => void; +}) { + return ( + + + ⚠️ + {title} + {lines.map((l, i) => ( + {l} + ))} + + {busy ? : {confirmLabel}} + + + Cancel + + + + ); +} + +function money(n: number): string { + return "$" + Math.round(n).toLocaleString(); +} + const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: theme.bg }, - topbar: { - flexDirection: "row", - alignItems: "center", - justifyContent: "space-between", - paddingHorizontal: 16, - paddingVertical: 10, - }, + topbar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16, paddingVertical: 10 }, brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" }, - link: { color: theme.textDim, fontSize: 16, fontWeight: "600", width: 72 }, + + body: { flex: 1, flexDirection: "row" }, + sidebar: { width: 84, backgroundColor: theme.card, borderRightWidth: 1, borderRightColor: theme.cardBorder, paddingTop: 8 }, + navItem: { paddingVertical: 14, alignItems: "center", gap: 4, borderLeftWidth: 3, borderLeftColor: "transparent" }, + navItemOn: { backgroundColor: theme.bg, borderLeftColor: theme.primary }, + navIcon: { fontSize: 22 }, + navLabel: { color: theme.textDim, fontSize: 11, fontWeight: "700", textAlign: "center", lineHeight: 13 }, + navLabelOn: { color: theme.text }, + content: { flex: 1 }, + pad: { padding: 16, paddingBottom: 48 }, + + h1: { color: theme.text, fontSize: 22, fontWeight: "800", marginBottom: 2 }, + sub: { color: theme.textDim, fontSize: 13, lineHeight: 19, marginBottom: 8 }, lead: { color: theme.textDim, fontSize: 15, lineHeight: 21, marginBottom: 8 }, - label: { color: theme.textDim, fontSize: 13, marginTop: 16, marginBottom: 6 }, - input: { - backgroundColor: theme.card, - borderWidth: 1, - borderColor: theme.cardBorder, - borderRadius: 12, - paddingHorizontal: 14, - paddingVertical: 14, - color: theme.text, - fontSize: 16, - }, + label: { color: theme.textDim, fontSize: 13, marginTop: 14, marginBottom: 6 }, + input: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12, color: theme.text, fontSize: 16, marginBottom: 2 }, error: { color: theme.dangerBright, marginTop: 12, fontSize: 14, fontWeight: "600" }, - btn: { - backgroundColor: theme.successBright, - borderRadius: 13, - paddingVertical: 15, - alignItems: "center", - marginTop: 20, - }, + msg: { color: theme.successBright, marginTop: 10, fontSize: 14, fontWeight: "700" }, + bold: { fontWeight: "800", color: theme.text }, + + btn: { backgroundColor: theme.successBright, borderRadius: 13, paddingVertical: 15, alignItems: "center", marginTop: 18 }, btnOff: { opacity: 0.4 }, btnText: { color: "#06210f", fontSize: 18, fontWeight: "800" }, types: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, - typePill: { - backgroundColor: theme.card, - borderWidth: 1, - borderColor: theme.cardBorder, - borderRadius: 999, - paddingHorizontal: 14, - paddingVertical: 9, - }, + typePill: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 999, paddingHorizontal: 13, paddingVertical: 8 }, typePillOn: { backgroundColor: theme.primary, borderColor: theme.primary }, - typePillText: { color: theme.textDim, fontSize: 14, fontWeight: "700" }, + typePillText: { color: theme.textDim, fontSize: 13, fontWeight: "700" }, typePillTextOn: { color: "#fff" }, - result: { - marginTop: 22, - alignItems: "center", - backgroundColor: theme.card, - borderWidth: 1, - borderColor: theme.cardBorder, - borderRadius: 16, - padding: 20, - }, - qr: { width: 220, height: 220, backgroundColor: "#fff", borderRadius: 10 }, + result: { marginTop: 22, alignItems: "center", backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 16, padding: 20 }, + qr: { width: 200, height: 200, backgroundColor: "#fff", borderRadius: 10 }, rcode: { color: theme.successBright, fontSize: 22, fontWeight: "800", letterSpacing: 2, marginTop: 12 }, rwho: { color: theme.text, fontSize: 16, marginTop: 4 }, rmail: { color: theme.textDim, fontSize: 13, marginTop: 8 }, + + searchRow: { flexDirection: "row", gap: 8, alignItems: "center", marginTop: 8 }, + searchBtn: { backgroundColor: theme.primary, borderRadius: 12, paddingHorizontal: 16, paddingVertical: 13 }, + searchBtnText: { color: "#fff", fontWeight: "800", fontSize: 15 }, + donorCard: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 12, padding: 14, marginTop: 12 }, + donorHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, + donorName: { color: theme.text, fontSize: 17, fontWeight: "800", flex: 1 }, + donorAmt: { color: theme.successBright, fontSize: 16, fontWeight: "800", marginLeft: 8 }, + donorLine: { color: theme.textDim, fontSize: 14, marginTop: 3 }, + donorTags: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 8, alignItems: "center" }, + donorSource: { color: theme.textDim, fontSize: 11, fontWeight: "700", textTransform: "uppercase", backgroundColor: theme.bg, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 6, paddingHorizontal: 6, paddingVertical: 2 }, + donorTag: { color: theme.text, fontSize: 12, backgroundColor: theme.primaryDark, borderRadius: 6, paddingHorizontal: 7, paddingVertical: 2 }, + + statusBox: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 12, padding: 14, marginTop: 4 }, + statusRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, + statusLabel: { color: theme.textDim, fontSize: 12, fontWeight: "700", textTransform: "uppercase", letterSpacing: 0.5 }, + refresh: { color: theme.text, fontSize: 20 }, + statusVal: { color: theme.text, fontSize: 14, marginTop: 6, fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace" }, + + dangerCard: { backgroundColor: "#241717", borderWidth: 1, borderColor: theme.danger, borderRadius: 14, padding: 16, marginTop: 18 }, + dangerTitle: { color: "#ff9a9a", fontSize: 17, fontWeight: "800", marginBottom: 6 }, + dangerBody: { color: "#e9cfcf", fontSize: 14, lineHeight: 20 }, + dangerBullet: { color: "#d9b8b8", fontSize: 13, lineHeight: 19, marginTop: 4 }, + redBtn: { backgroundColor: theme.dangerBright, borderRadius: 12, paddingVertical: 14, alignItems: "center", marginTop: 16 }, + redBtnText: { color: "#fff", fontSize: 16, fontWeight: "800" }, + + modalScrim: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "rgba(0,0,0,0.72)", alignItems: "center", justifyContent: "center", padding: 24 }, + modalCard: { backgroundColor: "#1a1010", borderWidth: 2, borderColor: theme.dangerBright, borderRadius: 18, padding: 22, width: "100%", maxWidth: 380 }, + modalWarn: { fontSize: 40, textAlign: "center" }, + modalTitle: { color: "#fff", fontSize: 20, fontWeight: "900", textAlign: "center", marginTop: 4, marginBottom: 12 }, + modalLine: { color: "#f0d9d9", fontSize: 14, lineHeight: 20 }, + cancelBtn: { paddingVertical: 14, alignItems: "center", marginTop: 6 }, + cancelText: { color: theme.textDim, fontSize: 16, fontWeight: "700" }, }); diff --git a/app/components/SideMenu.tsx b/app/components/SideMenu.tsx index 522b2c3..890027b 100644 --- a/app/components/SideMenu.tsx +++ b/app/components/SideMenu.tsx @@ -7,8 +7,8 @@ import { theme } from "../lib/theme"; const ITEMS: { label: string; icon: string; route: string; seg: string }[] = [ { label: "Scanner", icon: "📷", route: "/", seg: "" }, { label: "Event report", icon: "📊", route: "/stats", seg: "stats" }, - { label: "Comp tickets", icon: "🎟️", route: "/comp", seg: "comp" }, - { label: "Admin lookup", icon: "🔎", route: "/admin", seg: "admin" }, + { label: "Admin (crush33)", icon: "🔐", route: "/comp", seg: "comp" }, + { label: "Banquet lookup", icon: "🍽️", route: "/admin", seg: "admin" }, ]; export default function SideMenu({ visible, onClose }: { visible: boolean; onClose: () => void }) { diff --git a/app/lib/api.ts b/app/lib/api.ts index 9f9d4f1..d574790 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -252,6 +252,56 @@ export async function portalCreate(input: { return body; } +// ---- Admin actions (all gated by the portal password) ---- + +async function adminPost(path: string, password: string, extra: Record = {}): Promise { + const res = await fetch(`${API_BASE}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password, ...extra }), + }); + if (res.status === 401) throw new AuthError("Wrong password"); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new ApiError(body?.detail ?? body?.error ?? `Request failed (${res.status})`); + return body as T; +} + +export interface AdminStatus { + tickets: { tableId: string; count: number }; + audit: { tableId: string | null; count: number; enabled: boolean }; + defaults: { ticketsTableId: string; auditTableId: string | null }; +} +export function adminStatus(password: string): Promise { + return adminPost("/api/admin/status", password); +} + +export function adminWipe(password: string): Promise<{ ok: boolean; ticketsDeleted: number; auditDeleted: number }> { + return adminPost("/api/admin/wipe", password); +} + +export function adminSwitchTable( + password: string, + ticketsTableId: string, + auditTableId?: string, +): Promise<{ ok: boolean; tickets: { tableId: string }; audit: { tableId: string | null } }> { + return adminPost("/api/admin/switch-table", password, { ticketsTableId, auditTableId }); +} + +export interface DonorSearchResult { + name: string; + bearName: string; + email: string; + altEmail: string; + phone: string; + address: string; + lifetime: number | null; + tags: string[]; + source: "master" | "transactions"; +} +export function adminDonorSearch(password: string, query: string): Promise<{ results: DonorSearchResult[]; query: string }> { + return adminPost("/api/admin/donor-search", password, { query }); +} + export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{ enabled: boolean; entries: AuditEntry[]; diff --git a/backend/src/config.ts b/backend/src/config.ts index 88954cf..944dd0f 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -10,6 +10,10 @@ const schema = z.object({ // Optional "2026 Ticket Audit Logs" table. If unset, audit logging is skipped. NOCODB_AUDIT_TABLE_ID: z.string().optional(), + // Writable dir (mounted volume) for small runtime state — e.g. the active + // event table override set from the admin area, so it survives redeploys. + STATE_DIR: z.string().default("/data"), + // Donor tables for Banquet mode. If the master-list id is unset, banquet is // disabled. Online/offline are used as a fallback when a donor is not in the // master list. diff --git a/backend/src/context.ts b/backend/src/context.ts index fcf2c43..9bf2e2c 100644 --- a/backend/src/context.ts +++ b/backend/src/context.ts @@ -4,6 +4,7 @@ import { Mailer } from "./services/mailer.js"; import { RedeemQueue } from "./services/redeemQueue.js"; import { AuditLogger } from "./services/audit.js"; import { DonorService } from "./services/donors.js"; +import { loadActiveTables } from "./services/state.js"; /** Shared services wired once at startup and hung off the Fastify instance. */ export interface AppContext { @@ -16,12 +17,23 @@ export interface AppContext { } export function buildContext(config: Config): AppContext { + const nocodb = new NocoDBClient(config); + const audit = new AuditLogger(config); + + // Apply a persisted "active event table" override (set from the admin area), + // so switching the event survives redeploys without editing .env. + const override = loadActiveTables(config.STATE_DIR); + if (override) { + nocodb.setTableId(override.ticketsTableId); + audit.setTableId(override.auditTableId ?? null); + } + return { config, - nocodb: new NocoDBClient(config), + nocodb, mailer: new Mailer(config), queue: new RedeemQueue(), - audit: new AuditLogger(config), + audit, donors: new DonorService(config), }; } diff --git a/backend/src/routes/admin.ts b/backend/src/routes/admin.ts new file mode 100644 index 0000000..c2fc5a7 --- /dev/null +++ b/backend/src/routes/admin.ts @@ -0,0 +1,122 @@ +import { timingSafeEqual } from "node:crypto"; +import type { FastifyInstance } from "fastify"; +import { saveActiveTables } from "../services/state.js"; + +function safeEqual(a: string, b: string): boolean { + const ba = Buffer.from(a || ""); + const bb = Buffer.from(b || ""); + if (ba.length !== bb.length) return false; + return timingSafeEqual(ba, bb); +} + +/** + * Admin actions for the /crush33 area — all gated by the same PORTAL_PASSWORD + * that unlocks the portal. POST-only so the password never lands in a URL/log. + * + * POST /api/admin/status -> current event tables + record counts + * POST /api/admin/wipe -> delete all ticket + audit records + * POST /api/admin/switch-table -> point the app at different event table(s) + * POST /api/admin/donor-search -> admin-only donor directory search (PII) + */ +export async function adminRoutes(app: FastifyInstance): Promise { + const cfg = app.ctx.config; + + const gate = (req: any, reply: any): boolean => { + if (!cfg.PORTAL_PASSWORD) { + reply.code(404).send({ error: "admin_disabled" }); + return false; + } + const pw = (req.body ?? {}).password; + if (typeof pw !== "string" || !safeEqual(pw, cfg.PORTAL_PASSWORD)) { + reply.code(401).send({ error: "bad_password" }); + return false; + } + return true; + }; + + const rl = { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }; + + app.post("/api/admin/status", rl, async (req, reply) => { + if (!gate(req, reply)) return; + const [tickets, audit] = await Promise.all([ + app.ctx.nocodb.count().catch(() => -1), + app.ctx.audit.count().catch(() => -1), + ]); + return { + tickets: { tableId: app.ctx.nocodb.tableId, count: tickets }, + audit: { tableId: app.ctx.audit.currentTableId, count: audit, enabled: app.ctx.audit.enabled }, + // What .env would use if the override were cleared (for reference). + defaults: { ticketsTableId: cfg.NOCODB_TABLE_ID, auditTableId: cfg.NOCODB_AUDIT_TABLE_ID ?? null }, + }; + }); + + app.post("/api/admin/wipe", rl, async (req, reply) => { + if (!gate(req, reply)) return; + let ticketsDeleted = 0; + let auditDeleted = 0; + try { + ticketsDeleted = await app.ctx.nocodb.deleteAll(); + } catch (e: any) { + return reply.code(502).send({ error: "wipe_failed", detail: e?.message }); + } + try { + auditDeleted = await app.ctx.audit.deleteAll(); + } catch { + // Audit wipe is best-effort; tickets are the important part. + } + req.log.warn({ ticketsDeleted, auditDeleted }, "admin: wiped slate"); + return { ok: true, ticketsDeleted, auditDeleted }; + }); + + app.post("/api/admin/switch-table", rl, async (req, reply) => { + if (!gate(req, reply)) return; + const b = (req.body ?? {}) as { ticketsTableId?: string; auditTableId?: string }; + const ticketsTableId = String(b.ticketsTableId ?? "").trim(); + const auditTableId = String(b.auditTableId ?? "").trim(); + if (!ticketsTableId) { + return reply.code(400).send({ error: "missing_tickets_table" }); + } + + // Validate the new tickets table is reachable and has an Id primary key — + // switching to a PK-less table would make check-in updates hit every row. + const probe = await app.ctx.nocodb.probeTable(ticketsTableId); + if (!probe.ok) { + return reply.code(400).send({ error: "tickets_table_unreachable", status: probe.status }); + } + if (!probe.hasIdPk) { + return reply.code(400).send({ error: "tickets_table_no_id_pk" }); + } + if (auditTableId) { + const ap = await app.ctx.nocodb.probeTable(auditTableId); + if (!ap.ok) return reply.code(400).send({ error: "audit_table_unreachable", status: ap.status }); + } + + // Hot-swap the live clients, then persist so it survives a redeploy. + app.ctx.nocodb.setTableId(ticketsTableId); + app.ctx.audit.setTableId(auditTableId || app.ctx.audit.currentTableId); + saveActiveTables(cfg.STATE_DIR, { + ticketsTableId, + auditTableId: auditTableId || app.ctx.audit.currentTableId || undefined, + }); + req.log.warn({ ticketsTableId, auditTableId }, "admin: switched event table"); + return { + ok: true, + tickets: { tableId: app.ctx.nocodb.tableId }, + audit: { tableId: app.ctx.audit.currentTableId }, + }; + }); + + app.post("/api/admin/donor-search", rl, async (req, reply) => { + if (!gate(req, reply)) return; + if (!app.ctx.donors.enabled) return reply.code(404).send({ error: "donors_unavailable" }); + const q = String(((req.body ?? {}) as { query?: string }).query ?? "").trim(); + if (q.length < 2) return { results: [], query: q }; + try { + const results = await app.ctx.donors.search(q, 40); + return { results, query: q }; + } catch (e: any) { + req.log.error({ err: e }, "admin: donor search failed"); + return reply.code(502).send({ error: "search_failed", detail: e?.message }); + } + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 7895fe1..685e1a7 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -16,6 +16,7 @@ import { installRoutes } from "./routes/install.js"; import { webhookDocRoutes } from "./routes/webhookDoc.js"; import { publicLookupRoutes } from "./routes/publicLookup.js"; import { portalRoutes } from "./routes/portal.js"; +import { adminRoutes } from "./routes/admin.js"; export async function build() { const config = loadConfig(); @@ -40,6 +41,7 @@ export async function build() { await app.register(webhookDocRoutes); await app.register(publicLookupRoutes); await app.register(portalRoutes); + await app.register(adminRoutes); // Serve the exported Expo web build (if present) with SPA fallback. const webDir = config.WEB_DIR ?? join(process.cwd(), "web"); diff --git a/backend/src/services/audit.ts b/backend/src/services/audit.ts index 2782958..c6b3a29 100644 --- a/backend/src/services/audit.ts +++ b/backend/src/services/audit.ts @@ -34,7 +34,7 @@ export interface AuditRow extends AuditEntry { export class AuditLogger { private readonly base: string; private readonly token: string; - private readonly tableId: string | null; + private tableId: string | null; constructor(cfg: Pick) { this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, ""); @@ -46,10 +46,56 @@ export class AuditLogger { return this.tableId !== null; } + /** The audit table id (switchable at runtime by the admin action). */ + get currentTableId(): string | null { + return this.tableId; + } + setTableId(id: string | null): void { + this.tableId = id || null; + } + private get url(): string { return `${this.base}/api/v2/tables/${this.tableId}/records`; } + /** Total audit row count (cheap — reads pageInfo). */ + async count(): Promise { + if (!this.tableId) return 0; + const url = new URL(this.url); + url.searchParams.set("limit", "1"); + const res = await fetch(url.toString(), { + headers: { "xc-token": this.token, "Content-Type": "application/json" }, + }); + if (!res.ok) return 0; + const body: any = await res.json().catch(() => ({})); + return body?.pageInfo?.totalRows ?? (body?.list?.length ?? 0); + } + + /** Delete every audit row in the current table. Returns the count deleted. */ + async deleteAll(): Promise { + if (!this.tableId) return 0; + let total = 0; + for (;;) { + const url = new URL(this.url); + url.searchParams.set("limit", "1000"); + url.searchParams.set("fields", "Id"); + const res = await fetch(url.toString(), { + headers: { "xc-token": this.token, "Content-Type": "application/json" }, + }); + if (!res.ok) break; + const body: any = await res.json().catch(() => ({})); + const list = body?.list ?? []; + if (!list.length) break; + await fetch(this.url, { + method: "DELETE", + headers: { "xc-token": this.token, "Content-Type": "application/json" }, + body: JSON.stringify(list.map((r: any) => ({ Id: r.Id }))), + }); + total += list.length; + } + return total; + } + async log(entry: AuditEntry): Promise { if (!this.tableId) return; const sign = entry.people >= 0 ? "+" : ""; diff --git a/backend/src/services/donors.ts b/backend/src/services/donors.ts index 15909f0..46e5e53 100644 --- a/backend/src/services/donors.ts +++ b/backend/src/services/donors.ts @@ -1,5 +1,17 @@ import type { Config } from "../config.js"; +export interface DonorSearchResult { + name: string; + bearName: string; + email: string; + altEmail: string; + phone: string; + address: string; + lifetime: number | null; + tags: string[]; + source: "master" | "transactions"; +} + export interface DonorLookup { found: boolean; email: string; @@ -157,6 +169,67 @@ export class DonorService { }; } + /** + * Admin-only free-text donor search across the master list + transaction + * tables. Matches the query (substring, case-insensitive) against any + * name / email / phone / address / bear-name column each table exposes — + * columns are discovered from a sample row so it adapts to the schema. + * Results are de-duped by email (then name). PRIVACY: gate this to admins. + */ + async search(rawQuery: string, limit = 40): Promise { + const q = rawQuery.trim(); + if (!q || !this.enabled) return []; + const tables: { id: string | null; source: "master" | "transactions" }[] = [ + { id: this.masterId, source: "master" }, + { id: this.onlineId, source: "transactions" }, + { id: this.offlineId, source: "transactions" }, + ]; + const out = new Map(); + for (const t of tables) { + if (!t.id || out.size >= limit) continue; + let rows: any[]; + try { + rows = await this.searchTable(t.id, q, limit); + } catch { + continue; // a table without matching columns / transient error — skip + } + for (const r of rows) { + const res = mapDonorRow(r, t.source); + const key = (res.email || res.name || JSON.stringify(r)).toLowerCase(); + const existing = out.get(key); + // Prefer the master-list record (richer) when the same donor appears twice. + if (!existing || (existing.source === "transactions" && res.source === "master")) { + out.set(key, existing ? { ...res, lifetime: res.lifetime ?? existing.lifetime } : res); + } + if (out.size >= limit) break; + } + } + return [...out.values()].slice(0, limit); + } + + private colCache = new Map(); + + /** Discover the text columns worth searching (name/contact) from a sample row. */ + private async searchableColumns(tableId: string): Promise { + const cached = this.colCache.get(tableId); + if (cached) return cached; + const sample = await this.list(tableId, "", 1); + const keys = sample.length ? Object.keys(sample[0]) : []; + const want = /name|email|phone|mobile|cell|address|street|city|state|zip|postal|province|country|bear/i; + const skip = /[(),]/; // field names with filter-grammar chars can't be queried + const cols = keys.filter((k) => want.test(k) && !skip.test(k)); + this.colCache.set(tableId, cols); + return cols; + } + + private async searchTable(tableId: string, q: string, limit: number): Promise { + const cols = await this.searchableColumns(tableId); + if (!cols.length) return []; + const esc = q.replace(/[(),]/g, " "); + const where = cols.map((c) => `(${c},like,%${esc}%)`).join("~or"); + return this.list(tableId, where, limit); + } + /** * Total Paid donations for an email on/after `cutoff`, summed from the * transaction tables (the only dated source). Used for ticket-voucher @@ -183,6 +256,40 @@ function num(v: unknown): number { return Number.isFinite(n) ? n : 0; } +/** First non-empty value whose column name matches `rx`. */ +function pick(row: any, rx: RegExp): string { + for (const k of Object.keys(row)) if (rx.test(k) && row[k] != null && row[k] !== "") return String(row[k]); + return ""; +} +/** Join all non-empty values whose column name matches `rx` (e.g. address parts). */ +function pickAll(row: any, rx: RegExp): string { + const parts: string[] = []; + for (const k of Object.keys(row)) if (rx.test(k) && row[k] != null && row[k] !== "") parts.push(String(row[k])); + return [...new Set(parts)].join(", "); +} + +function mapDonorRow(r: any, source: "master" | "transactions"): DonorSearchResult { + const name = + r["Display Name"] || + r["Name"] || + [r["First Name"], r["Last Name"]].filter(Boolean).join(" ") || + r["Bear Name"] || + pick(r, /name/i) || + ""; + const lifetimeRaw = r["Total Donations"]; + return { + name: String(name), + bearName: String(r["Bear Name"] ?? ""), + email: String(r["Email"] ?? pick(r, /email/i)), + altEmail: String(r["Alternate Email"] ?? ""), + phone: pick(r, /phone|mobile|cell/i), + address: pickAll(r, /address|street|city|state|zip|postal|province|country/i), + lifetime: lifetimeRaw !== undefined && lifetimeRaw !== null && lifetimeRaw !== "" ? num(lifetimeRaw) : null, + tags: splitTags(r["Tags"]), + source, + }; +} + // Count a transaction unless it's explicitly not paid (refunded/failed/pending). function isPaid(row: any): boolean { const s = String(row["Payment Status"] ?? "").trim(); diff --git a/backend/src/services/nocodb.ts b/backend/src/services/nocodb.ts index f26f452..9e3416e 100644 --- a/backend/src/services/nocodb.ts +++ b/backend/src/services/nocodb.ts @@ -8,16 +8,24 @@ import { COL, type NocoRecord } from "../fields.js"; export class NocoDBClient { private readonly base: string; private readonly token: string; - private readonly tableId: string; + private _tableId: string; constructor(cfg: Pick) { this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, ""); this.token = cfg.NOCODB_API_TOKEN; - this.tableId = cfg.NOCODB_TABLE_ID; + this._tableId = cfg.NOCODB_TABLE_ID; + } + + /** The table this client currently reads/writes (switchable at runtime). */ + get tableId(): string { + return this._tableId; + } + setTableId(id: string): void { + this._tableId = id; } private get recordsUrl(): string { - return `${this.base}/api/v2/tables/${this.tableId}/records`; + return `${this.base}/api/v2/tables/${this._tableId}/records`; } private async request(url: string, init: RequestInit = {}): Promise { @@ -138,6 +146,47 @@ export class NocoDBClient { return out; } + /** Total record count in the current table (cheap — reads pageInfo). */ + async count(): Promise { + const url = new URL(this.recordsUrl); + url.searchParams.set("limit", "1"); + const body = await this.request(url.toString()); + return body?.pageInfo?.totalRows ?? (body?.list?.length ?? 0); + } + + /** Delete every record in the current table (paginated bulk delete). Returns + * the number deleted. Used by the admin "wipe slate" action. */ + async deleteAll(): Promise { + let total = 0; + for (;;) { + const rows = await this.list("", 1000); + if (!rows.length) break; + const ids = rows.map((r) => ({ Id: (r as any).Id })); + await this.request(this.recordsUrl, { method: "DELETE", body: JSON.stringify(ids) }); + total += rows.length; + } + return total; + } + + /** Reachability + primary-key probe for a candidate table id (admin switch). + * Returns { ok, hasIdPk }. hasIdPk is false only if rows exist without an Id. */ + async probeTable(tableId: string): Promise<{ ok: boolean; hasIdPk: boolean; status: number }> { + const url = new URL(`${this.base}/api/v2/tables/${tableId}/records`); + url.searchParams.set("limit", "1"); + try { + const res = await fetch(url.toString(), { + headers: { "xc-token": this.token, "Content-Type": "application/json" }, + }); + if (!res.ok) return { ok: false, hasIdPk: false, status: res.status }; + const body: any = await res.json().catch(() => ({})); + const list = body?.list ?? []; + const hasIdPk = list.length === 0 || "Id" in list[0]; + return { ok: true, hasIdPk, status: 200 }; + } catch { + return { ok: false, hasIdPk: false, status: 0 }; + } + } + /** Cheap connectivity probe for healthchecks. */ async ping(): Promise { const url = new URL(this.recordsUrl); diff --git a/backend/src/services/state.ts b/backend/src/services/state.ts new file mode 100644 index 0000000..b2cde2a --- /dev/null +++ b/backend/src/services/state.ts @@ -0,0 +1,32 @@ +import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Tiny persisted state, stored as JSON on a mounted volume (STATE_DIR). Used for + * the admin "switch event table" action so the choice survives a redeploy — + * otherwise the app would revert to the .env table IDs on every restart. + */ +export interface ActiveTables { + ticketsTableId: string; + auditTableId?: string; +} + +const FILE = "active-tables.json"; + +export function loadActiveTables(dir: string): ActiveTables | null { + try { + const raw = readFileSync(join(dir, FILE), "utf8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.ticketsTableId === "string" && parsed.ticketsTableId) { + return { ticketsTableId: parsed.ticketsTableId, auditTableId: parsed.auditTableId || undefined }; + } + } catch { + // No override or unreadable — fall back to .env config. + } + return null; +} + +export function saveActiveTables(dir: string, tables: ActiveTables): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, FILE), JSON.stringify(tables, null, 2), "utf8"); +} diff --git a/docker-compose.yml b/docker-compose.yml index 3668405..dc62d8d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,4 +19,11 @@ services: # host.docker.internal resolves to the host gateway. extra_hosts: - "host.docker.internal:host-gateway" + # Small writable volume for runtime state (the active event-table override + # set from the admin area), so it survives redeploys. + volumes: + - camptickets-data:/data restart: unless-stopped + +volumes: + camptickets-data: diff --git a/scripts/switch-event.sh b/scripts/switch-event.sh new file mode 100755 index 0000000..862ad54 --- /dev/null +++ b/scripts/switch-event.sh @@ -0,0 +1,73 @@ +#!/usr/bin/env bash +set -euo pipefail +# +# switch-event.sh — point the scanner app at a DIFFERENT NocoDB tickets (and +# optionally audit) table, e.g. to start a NEW event on a fresh table while +# keeping the old table intact for archive. Backs up backend/.env, updates it, +# and restarts the app container. The old table is never touched. +# +# Usage: +# scripts/switch-event.sh [AUDIT_TABLE_ID] +# +# FIRST create the new table(s): in the NocoDB UI, DUPLICATE the current table +# with "structure only" (no records). That preserves every column AND the Id +# primary key — critical, because updates against a table with no primary key +# would hit every row. Then grab the new table id from its URL/API and pass it +# here. (The app also fail-safes: it refuses to update a row that has no Id.) +# +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +ENV_FILE="$ROOT/backend/.env" +CONTAINER="${CONTAINER:-camptickets}" + +NEW_TICKETS="${1:-}" +NEW_AUDIT="${2:-}" +[ -n "$NEW_TICKETS" ] || { echo "Usage: $0 [AUDIT_TABLE_ID]" >&2; exit 1; } + +get() { grep -E "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2-; } +BASE_URL="$(get NOCODB_BASE_URL)" +TOKEN="$(get NOCODB_API_TOKEN)" +CUR_TICKETS="$(get NOCODB_TABLE_ID)" +CUR_AUDIT="$(get NOCODB_AUDIT_TABLE_ID)" + +# Validate a table is reachable and (if it has rows) exposes an Id primary key. +check() { + local table="$1" tmp http + tmp="$(mktemp)" + http="$(curl -s -o "$tmp" -w '%{http_code}' -H "xc-token: $TOKEN" \ + "$BASE_URL/api/v2/tables/$table/records?limit=1")" + if [ "$http" != "200" ]; then + echo " ✗ $table not reachable (HTTP $http)"; rm -f "$tmp"; return 1 + fi + if ! python3 -c 'import sys,json; l=json.load(open(sys.argv[1]))["list"]; sys.exit(0 if (not l or "Id" in l[0]) else 1)' "$tmp"; then + echo " ✗ $table has rows without an Id primary key — refusing"; rm -f "$tmp"; return 1 + fi + rm -f "$tmp"; echo " ✓ $table reachable" +} + +echo "Validating new table(s) on $BASE_URL ..." +check "$NEW_TICKETS" || exit 1 +[ -n "$NEW_AUDIT" ] && { check "$NEW_AUDIT" || exit 1; } + +BK="$ENV_FILE.bak.$(date +%Y%m%d-%H%M%S)" +cp "$ENV_FILE" "$BK" +echo "Backed up env -> $BK" + +echo "Switching tables:" +echo " tickets: $CUR_TICKETS -> $NEW_TICKETS" +sed -i -E "s|^NOCODB_TABLE_ID=.*|NOCODB_TABLE_ID=$NEW_TICKETS|" "$ENV_FILE" +if [ -n "$NEW_AUDIT" ]; then + echo " audit: $CUR_AUDIT -> $NEW_AUDIT" + sed -i -E "s|^NOCODB_AUDIT_TABLE_ID=.*|NOCODB_AUDIT_TABLE_ID=$NEW_AUDIT|" "$ENV_FILE" +else + echo " audit: unchanged ($CUR_AUDIT) — pass a second arg to switch it too" +fi + +echo "Restarting $CONTAINER ..." +( cd "$ROOT" && docker compose up -d --force-recreate >/dev/null ) +sleep 3 + +echo "Now active:" +echo " NOCODB_TABLE_ID=$(get NOCODB_TABLE_ID)" +echo " NOCODB_AUDIT_TABLE_ID=$(get NOCODB_AUDIT_TABLE_ID)" +echo "Old tickets table $CUR_TICKETS kept intact. (env backup: $BK)" diff --git a/scripts/wipe-slate.sh b/scripts/wipe-slate.sh new file mode 100755 index 0000000..58fc3a8 --- /dev/null +++ b/scripts/wipe-slate.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +set -euo pipefail +# +# wipe-slate.sh — clear ALL ticket + audit records from the tables the scanner +# app currently uses, for a clean event run-through. Leaves the table SCHEMAS +# intact and does NOT touch donor data. Reads NocoDB creds from backend/.env. +# +# Usage: +# scripts/wipe-slate.sh # prompts for confirmation +# scripts/wipe-slate.sh --yes # skip the prompt (for automation) +# +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="${ENV_FILE:-$SCRIPT_DIR/../backend/.env}" + +get() { grep -E "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2-; } +BASE_URL="$(get NOCODB_BASE_URL)" +TOKEN="$(get NOCODB_API_TOKEN)" +TICKETS="$(get NOCODB_TABLE_ID)" +AUDIT="$(get NOCODB_AUDIT_TABLE_ID)" + +[ -n "$BASE_URL" ] && [ -n "$TOKEN" ] && [ -n "$TICKETS" ] || { + echo "Missing NocoDB config in $ENV_FILE" >&2; exit 1; } + +YES=0 +case "${1:-}" in -y|--yes) YES=1;; esac + +count() { + curl -s -H "xc-token: $TOKEN" "$BASE_URL/api/v2/tables/$1/records?limit=1" \ + | python3 -c 'import sys,json;print(json.load(sys.stdin).get("pageInfo",{}).get("totalRows",0))' +} + +echo "Target: $BASE_URL" +echo " tickets ($TICKETS): $(count "$TICKETS") records" +[ -n "$AUDIT" ] && echo " audit ($AUDIT): $(count "$AUDIT") records" + +if [ "$YES" -ne 1 ]; then + read -rp "Delete ALL of the above? This cannot be undone. [y/N] " ans + case "$ans" in y|Y|yes|YES) ;; *) echo "aborted"; exit 1;; esac +fi + +wipe() { + local label="$1" table="$2" total=0 ids n + while :; do + ids="$(curl -s -H "xc-token: $TOKEN" "$BASE_URL/api/v2/tables/$table/records?limit=1000&fields=Id" \ + | python3 -c 'import sys,json;print(json.dumps([{"Id":r["Id"]} for r in json.load(sys.stdin)["list"]]))')" + n="$(printf '%s' "$ids" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)))')" + [ "$n" -eq 0 ] && break + curl -s -o /dev/null -X DELETE -H "xc-token: $TOKEN" -H "Content-Type: application/json" \ + "$BASE_URL/api/v2/tables/$table/records" --data "$ids" + total=$((total + n)) + done + echo " $label: deleted $total" +} + +wipe "tickets" "$TICKETS" +[ -n "$AUDIT" ] && wipe "audit" "$AUDIT" +echo "Done — slate is clean." From 1c8cb47209fc2694350b6fca424d73a0ddfb7e1f Mon Sep 17 00:00:00 2001 From: Hank Date: Thu, 23 Jul 2026 01:49:15 +0000 Subject: [PATCH 35/37] Move admin hub into the /crush33 page; drop the /comp app route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The admin area belongs at /crush33 (the standalone, portal-password page — no app login), not an in-app /comp route I'd added unasked. - Rebuilt the /crush33 page into the full hub: password unlock → sidebar (Comp tickets · Donor lookup · Actions). Vanilla JS calling the same /api/portal + /api/admin endpoints. Actions has the danger cards + an "are you sure" modal spelling out exactly what happens. - Deleted app/app/comp.tsx (removes the /comp route). - Drawer "Admin (crush33)" now opens the /crush33 web page (Linking) instead of routing to /comp. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/app/comp.tsx | 545 ----------------------------------- app/components/SideMenu.tsx | 15 +- backend/src/routes/portal.ts | 381 ++++++++++++++++++++---- 3 files changed, 328 insertions(+), 613 deletions(-) delete mode 100644 app/app/comp.tsx diff --git a/app/app/comp.tsx b/app/app/comp.tsx deleted file mode 100644 index 648af1e..0000000 --- a/app/app/comp.tsx +++ /dev/null @@ -1,545 +0,0 @@ -import { useState, useCallback, useEffect } from "react"; -import { - StyleSheet, - View, - Text, - TextInput, - Pressable, - ScrollView, - Image, - KeyboardAvoidingView, - Platform, - ActivityIndicator, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { - portalVerify, - portalCreate, - adminStatus, - adminWipe, - adminSwitchTable, - adminDonorSearch, - AuthError, - type PortalTicket, - type AdminStatus, - type DonorSearchResult, -} from "../lib/api"; -import { useAuth } from "../lib/auth"; -import { useMenu } from "../lib/menu"; -import { theme } from "../lib/theme"; - -const TYPES = ["Guest", "Worker", "Performer", "Volunteer", "Speaker"]; -const TYPE_ICON: Record = { - Guest: "🎫", - Worker: "🛠️", - Performer: "🎭", - Volunteer: "🙌", - Speaker: "🎤", -}; - -type Section = "comp" | "donors" | "actions"; -const NAV: { key: Section; icon: string; label: string }[] = [ - { key: "comp", icon: "🎟️", label: "Comp\ntickets" }, - { key: "donors", icon: "🔎", label: "Donor\nlookup" }, - { key: "actions", icon: "⚠️", label: "Actions" }, -]; - -export default function AdminHub() { - const { operator } = useAuth(); - const { open: openMenu } = useMenu(); - const [password, setPassword] = useState(""); - const [unlocked, setUnlocked] = useState(false); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [section, setSection] = useState
    ("comp"); - - const relock = useCallback(() => { - setUnlocked(false); - setError("Password changed — unlock again."); - }, []); - - async function unlock() { - if (!password || busy) return; - setBusy(true); - setError(""); - try { - await portalVerify(password); - setUnlocked(true); - } catch (e: any) { - setError(e instanceof AuthError ? "Wrong password" : e?.message ?? "Failed"); - } finally { - setBusy(false); - } - } - - return ( - - - - - - Admin · crush33 - - - - {!unlocked ? ( - - - Admin-only area. Enter the shared portal password to unlock. - Portal password - - {!!error && {error}} - - {busy ? "Checking…" : "Unlock"} - - - - ) : ( - - - {NAV.map((n) => { - const active = section === n.key; - return ( - setSection(n.key)}> - {n.icon} - {n.label} - - ); - })} - - - - - {section === "comp" && } - {section === "donors" && } - {section === "actions" && } - - - - )} - - ); -} - -/* ---------------- Comp tickets ---------------- */ - -function CompSection({ password, operator, onRelock }: { password: string; operator: string | null; onRelock: () => void }) { - const [type, setType] = useState("Guest"); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [result, setResult] = useState(null); - - async function create() { - if (!name.trim() || !email.trim() || busy) return; - setBusy(true); - setError(""); - try { - const r = await portalCreate({ password, name: name.trim(), email: email.trim(), type, createdBy: operator ?? "" }); - setResult(r); - setName(""); - setEmail(""); - } catch (e: any) { - if (e instanceof AuthError) onRelock(); - else setError(e?.message ?? "Failed to create ticket"); - } finally { - setBusy(false); - } - } - - return ( - - Comp tickets - Entry-only tickets for guests & staff. - - Ticket type - - {TYPES.map((t) => ( - setType(t)}> - {(TYPE_ICON[t] ?? "🎫") + " " + t} - - ))} - - - Full name - - Email - - - {!!error && {error}} - - {busy ? "Creating…" : `Create ${type} ticket`} - - - {result && ( - - - {result.code} - {result.type} · {result.name} - {result.emailSent ? "✓ Emailed the ticket" : "Email not sent — screenshot this QR"} - - )} - - ); -} - -/* ---------------- Donor lookup ---------------- */ - -function DonorSection({ password, onRelock }: { password: string; onRelock: () => void }) { - const [query, setQuery] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(""); - const [results, setResults] = useState(null); - - async function run() { - const q = query.trim(); - if (q.length < 2 || busy) return; - setBusy(true); - setError(""); - try { - const r = await adminDonorSearch(password, q); - setResults(r.results); - } catch (e: any) { - if (e instanceof AuthError) onRelock(); - else setError(e?.message ?? "Search failed"); - } finally { - setBusy(false); - } - } - - return ( - - Donor lookup - 🔒 Admin only · private donor info. Search by name, email, phone, address, bear name… - - - - - {busy ? "…" : "Search"} - - - - {!!error && {error}} - {results !== null && !busy && results.length === 0 && No donors match “{query.trim()}”.} - - {results?.map((d, i) => ( - - - {d.name || d.email || "(unnamed)"} - {d.lifetime != null && {money(d.lifetime)}} - - {!!d.bearName && 🐻 {d.bearName}} - {!!d.email && ✉️ {d.email}} - {!!d.altEmail && ✉️ {d.altEmail} (alt)} - {!!d.phone && 📞 {d.phone}} - {!!d.address && 🏠 {d.address}} - - {d.source === "master" ? "directory" : "transactions"} - {d.tags.map((t) => ( - {t} - ))} - - - ))} - - ); -} - -/* ---------------- Actions (danger zone) ---------------- */ - -function ActionsSection({ password, onRelock }: { password: string; onRelock: () => void }) { - const [status, setStatus] = useState(null); - const [loading, setLoading] = useState(false); - const [msg, setMsg] = useState(""); - const [confirm, setConfirm] = useState(null); - const [busy, setBusy] = useState(false); - const [newTickets, setNewTickets] = useState(""); - const [newAudit, setNewAudit] = useState(""); - - const refresh = useCallback(async () => { - setLoading(true); - try { - setStatus(await adminStatus(password)); - } catch (e: any) { - if (e instanceof AuthError) onRelock(); - } finally { - setLoading(false); - } - }, [password, onRelock]); - - // Load status the first time this section renders. - useEffect(() => { - refresh(); - }, [refresh]); - - async function doWipe() { - setBusy(true); - setMsg(""); - try { - const r = await adminWipe(password); - setMsg(`✓ Wiped ${r.ticketsDeleted} tickets and ${r.auditDeleted} audit rows.`); - setConfirm(null); - refresh(); - } catch (e: any) { - if (e instanceof AuthError) onRelock(); - else setMsg(e?.message ?? "Wipe failed"); - } finally { - setBusy(false); - } - } - - async function doSwitch() { - if (!newTickets.trim()) return; - setBusy(true); - setMsg(""); - try { - const r = await adminSwitchTable(password, newTickets.trim(), newAudit.trim() || undefined); - setMsg(`✓ Now using tickets table ${r.tickets.tableId}.`); - setConfirm(null); - setNewTickets(""); - setNewAudit(""); - refresh(); - } catch (e: any) { - if (e instanceof AuthError) onRelock(); - else setMsg(e?.message ?? "Switch failed"); - } finally { - setBusy(false); - } - } - - return ( - - Actions - Event-management tools. These change live data — read the warnings. - - {/* Current status */} - - - Active event table - - {loading ? "…" : "↻"} - - - {status ? ( - <> - tickets: {status.tickets.tableId} · {status.tickets.count} records - audit: {status.audit.tableId ?? "—"} · {status.audit.count} records - - ) : ( - {loading ? "loading…" : "—"} - )} - - - {!!msg && {msg}} - - {/* Wipe slate */} - - 🧹 Wipe the slate clean - - Permanently deletes every ticket and every check-in in the active event - table. Use this to reset before a run-through or a fresh event. - - • Does NOT affect donor data. - • Cannot be undone. - { setMsg(""); setConfirm("wipe"); }}> - Wipe slate… - - - - {/* Switch table */} - - 🔀 Switch event table - - Point the scanner at a different NocoDB table — e.g. to start a new event on - a fresh table while keeping the current one intact. - - • Create the new table first (duplicate the current one's structure in NocoDB — keep the Id column). - • The current event's data is NOT deleted, just no longer shown. - New tickets table ID - - New audit table ID (optional) - - { setMsg(""); setConfirm("switch"); }} disabled={!newTickets.trim()}> - Switch table… - - - - {confirm === "wipe" && ( - setConfirm(null)} - /> - )} - {confirm === "switch" && ( - setConfirm(null)} - /> - )} - - ); -} - -function ConfirmModal({ - title, - lines, - confirmLabel, - busy, - onConfirm, - onCancel, -}: { - title: string; - lines: string[]; - confirmLabel: string; - busy: boolean; - onConfirm: () => void; - onCancel: () => void; -}) { - return ( - - - ⚠️ - {title} - {lines.map((l, i) => ( - {l} - ))} - - {busy ? : {confirmLabel}} - - - Cancel - - - - ); -} - -function money(n: number): string { - return "$" + Math.round(n).toLocaleString(); -} - -const styles = StyleSheet.create({ - root: { flex: 1, backgroundColor: theme.bg }, - topbar: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", paddingHorizontal: 16, paddingVertical: 10 }, - brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, - hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" }, - - body: { flex: 1, flexDirection: "row" }, - sidebar: { width: 84, backgroundColor: theme.card, borderRightWidth: 1, borderRightColor: theme.cardBorder, paddingTop: 8 }, - navItem: { paddingVertical: 14, alignItems: "center", gap: 4, borderLeftWidth: 3, borderLeftColor: "transparent" }, - navItemOn: { backgroundColor: theme.bg, borderLeftColor: theme.primary }, - navIcon: { fontSize: 22 }, - navLabel: { color: theme.textDim, fontSize: 11, fontWeight: "700", textAlign: "center", lineHeight: 13 }, - navLabelOn: { color: theme.text }, - content: { flex: 1 }, - pad: { padding: 16, paddingBottom: 48 }, - - h1: { color: theme.text, fontSize: 22, fontWeight: "800", marginBottom: 2 }, - sub: { color: theme.textDim, fontSize: 13, lineHeight: 19, marginBottom: 8 }, - lead: { color: theme.textDim, fontSize: 15, lineHeight: 21, marginBottom: 8 }, - label: { color: theme.textDim, fontSize: 13, marginTop: 14, marginBottom: 6 }, - input: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 12, paddingHorizontal: 14, paddingVertical: 12, color: theme.text, fontSize: 16, marginBottom: 2 }, - error: { color: theme.dangerBright, marginTop: 12, fontSize: 14, fontWeight: "600" }, - msg: { color: theme.successBright, marginTop: 10, fontSize: 14, fontWeight: "700" }, - bold: { fontWeight: "800", color: theme.text }, - - btn: { backgroundColor: theme.successBright, borderRadius: 13, paddingVertical: 15, alignItems: "center", marginTop: 18 }, - btnOff: { opacity: 0.4 }, - btnText: { color: "#06210f", fontSize: 18, fontWeight: "800" }, - - types: { flexDirection: "row", flexWrap: "wrap", gap: 8 }, - typePill: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 999, paddingHorizontal: 13, paddingVertical: 8 }, - typePillOn: { backgroundColor: theme.primary, borderColor: theme.primary }, - typePillText: { color: theme.textDim, fontSize: 13, fontWeight: "700" }, - typePillTextOn: { color: "#fff" }, - - result: { marginTop: 22, alignItems: "center", backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 16, padding: 20 }, - qr: { width: 200, height: 200, backgroundColor: "#fff", borderRadius: 10 }, - rcode: { color: theme.successBright, fontSize: 22, fontWeight: "800", letterSpacing: 2, marginTop: 12 }, - rwho: { color: theme.text, fontSize: 16, marginTop: 4 }, - rmail: { color: theme.textDim, fontSize: 13, marginTop: 8 }, - - searchRow: { flexDirection: "row", gap: 8, alignItems: "center", marginTop: 8 }, - searchBtn: { backgroundColor: theme.primary, borderRadius: 12, paddingHorizontal: 16, paddingVertical: 13 }, - searchBtnText: { color: "#fff", fontWeight: "800", fontSize: 15 }, - donorCard: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 12, padding: 14, marginTop: 12 }, - donorHead: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, - donorName: { color: theme.text, fontSize: 17, fontWeight: "800", flex: 1 }, - donorAmt: { color: theme.successBright, fontSize: 16, fontWeight: "800", marginLeft: 8 }, - donorLine: { color: theme.textDim, fontSize: 14, marginTop: 3 }, - donorTags: { flexDirection: "row", flexWrap: "wrap", gap: 6, marginTop: 8, alignItems: "center" }, - donorSource: { color: theme.textDim, fontSize: 11, fontWeight: "700", textTransform: "uppercase", backgroundColor: theme.bg, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 6, paddingHorizontal: 6, paddingVertical: 2 }, - donorTag: { color: theme.text, fontSize: 12, backgroundColor: theme.primaryDark, borderRadius: 6, paddingHorizontal: 7, paddingVertical: 2 }, - - statusBox: { backgroundColor: theme.card, borderWidth: 1, borderColor: theme.cardBorder, borderRadius: 12, padding: 14, marginTop: 4 }, - statusRow: { flexDirection: "row", justifyContent: "space-between", alignItems: "center" }, - statusLabel: { color: theme.textDim, fontSize: 12, fontWeight: "700", textTransform: "uppercase", letterSpacing: 0.5 }, - refresh: { color: theme.text, fontSize: 20 }, - statusVal: { color: theme.text, fontSize: 14, marginTop: 6, fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace" }, - - dangerCard: { backgroundColor: "#241717", borderWidth: 1, borderColor: theme.danger, borderRadius: 14, padding: 16, marginTop: 18 }, - dangerTitle: { color: "#ff9a9a", fontSize: 17, fontWeight: "800", marginBottom: 6 }, - dangerBody: { color: "#e9cfcf", fontSize: 14, lineHeight: 20 }, - dangerBullet: { color: "#d9b8b8", fontSize: 13, lineHeight: 19, marginTop: 4 }, - redBtn: { backgroundColor: theme.dangerBright, borderRadius: 12, paddingVertical: 14, alignItems: "center", marginTop: 16 }, - redBtnText: { color: "#fff", fontSize: 16, fontWeight: "800" }, - - modalScrim: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "rgba(0,0,0,0.72)", alignItems: "center", justifyContent: "center", padding: 24 }, - modalCard: { backgroundColor: "#1a1010", borderWidth: 2, borderColor: theme.dangerBright, borderRadius: 18, padding: 22, width: "100%", maxWidth: 380 }, - modalWarn: { fontSize: 40, textAlign: "center" }, - modalTitle: { color: "#fff", fontSize: 20, fontWeight: "900", textAlign: "center", marginTop: 4, marginBottom: 12 }, - modalLine: { color: "#f0d9d9", fontSize: 14, lineHeight: 20 }, - cancelBtn: { paddingVertical: 14, alignItems: "center", marginTop: 6 }, - cancelText: { color: theme.textDim, fontSize: 16, fontWeight: "700" }, -}); diff --git a/app/components/SideMenu.tsx b/app/components/SideMenu.tsx index 890027b..a91600e 100644 --- a/app/components/SideMenu.tsx +++ b/app/components/SideMenu.tsx @@ -1,13 +1,16 @@ import { useEffect, useRef } from "react"; -import { Animated, StyleSheet, View, Text, Pressable, Easing, useWindowDimensions } from "react-native"; +import { Animated, StyleSheet, View, Text, Pressable, Easing, useWindowDimensions, Linking, Platform } from "react-native"; import { router, useSegments } from "expo-router"; import { useAuth } from "../lib/auth"; import { theme } from "../lib/theme"; -const ITEMS: { label: string; icon: string; route: string; seg: string }[] = [ +// The admin hub is the standalone /crush33 web page (not an app route). +const CRUSH_URL = Platform.OS === "web" ? "/crush33" : "https://scan.beartariacampgrounds.com/crush33"; + +const ITEMS: { label: string; icon: string; route: string; seg: string; external?: string }[] = [ { label: "Scanner", icon: "📷", route: "/", seg: "" }, { label: "Event report", icon: "📊", route: "/stats", seg: "stats" }, - { label: "Admin (crush33)", icon: "🔐", route: "/comp", seg: "comp" }, + { label: "Admin (crush33)", icon: "🔐", route: "", seg: "__admin", external: CRUSH_URL }, { label: "Banquet lookup", icon: "🍽️", route: "/admin", seg: "admin" }, ]; @@ -32,8 +35,12 @@ export default function SideMenu({ visible, onClose }: { visible: boolean; onClo ]).start(); }, [visible, panelW, tx, fade]); - const go = (item: { route: string; seg: string }) => { + const go = (item: { route: string; seg: string; external?: string }) => { onClose(); + if (item.external) { + Linking.openURL(item.external).catch(() => {}); + return; + } if (item.seg !== current) router.replace(item.route as any); }; diff --git a/backend/src/routes/portal.ts b/backend/src/routes/portal.ts index 8c55c0e..b6efcba 100644 --- a/backend/src/routes/portal.ts +++ b/backend/src/routes/portal.ts @@ -103,92 +103,345 @@ const PAGE = ` -Camp Scan — Comp Tickets +Camp Scan — Admin (crush33) -
    -
    - -

    Comp Ticket Portal

    -

    Entry-only tickets for workers & guests

    -
    +
    + +

    Admin · crush33

    +

    Admin-only area. Enter the shared portal password.

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

    Comp tickets

    +

    Entry-only tickets for guests & staff.

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

    Donor lookup

    +

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

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

    Actions

    +

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

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

    🧹 Wipe the slate clean

    +

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

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

    🔀 Switch event table

    +

    Point the scanner at a different NocoDB table — start a new event on a fresh table while keeping the current one intact.

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