diff --git a/.env.example b/.env.example index 2fe3055..c52cdc0 100644 --- a/.env.example +++ b/.env.example @@ -17,17 +17,8 @@ NOCODB_DONORS_TABLE_ID= NOCODB_DONOR_ONLINE_TABLE_ID= NOCODB_DONOR_OFFLINE_TABLE_ID= -# 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 -# date each year. NOTE: with no donations after the cutoff, everyone gets 0. -VOUCHER_SINCE=2025-09-04 -VOUCHER_TIER1_MIN=400 -VOUCHER_TIER2_MIN=1000 +# Ice bags granted when a purchase includes ice but the webhook sends only a boolean +ICE_BAGS_DEFAULT=3 # Serve /test with sample QR codes (seeds test personas into the CURRENT table). # Keep this false/empty in production — only enable when pointed at a TEST table. @@ -35,7 +26,7 @@ ENABLE_TEST_PAGE=false # MailerSend MAILERSEND_API_TOKEN= -MAIL_FROM_EMAIL=info@beartariacampgrounds.com +MAIL_FROM_EMAIL=tickets@beartariacampgrounds.com MAIL_FROM_NAME=Beartaria Campgrounds # Shared secret FluentForms sends in the X-Webhook-Secret header (long random string) diff --git a/.forgejo/workflows/build-apk.yml b/.forgejo/workflows/build-apk.yml index 2564239..3f3fd2b 100644 --- a/.forgejo/workflows/build-apk.yml +++ b/.forgejo/workflows/build-apk.yml @@ -139,30 +139,14 @@ jobs: cp app/build/outputs/apk/release/app-release.apk \ "$GITHUB_WORKSPACE/artifacts/camp-scan-${{ steps.ver.outputs.tag }}.apk" - - 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" + - 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 diff --git a/Dockerfile b/Dockerfile index e5ce281..845477a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,11 +32,8 @@ 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. /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 +# Run as the non-root node user shipped in the base image. +RUN chown -R node:node /srv USER node EXPOSE 8080 diff --git a/README.md b/README.md index 9aa9646..a3b8964 100644 --- a/README.md +++ b/README.md @@ -51,9 +51,7 @@ The app expects the **2026 Campground Tickets** table to be a clone of the 2025 | `Ice Total` | Number (prepaid ice bags) | | `Ice Redeemed` | Number (default 0) | -Total redeemable tickets = sum of the age-bracket columns **excluding `Ages 0-3`** (free). Ice bags remaining = `Ice Total − Ice Redeemed`. - -> **The table MUST have an `Id` primary key.** NocoDB's v2 `PATCH /records` with no primary key updates *every row in the table*, so a PK-less table would make each scan rewrite all tickets. The backend now refuses to update a record with no `Id` (fail-safe), but the table itself must have one. Tables cloned from the existing 2025 table already have `Id`; if you build one by hand via the API, include an `{"title":"Id","uidt":"ID"}` column. Column names are mapped in [`backend/src/fields.ts`](./backend/src/fields.ts) — change them there if the real titles differ. Put the table's ID (right-click table → *Copy Table ID*) in `NOCODB_TABLE_ID`. +Total redeemable tickets = sum of the age-bracket columns **excluding `Ages 0-3`** (free). Ice bags remaining = `Ice Total − Ice Redeemed`. Column names are mapped in [`backend/src/fields.ts`](./backend/src/fields.ts) — change them there if the real titles differ. Put the table's ID (right-click table → *Copy Table ID*) in `NOCODB_TABLE_ID`. ### Audit log table — "2026 Ticket Audit Logs" diff --git a/app/app.json b/app/app.json index 146efd3..4e23192 100644 --- a/app/app.json +++ b/app/app.json @@ -2,19 +2,14 @@ "expo": { "name": "Camp Scan", "slug": "camptickets", - "version": "0.3.0", + "version": "0.1.0", "orientation": "portrait", "scheme": "campscan", "userInterfaceStyle": "automatic", "newArchEnabled": true, - "icon": "./assets/icon.png", "android": { "package": "top.mowden.campscan", - "versionCode": 3, - "adaptiveIcon": { - "foregroundImage": "./assets/adaptive-icon.png", - "backgroundColor": "#0f1a12" - }, + "versionCode": 1, "permissions": [ "android.permission.CAMERA", "android.permission.VIBRATE" diff --git a/app/app/_layout.tsx b/app/app/_layout.tsx index cfd2a76..358a7a4 100644 --- a/app/app/_layout.tsx +++ b/app/app/_layout.tsx @@ -4,7 +4,6 @@ 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() { @@ -12,9 +11,7 @@ export default function RootLayout() { - - - + ); diff --git a/app/app/admin.tsx b/app/app/admin.tsx index 4d1a277..fd9c121 100644 --- a/app/app/admin.tsx +++ b/app/app/admin.tsx @@ -4,7 +4,6 @@ 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 { @@ -44,7 +43,6 @@ 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); @@ -121,8 +119,10 @@ export default function AdminScreen() { return ( - - + router.replace("/")} hitSlop={10}> + + ‹ Scanner + Admin lookup @@ -201,15 +201,11 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti }, [ticket.redeemed, showHistory]); 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"); - if (e.rvParking) tags.push("🚐 RV"); - if (e.utv) tags.push("🏍️ UTV"); - if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total}`); - if (e.freeKids > 0) tags.push(`👶 ${e.freeKids} free kids`); + 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`); return ( @@ -276,7 +272,6 @@ 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/index.tsx b/app/app/index.tsx index e624c36..324d545 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -6,7 +6,6 @@ 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"; @@ -20,8 +19,7 @@ const MODES: { key: Mode; label: string; icon: string }[] = [ ]; export default function ScannerScreen() { - const { operator } = useAuth(); - const { open: openMenu } = useMenu(); + const { signOut, operator } = useAuth(); const [mode, setMode] = useState("tickets"); const [phase, setPhase] = useState("scanning"); const [ticket, setTicket] = useState(null); @@ -109,9 +107,8 @@ export default function ScannerScreen() { feedbackSuccess(); const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining; setTicket(res.ticket); - // 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)); + // Ice: default to grabbing all remaining bags at once. Tickets: default 1. + setCount(mode === "ice" ? Math.max(1, remaining) : Math.min(1, remaining)); setPhase("confirm"); } catch (e: any) { if (e?.name === "AuthError") return router.replace("/login"); @@ -150,19 +147,29 @@ 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 + + @@ -179,31 +186,6 @@ export default function ScannerScreen() { ))} - {mode === "banquet" && (phase === "scanning" || phase === "banquet") && ( - // Kept above the scanner (near the top) so the on-screen keyboard, - // which covers the bottom of the screen, never hides this input. - - manualEmail.trim() && runBanquet({ email: manualEmail.trim() })} - /> - manualEmail.trim() && runBanquet({ email: manualEmail.trim() })} - > - Look up - - - )} - {phase === "scanning" && ( @@ -235,7 +217,6 @@ export default function ScannerScreen() { {phase === "success" && ticket && ( - {isIce ? ( <> @@ -253,8 +234,6 @@ export default function ScannerScreen() { {ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining - - )} @@ -285,6 +264,29 @@ export default function ScannerScreen() { )} + + {mode === "banquet" && (phase === "scanning" || phase === "banquet") && ( + + manualEmail.trim() && runBanquet({ email: manualEmail.trim() })} + /> + manualEmail.trim() && runBanquet({ email: manualEmail.trim() })} + > + Look up + + + )} ); } @@ -341,14 +343,11 @@ function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticke function ExtrasRow({ ticket }: { ticket: TicketView }) { const tags: string[] = []; - 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.freeKids > 0) tags.push(`👶 ${e.freeKids} ${e.freeKids === 1 ? "kid" : "kids"} 12 & under (free)`); + 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)`); if (!tags.length) return null; return ( @@ -361,79 +360,6 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) { ); } -const TYPE_ICON: Record = { - Guest: "🎫", - Worker: "🛠️", - Performer: "🎭", - Volunteer: "🙌", - Speaker: "🎤", - "Food Vendor": "🍔", - Vendor: "🛒", -}; - -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 ( - - {names.map((n, i) => ( - - {n} - - ))} - - ); -} - -/** - * Big Adults / Youth / Kids breakdown so gate staff can eyeball the party - * against the ticket — a deterrent for adults signing up under a (free/cheaper) - * younger bracket. Adults (18+) and Youth (13-16) are the paid tickets; Kids - * (0-12) are free. A detail line breaks the kids into their age bands. - */ -function PartyPanel({ ticket }: { ticket: TicketView }) { - const get = (b: string) => ticket.ages.find((a) => a.bracket === b)?.count ?? 0; - const adults = get("Adults"); - const youth = get("Youth 13-16"); - const kidBrackets = ticket.ages.filter((a) => a.bracket.startsWith("Kids")); - const kids = kidBrackets.reduce((s, a) => s + a.count, 0); - return ( - - - - {adults} - ADULTS{"\n"}18+ - - - - {youth} - YOUTH{"\n"}13-16 - - - - {kids} - KIDS{"\n"}0-12 - - - {kidBrackets.length > 0 && ( - - kids: {kidBrackets.map((a) => `${a.count}× ${a.bracket.replace(/^Kids\s*/, "")}`).join(" · ")} - - )} - - ); -} - function ConfirmCard({ ticket, isIce, @@ -460,14 +386,11 @@ function ConfirmCard({ return ( {ticket.name} - {ticket.code} - {!isIce && } {remaining} of {total} {unit} remaining {redeemed} already redeemed - {!isIce && } {!isIce && } {isIce && total === 0 && This ticket did not prepay for ice.} @@ -510,8 +433,6 @@ 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" }, @@ -566,33 +487,6 @@ 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" }, - party: { - alignSelf: "stretch", - backgroundColor: "#1d2a1f", - borderWidth: 2, - borderColor: theme.warn, - borderRadius: 14, - paddingVertical: 10, - paddingHorizontal: 10, - marginTop: 10, - marginBottom: 2, - }, - partyRow: { flexDirection: "row", alignItems: "center", justifyContent: "center" }, - partyCell: { flex: 1, alignItems: "center" }, - partyNum: { color: theme.text, fontSize: 36, fontWeight: "900", lineHeight: 40 }, - partyLbl: { color: theme.warn, fontSize: 11, fontWeight: "800", letterSpacing: 0.5, marginTop: 1, textAlign: "center", lineHeight: 13 }, - partyDivider: { width: 1.5, height: 42, backgroundColor: theme.cardBorder }, - partyDetail: { color: theme.textDim, fontSize: 12, textAlign: "center", marginTop: 8, fontWeight: "600" }, tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 }, tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" }, diff --git a/app/app/stats.tsx b/app/app/stats.tsx deleted file mode 100644 index acd8c84..0000000 --- a/app/app/stats.tsx +++ /dev/null @@ -1,308 +0,0 @@ -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: "🎤", - "Food Vendor": "🍔", - Vendor: "🛒", -}; -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/assets/adaptive-icon.png b/app/assets/adaptive-icon.png index 5a37baa..98b5fd6 100644 Binary files a/app/assets/adaptive-icon.png and b/app/assets/adaptive-icon.png differ diff --git a/app/assets/favicon.png b/app/assets/favicon.png index 50934a4..109b763 100644 Binary files a/app/assets/favicon.png and b/app/assets/favicon.png differ diff --git a/app/assets/icon-192.png b/app/assets/icon-192.png index 31340dc..ceb7dd3 100644 Binary files a/app/assets/icon-192.png and b/app/assets/icon-192.png differ diff --git a/app/assets/icon-512.png b/app/assets/icon-512.png index 1b7b7b5..98b5fd6 100644 Binary files a/app/assets/icon-512.png and b/app/assets/icon-512.png differ diff --git a/app/assets/icon.png b/app/assets/icon.png index ea11d0a..98b5fd6 100644 Binary files a/app/assets/icon.png and b/app/assets/icon.png differ diff --git a/app/components/QRScanner.tsx b/app/components/QRScanner.tsx index c2b9b1c..56f40b8 100644 --- a/app/components/QRScanner.tsx +++ b/app/components/QRScanner.tsx @@ -1,6 +1,6 @@ import { useRef } from "react"; -import { StyleSheet, View, Text, Pressable, type LayoutChangeEvent } from "react-native"; -import { CameraView, useCameraPermissions, type BarcodeScanningResult } from "expo-camera"; +import { StyleSheet, View, Text, Pressable } from "react-native"; +import { CameraView, useCameraPermissions } from "expo-camera"; import { theme } from "../lib/theme"; export interface QRScannerProps { @@ -8,48 +8,10 @@ export interface QRScannerProps { active: boolean; } -// Side of the accept square, matching the visible reticle (index.tsx uses 240), -// with a little tolerance so a code aimed inside the box always registers. -const RETICLE = 260; - -/** Native (Android/iOS) scanner using expo-camera. Only accepts codes whose - * position falls within the centered reticle square. */ +/** Native (Android/iOS) scanner using expo-camera. */ export default function QRScanner({ onScan, active }: QRScannerProps) { const [permission, requestPermission] = useCameraPermissions(); const lastScan = useRef<{ code: string; at: number }>({ code: "", at: 0 }); - const layout = useRef({ w: 0, h: 0 }); - - const onLayout = (e: LayoutChangeEvent) => { - layout.current = { w: e.nativeEvent.layout.width, h: e.nativeEvent.layout.height }; - }; - - // True if the scanned code sits inside the centered reticle. Fails OPEN when - // geometry is missing/unknown so scanning never silently breaks. - const inReticle = (res: BarcodeScanningResult): boolean => { - const { w, h } = layout.current; - if (!w || !h) return true; - const pts = res.cornerPoints as { x: number; y: number }[] | undefined; - let cx: number | undefined; - let cy: number | undefined; - if (pts && pts.length) { - cx = pts.reduce((s, p) => s + p.x, 0) / pts.length; - cy = pts.reduce((s, p) => s + p.y, 0) / pts.length; - } else { - const b: any = (res as any).bounds; - if (b?.origin && b?.size) { - cx = b.origin.x + b.size.width / 2; - cy = b.origin.y + b.size.height / 2; - } - } - if (cx === undefined || cy === undefined) return true; - // Some platforms report normalized [0,1] coords — scale to view size. - if (cx <= 1 && cy <= 1) { - cx *= w; - cy *= h; - } - const half = RETICLE / 2; - return Math.abs(cx - w / 2) <= half && Math.abs(cy - h / 2) <= half; - }; if (!permission) { return ; @@ -69,13 +31,10 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { { - if (!inReticle(res)) return; // ignore codes outside the square - const data = res.data; + ? ({ data }) => { const now = Date.now(); // Debounce repeated frames of the same code. if (data === lastScan.current.code && now - lastScan.current.at < 3000) return; diff --git a/app/components/QRScanner.web.tsx b/app/components/QRScanner.web.tsx index 82d4cbb..db5f1d6 100644 --- a/app/components/QRScanner.web.tsx +++ b/app/components/QRScanner.web.tsx @@ -56,10 +56,6 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { video.play().catch(() => {}); } const detector = new BarcodeDetector({ formats: ["qr_code"] }); - // Offscreen canvas holding just the centered reticle crop — we only run - // detection on this region so QR codes elsewhere in view are ignored. - const canvas = document.createElement("canvas"); - const cctx = canvas.getContext("2d", { willReadFrequently: true }); let busy = false; const tick = async () => { rafRef.current = requestAnimationFrame(tick); @@ -72,21 +68,7 @@ export default function QRScanner({ onScan, active }: QRScannerProps) { if (busy || !activeRef.current) return; busy = true; try { - // Crop the central square of the frame (matches the on-screen - // reticle) and detect only within it. - const vw = v.videoWidth; - const vh = v.videoHeight; - let target: HTMLVideoElement | HTMLCanvasElement = v; - if (cctx && vw && vh) { - const side = Math.round(Math.min(vw, vh) * 0.62); - const sx = Math.round((vw - side) / 2); - const sy = Math.round((vh - side) / 2); - canvas.width = side; - canvas.height = side; - cctx.drawImage(v, sx, sy, side, side, 0, 0, side, side); - target = canvas; - } - const codes = await detector.detect(target); + const codes = await detector.detect(v); if (codes && codes.length) { const data = codes[0].rawValue; const now = Date.now(); diff --git a/app/components/SideMenu.tsx b/app/components/SideMenu.tsx deleted file mode 100644 index db0ca3c..0000000 --- a/app/components/SideMenu.tsx +++ /dev/null @@ -1,113 +0,0 @@ -import { useEffect, useRef } from "react"; -import { Animated, StyleSheet, View, Text, Pressable, Easing, useWindowDimensions } from "react-native"; -import { router, useSegments } from "expo-router"; -import { useAuth } from "../lib/auth"; -import { theme } from "../lib/theme"; - -// Note: the /crush33 admin hub is intentionally NOT listed here — it's an -// admin-only URL, not surfaced to gate staff in the app drawer. -const ITEMS: { label: string; icon: string; route: string; seg: string }[] = [ - { label: "Scanner", icon: "📷", route: "/", seg: "" }, - { label: "Event report", icon: "📊", route: "/stats", seg: "stats" }, - { label: "Banquet lookup", icon: "🍽️", route: "/admin", seg: "admin" }, -]; - -export default function SideMenu({ visible, onClose }: { visible: boolean; onClose: () => void }) { - const { operator, signOut } = useAuth(); - const segments = useSegments(); - const current = segments[0] ?? ""; - const { width } = useWindowDimensions(); - const panelW = Math.min(320, width * 0.84); - const tx = useRef(new Animated.Value(-panelW)).current; - const fade = useRef(new Animated.Value(0)).current; - - useEffect(() => { - Animated.parallel([ - Animated.timing(tx, { - toValue: visible ? 0 : -panelW, - duration: 220, - easing: Easing.out(Easing.cubic), - useNativeDriver: true, - }), - Animated.timing(fade, { toValue: visible ? 1 : 0, duration: 220, useNativeDriver: true }), - ]).start(); - }, [visible, panelW, tx, fade]); - - const go = (item: { route: string; seg: string }) => { - onClose(); - if (item.seg !== current) router.replace(item.route as any); - }; - - return ( - - - - - - - 🐻 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 d574790..17f2bea 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -21,22 +21,16 @@ export interface TicketView { code: string; name: string; email: string; - ticketType: string; - createdBy: string; total: number; redeemed: number; remaining: number; ice: ResourceCount; - adultNames: string[]; extras: { carParking: boolean; rvParking: boolean; - utv: boolean; iceAccess: boolean; isDonor: boolean; - donorTier: string; - vouchers: number; - freeKids: number; + freeUnder4: number; }; ages: { bracket: string; count: number; free: boolean }[]; } @@ -195,113 +189,6 @@ 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; -} - -// ---- 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/app/lib/menu.tsx b/app/lib/menu.tsx deleted file mode 100644 index 356c944..0000000 --- a/app/lib/menu.tsx +++ /dev/null @@ -1,21 +0,0 @@ -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/app/public/favicon.png b/app/public/favicon.png index 50934a4..109b763 100644 Binary files a/app/public/favicon.png and b/app/public/favicon.png differ diff --git a/app/public/icon-192.png b/app/public/icon-192.png index 31340dc..ceb7dd3 100644 Binary files a/app/public/icon-192.png and b/app/public/icon-192.png differ diff --git a/app/public/icon-512.png b/app/public/icon-512.png index 1b7b7b5..98b5fd6 100644 Binary files a/app/public/icon-512.png and b/app/public/icon-512.png differ diff --git a/backend/src/config.ts b/backend/src/config.ts index 944dd0f..3b98337 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -10,10 +10,6 @@ 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. @@ -21,33 +17,9 @@ const schema = z.object({ NOCODB_DONOR_ONLINE_TABLE_ID: z.string().optional(), NOCODB_DONOR_OFFLINE_TABLE_ID: z.string().optional(), - // 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 - // names or dollar amounts. Rate-limited + CORS-restricted. - PUBLIC_LOOKUP_SECRET: z.string().optional(), - // Comma-separated allowlist of browser origins permitted to call the public - // lookups (the request's Origin is echoed back only if it matches one). - PUBLIC_LOOKUP_ORIGIN: z - .string() - .default("https://tickets.beartariacampgrounds.com,https://vendors.beartariacampgrounds.com") - .transform((s) => - s - .split(",") - .map((o) => o.trim().replace(/\/+$/, "")) - .filter(Boolean), - ), - - // Ticket-voucher entitlement: donations on/after VOUCHER_SINCE totalling - // >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the date each year. - VOUCHER_SINCE: z.string().default("2025-09-04"), - VOUCHER_TIER1_MIN: z.coerce.number().default(400), - VOUCHER_TIER2_MIN: z.coerce.number().default(1000), + // 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), // 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). @@ -62,8 +34,6 @@ 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/context.ts b/backend/src/context.ts index 9bf2e2c..fcf2c43 100644 --- a/backend/src/context.ts +++ b/backend/src/context.ts @@ -4,7 +4,6 @@ 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 { @@ -17,23 +16,12 @@ 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, + nocodb: new NocoDBClient(config), mailer: new Mailer(config), queue: new RedeemQueue(), - audit, + audit: new AuditLogger(config), donors: new DonorService(config), }; } diff --git a/backend/src/fields.ts b/backend/src/fields.ts index ae4dbf9..c34bff7 100644 --- a/backend/src/fields.ts +++ b/backend/src/fields.ts @@ -1,41 +1,48 @@ /** - * Mapping for the "2026 Campground Tickets" NocoDB table, matching the 2026 - * FluentForms "Tickets 2026" schema. Change titles here if the columns differ. + * 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. */ export const COL = { id: "Id", - name: "Title", // purchaser full name - adultNames: "Adult Names", // newline-separated list of adult attendee names + name: "Title", // first column in the 2025 table holds the purchaser name 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", - ticketType: "Ticket Type", // "" for regular; Guest/Worker/Performer/Volunteer/Speaker for portal comps - createdBy: "Created By", // gate-staff name who issued a comp ticket (portal) - // Columns this system manages: + // Columns this system adds to the table: code: "Ticket Code", redeemed: "Redeemed", submissionKey: "SubmissionKey", lastScanAt: "LastScanAt", - iceTotal: "Ice Total", - iceRedeemed: "Ice Redeemed", + iceTotal: "Ice Total", // prepaid ice bags + iceRedeemed: "Ice Redeemed", // bags picked up } 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 { @@ -50,46 +57,23 @@ function bool(v: unknown): boolean { return false; } -/** - * 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. - */ +/** Total redeemable tickets = sum of age brackets minus the free ones. */ export function computeTotal(rec: NocoRecord): number { - 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 { - return num(rec[COL.iceTotal]); -} - -/** Per-group breakdown for display. */ -export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number; free: boolean }[] { - 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: 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); -} - -/** 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); + let total = 0; + for (const col of AGE_COLUMNS) { + if (FREE_AGE_COLUMNS.includes(col)) continue; + total += num(rec[col]); } - return []; + return total; +} + +/** Per-bracket 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); } export interface ResourceCount { @@ -102,26 +86,24 @@ export interface TicketView { code: string; 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; ice: ResourceCount; - adultNames: string[]; extras: { carParking: boolean; rvParking: boolean; - utv: boolean; iceAccess: boolean; isDonor: boolean; - donorTier: string; - vouchers: number; - freeKids: number; // children 12 & under (free admission) + freeUnder4: 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]); @@ -131,8 +113,6 @@ 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] ?? ""), - createdBy: String(rec[COL.createdBy] ?? ""), total, redeemed, remaining: Math.max(0, total - redeemed), @@ -141,16 +121,12 @@ 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]), - donorTier: String(rec[COL.donorTier] ?? ""), - vouchers: num(rec[COL.vouchers]), - freeKids: freeKidsCount(rec), + freeUnder4: num(rec["Ages 0-3"]), }, ages: ageBreakdown(rec), }; diff --git a/backend/src/fluentforms.ts b/backend/src/fluentforms.ts deleted file mode 100644 index 60f9e4c..0000000 --- a/backend/src/fluentforms.ts +++ /dev/null @@ -1,96 +0,0 @@ -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; -} - -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(); - 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/admin.ts b/backend/src/routes/admin.ts deleted file mode 100644 index c2fc5a7..0000000 --- a/backend/src/routes/admin.ts +++ /dev/null @@ -1,122 +0,0 @@ -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/routes/install.ts b/backend/src/routes/install.ts deleted file mode 100644 index 0bbbb58..0000000 --- a/backend/src/routes/install.ts +++ /dev/null @@ -1,117 +0,0 @@ -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/routes/portal.ts b/backend/src/routes/portal.ts deleted file mode 100644 index e8f9d75..0000000 --- a/backend/src/routes/portal.ts +++ /dev/null @@ -1,452 +0,0 @@ -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); - }); - - // Password check only (for the in-app portal to gate its form). - app.post( - "/api/portal/verify", - { config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, - async (req, reply) => { - const cfg = app.ctx.config; - if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" }); - const b = (req.body ?? {}) as { password?: string }; - if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) { - return reply.code(401).send({ error: "bad_password" }); - } - return { ok: true, types: TYPES }; - }, - ); - - app.post( - "/api/portal/create-ticket", - { config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, - async (req, reply) => { - const cfg = app.ctx.config; - if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" }); - - const b = (req.body ?? {}) as { - password?: string; - name?: string; - email?: string; - type?: string; - createdBy?: string; - }; - if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) { - return reply.code(401).send({ error: "bad_password" }); - } - const name = String(b.name ?? "").trim(); - const email = String(b.email ?? "").trim(); - const type = TYPES.includes(String(b.type)) ? String(b.type) : "Guest"; - // Who issued it — from the in-app portal (signed-in gate staff) or header. - const createdBy = String(b.createdBy ?? req.headers["x-operator"] ?? "").slice(0, 80).trim(); - if (!name || !email) { - return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" }); - } - - let result: Awaited>; - try { - result = await createTicket(app.ctx, { - name, - adultNames: [name], - email, - ticketType: type, - createdBy, - counts: { adults: 1, youth: 0, kids12: 0, kids9: 0, kids4: 0 }, - submissionKey: `portal:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`, - }); - } catch (e: any) { - req.log.error({ err: e }, "portal: create failed"); - return reply.code(502).send({ error: "db_error", detail: e?.message }); - } - - // Email the QR (best-effort — the portal also shows it on-screen). - let emailSent = false; - if (!app.ctx.mailer.isBlockedRecipient(email)) { - try { - const png = await renderQrPng(result.code); - await app.ctx.mailer.sendTicket({ toEmail: email, toName: name, code: result.code, quantity: 1, qrPng: png }); - emailSent = true; - } catch (e: any) { - req.log.error({ err: e, code: result.code }, "portal: email failed"); - } - } - - const qr = await renderQrDataUrl(result.code); - return { ok: true, code: result.code, type, name, emailSent, qr }; - }, - ); -} - -const PAGE = ` - - - - - -Camp Scan — Admin (crush33) - - - -
- -

Admin · crush33

-

Admin-only area. Enter the shared portal password.

- - -
- ← Back to the scan app -
- -
-
- ← Scanner -
🐻 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.
- - - - - -
-
-
-
-
- -
- -
- - - -`; diff --git a/backend/src/routes/publicLookup.ts b/backend/src/routes/publicLookup.ts deleted file mode 100644 index 998a7fb..0000000 --- a/backend/src/routes/publicLookup.ts +++ /dev/null @@ -1,109 +0,0 @@ -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 allowed = cfg.PUBLIC_LOOKUP_ORIGIN; // string[] allowlist - - 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(req, reply); - return reply.code(204).send(); - }; - app.options("/api/public/donor-eligibility", preflight); - app.options("/api/public/ticket-vouchers", preflight); - - const checkSecret = (req: any): boolean => { - const { key } = (req.query ?? {}) as { key?: string }; - return !!cfg.PUBLIC_LOOKUP_SECRET && !!key && safeEqual(key, cfg.PUBLIC_LOOKUP_SECRET); - }; - - app.get( - "/api/public/donor-eligibility", - { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, - async (req, reply) => { - cors(req, 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 }; - } - }, - ); - - // Ticket-voucher entitlement: how many FREE tickets a donor has left. This is - // the tier entitlement earned from giving on/after VOUCHER_SINCE, MINUS the - // vouchers already consumed by their prior ticket orders (each order stores - // how many it used), so a donor can't keep claiming free tickets by - // re-submitting the form. `vouchers` is the remaining count the form should - // grant; `entitled`/`used`/`remaining` are the breakdown. No dollar amounts. - // - // To reset for testing: zero out (or delete) the "Vouchers" value on that - // donor's ticket order row(s) in NocoDB — `used` drops and `remaining` rises. - app.get( - "/api/public/ticket-vouchers", - { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, - async (req, reply) => { - cors(req, reply); - if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) { - return reply.code(404).send({ error: "not_available" }); - } - if (!checkSecret(req)) { - return reply.code(401).send({ error: "unauthorized" }); - } - const { email } = (req.query ?? {}) as { email?: string }; - const addr = String(email ?? "").trim(); - if (!addr) return { vouchers: 0, entitled: 0, used: 0, remaining: 0 }; - - try { - const cutoff = new Date(cfg.VOUCHER_SINCE); - const { amount } = await app.ctx.donors.amountSince(addr, cutoff); - const entitled = amount >= cfg.VOUCHER_TIER2_MIN ? 2 : amount >= cfg.VOUCHER_TIER1_MIN ? 1 : 0; - const used = await app.ctx.nocodb.vouchersUsedByEmail(addr); - const remaining = Math.max(0, entitled - used); - return { vouchers: remaining, entitled, used, remaining }; - } catch { - // Fail closed — grant no vouchers rather than error. - return { vouchers: 0, entitled: 0, used: 0, remaining: 0 }; - } - }, - ); -} diff --git a/backend/src/routes/test.ts b/backend/src/routes/test.ts index a82c0f3..3d4a680 100644 --- a/backend/src/routes/test.ts +++ b/backend/src/routes/test.ts @@ -7,90 +7,59 @@ interface Persona { key: string; name: string; email: string; - adultNames?: string[]; - counts: { adults: number; youth: number; kids12: number; kids9: number; kids4: number }; + ages: Record; iceBags?: number; carParking?: boolean; rvParking?: boolean; - utv?: boolean; isDonor?: boolean; - donorTier?: string; - ticketType?: 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", - adultNames: ["Solo Sam"], - counts: C(1), + ages: { "Ages 18-25": 1 }, blurb: "1 ticket, no extras. Check-in mode → green, 1/1.", }, { key: "family", name: "Family Fay", email: "family@test.beartaria", - adultNames: ["Family Fay", "Frank Fay"], - counts: C(2, 1, 1, 2, 2), // 2 adults + 1 youth = 3 paid; 5 kids 12 & under free + ages: { "Ages 0-3": 2, "Ages 8-12": 3, "Ages 26-45": 2 }, iceBags: 3, carParking: true, blurb: - "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.", + "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.", }, { - key: "donor2", - name: "Donor Dan", - email: "donor@example.test", - adultNames: ["Donor Dan", "Donna Dan"], - counts: C(2), + key: "donor", + name: "Adam Stevens (real donor)", + email: "adam21stevens@gmail.com", + ages: { "Ages 26-45": 2 }, rvParking: true, - utv: true, isDonor: true, - donorTier: "member", - blurb: - "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.", + blurb: "2 tickets, RV parking. Banquet mode → shows real donation total ($801).", }, { key: "ice", name: "Ice Ike", email: "ice@test.beartaria", - 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.", + ages: { "Ages 18-25": 1 }, + iceBags: 3, + blurb: "1 ticket + 3 ice bags. Ice mode → grab all 3 at once, then scan again → exhausted.", }, { key: "exhausted", name: "Done Dora", email: "done@test.beartaria", - counts: C(2), + ages: { "Ages 26-45": 2 }, 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" @@ -104,23 +73,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, - counts: p.counts, + ages: p.ages, iceBags: p.iceBags, carParking: p.carParking, rvParking: p.rvParking, - 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 - // is deterministic (total = scannable count from the persona's counts). + // is deterministic (compute the total from the persona's own age counts, + // since NocoDB's create response may not echo them back). if (p.exhaust) { - const { adults, youth } = p.counts; - await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth }); + 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 }); } cards.push({ code: result.code, diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index eb03880..c99c2b8 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -2,7 +2,6 @@ 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 { @@ -43,12 +42,6 @@ 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; @@ -174,16 +167,11 @@ export async function ticketRoutes(app: FastifyInstance): Promise { schema: { body: { type: "object", - required: ["name"], + required: ["name", "ages"], properties: { name: { type: "string", minLength: 1 }, email: { type: "string" }, - 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" } }, + ages: { type: "object" }, sendEmail: { type: "boolean" }, }, }, @@ -194,15 +182,8 @@ 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 ?? "", - counts: { - adults: b.adults ?? 1, - youth: b.youth ?? 0, - kids12: b.kids12 ?? 0, - kids9: b.kids9 ?? 0, - kids4: b.kids4 ?? 0, - }, + ages: b.ages, submissionKey, }); if (b.sendEmail && b.email && !app.ctx.mailer.isBlockedRecipient(b.email)) { diff --git a/backend/src/routes/vendorWebhook.ts b/backend/src/routes/vendorWebhook.ts deleted file mode 100644 index 86722c2..0000000 --- a/backend/src/routes/vendorWebhook.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { createHash } from "node:crypto"; -import type { FastifyInstance } from "fastify"; -import { createTicket } from "../ticketService.js"; -import { renderQrPng } from "../services/qrcode.js"; -import { safeEqual, nameGroup, addressLine, readDonor } from "../fluentforms.js"; - -/** - * Vendor booth webhooks (Vendor Fee Food / Non-Food 2026, on - * vendors.beartariacampgrounds.com). Only FOOD vendors receive entry tickets: - * - * - Food: two named pass-holders (`names` = "Name Ticket 1", - * `names_1` = "Name Ticket #2") → up to 2 gate passes. - * - Non-Food: NO entry ticket. The endpoint acknowledges the submission - * (so a wired FluentForms feed doesn't error) but issues nothing. - * - * For food, each named person gets one gate ticket. The booth name becomes the - * ticket title (so gate staff see the booth) and the pass-holders are stored as - * the attendee names. The ticket is tagged with a "Food Vendor" `Ticket Type` - * so it shows a badge on scan and rolls up in the event report. Booth size / - * additional space are logistics, not admissions, so they don't affect passes. - * - * Shares WEBHOOK_SECRET with the attendee webhook (same X-Webhook-Secret header). - */ -const FOOD_NAME_SLOTS = ["names", "names_1"]; // pass-holder name field bases - -function checkSecret(app: FastifyInstance, req: any): boolean { - const secret = req.headers["x-webhook-secret"]; - return typeof secret === "string" && safeEqual(secret, app.ctx.config.WEBHOOK_SECRET); -} - -function foodHandler(app: FastifyInstance) { - return async (req: any, reply: any) => { - if (!checkSecret(app, req)) { - return reply.code(401).send({ error: "unauthorized" }); - } - - const body = (req.body ?? {}) as Record; - - const boothName = String(body.input_text ?? "").trim(); - // Pass-holder names (non-empty slots, in order). - const passHolders = FOOD_NAME_SLOTS.map((b) => nameGroup(body, b)).filter(Boolean); - const primary = passHolders[0] ?? ""; - // Ticket title = booth name (most useful at the gate), else the first person. - const title = boothName || primary; - if (!title) { - return reply.code(400).send({ error: "missing_fields", detail: "booth name or vendor name is required" }); - } - - const email = String(body.email ?? "").trim(); - // One entry pass per named person; a booth with no names still gets 1. - const passes = Math.max(1, passHolders.length); - - const { isDonor, donorTier } = readDonor(body); - const address = addressLine(body.address_1); - - // Idempotency: prefer a stable submission id, else hash the content. - const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id; - const submissionKey = submissionId - ? `sub:${String(submissionId)}` - : "hash:" + - createHash("sha256") - .update(`vendor|Food Vendor|${email}|${title}|${passes}`) - .digest("hex") - .slice(0, 32); - - // Vendor passes are adult admissions; no youth/kids/ice/parking. - const counts = { adults: passes, youth: 0, kids12: 0, kids9: 0, kids4: 0 }; - - let result: Awaited>; - try { - result = await createTicket(app.ctx, { - name: title, - adultNames: passHolders, - email, - address, - isDonor, - donorTier, - ticketType: "Food Vendor", - counts, - paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined, - submissionKey, - }); - } catch (e: any) { - req.log.error({ err: e }, "vendor webhook: failed to create ticket"); - return reply.code(502).send({ error: "db_error", detail: e?.message }); - } - - if (result.status === "duplicate") { - return { status: "duplicate", code: result.code }; - } - - // Email the ticket QR (FluentForms sends the receipt separately). - if (!email) { - req.log.warn({ code: result.code }, "vendor webhook: ticket created but no email"); - return { status: "created", code: result.code, passes, emailSent: false, emailSkipped: "no_email" }; - } - if (app.ctx.mailer.isBlockedRecipient(email)) { - req.log.warn({ email }, "vendor webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send"); - return { status: "created", code: result.code, passes, emailSent: false, emailSkipped: "trial_restriction" }; - } - - try { - const qr = await renderQrPng(result.code); - await app.ctx.mailer.sendTicket({ - toEmail: email, - toName: primary || title, - code: result.code, - quantity: passes, - qrPng: qr, - }); - } catch (e: any) { - req.log.error({ err: e, code: result.code }, "vendor webhook: created but email failed"); - return reply.code(502).send({ status: "created", code: result.code, passes, emailSent: false, error: e?.message }); - } - - return { status: "created", code: result.code, passes, emailSent: true }; - }; -} - -/** Non-food vendors don't get an entry ticket. Acknowledge and issue nothing - * (so a wired FluentForms feed doesn't error), but never create a ticket. */ -function nonFoodHandler(app: FastifyInstance) { - return async (req: any, reply: any) => { - if (!checkSecret(app, req)) { - return reply.code(401).send({ error: "unauthorized" }); - } - return { status: "ignored", reason: "non_food_no_ticket" }; - }; -} - -export async function vendorWebhookRoutes(app: FastifyInstance): Promise { - // 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/webhook.ts b/backend/src/routes/webhook.ts index 250c14f..b1352a8 100644 --- a/backend/src/routes/webhook.ts +++ b/backend/src/routes/webhook.ts @@ -1,21 +1,28 @@ -import { createHash } from "node:crypto"; +import { createHash, timingSafeEqual } from "node:crypto"; import type { FastifyInstance } from "fastify"; -import { toBool } from "../fields.js"; +import { AGE_COLUMNS, toBool, toNumber } from "../fields.js"; import { createTicket } from "../ticketService.js"; import { renderQrPng } from "../services/qrcode.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 = [ - "names", - "names_1", "names_2", "names_3", "names_4", "names_5", "names_6", "names_7", "names_8", "names_9", -]; -// 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]; +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); +} + +// 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+", +}; export async function webhookRoutes(app: FastifyInstance): Promise { const handler = async (req: any, reply: any) => { @@ -24,102 +31,57 @@ export async function webhookRoutes(app: FastifyInstance): Promise { return reply.code(401).send({ error: "unauthorized" }); } - const body = (req.body ?? {}) as Record; - + const body = (req.body ?? {}) as Record; + const name = String(body.name ?? "").trim(); const email = String(body.email ?? "").trim(); - - // Billing/customer name (the purchaser — may differ from attendees, e.g. - // buying for others or add-ons only) + the attendee name groups. - const customerName = nameGroup(body, "customer_name") || String(body.name ?? "").trim(); - const adultNames = ADULT_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean); - // Free voucher adult tickets = number of donor name fields filled. - const voucherTickets = DONOR_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean).length; - // Ticket title + email recipient = the billing/customer name (fall back to - // the first attendee only if the customer name is somehow missing). - const purchaser = customerName || adultNames[0]; - const title = customerName || adultNames[0]; - if (!title) { - return reply.code(400).send({ error: "missing_fields", detail: "customer or attendee name is required" }); + if (!name || !email) { + return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" }); } - // 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) + - 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), - kids4: qty(body.item_quantity_kids_4), - }; - // Paid/scannable admissions = adults + youth 13-16. Children 12 & under are - // free (charging starts at 13) and are stored but not counted at the gate. - const scannable = counts.adults + counts.youth; - - // Donor info (hidden fields from the eligibility/voucher lookups) + radio. - const donorTier = String(body.donor_tier ?? "").trim(); - const isDonor = - donorTier === "member" || - donorTier === "donor" || - toBool(body.donor_eligible) || - selected(body.input_radio); // "Are you a campground donor?" - // Vouchers consumed in this order = the free voucher tickets actually taken - // (donor names filled), which is what the ticket-voucher lookup subtracts. - const vouchers = voucherTickets; - - // Extras (best-effort from payment fields — donor variants may be free/$0). - const carParking = selected(body.payment_parking_reg) || selected(body.payment_parking_donor); - const rvParking = selected(body.payment_rv_reg) || selected(body.payment_rv_donor); - const utv = selected(body.payment_utv_reg) || selected(body.payment_utv_donor); - // Ice: payment_ice is a descriptive option label whose "(N total bags)" - // states the bags. One ice ticket = ICE_BAGS_PER_TICKET bags. - const iceBags = iceBagsFromPayment(body.payment_ice, { - bagsPerTicket: app.ctx.config.ICE_BAGS_PER_TICKET, - ticketPrice: app.ctx.config.ICE_TICKET_PRICE, - }); - const iceAccess = iceBags > 0 || selected(body.input_radio_7); - - // Tickets are optional: a customer can buy ice/UTV/parking with no admission - // ticket, or buy tickets for others. Only reject a truly empty order — - // nothing to check in, redeem, or verify at the gate. - const hasIssuable = scannable > 0 || iceBags > 0 || utv || carParking || rvParking; - if (!hasIssuable) { - req.log.warn({ body }, "webhook: submission has nothing to issue"); - return reply.code(400).send({ error: "no_items", detail: "no tickets, ice, or add-ons in submission" }); + // 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" }); } - const address = addressLine(body.address_1); - - // Idempotency: prefer a stable submission id, else hash the content - // (include ice/extras so distinct add-on-only orders don't collide). - const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id; + // Idempotency key: prefer a stable submission id, else hash the content. + const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id; const submissionKey = submissionId ? `sub:${String(submissionId)}` : "hash:" + createHash("sha256") - .update(`${email}|${title}|${JSON.stringify(counts)}|${iceBags}|${carParking}|${rvParking}|${utv}`) + .update(`${email}|${name}|${JSON.stringify(ages)}`) .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: title, - adultNames, + name, email, - address, - isDonor, - donorTier, - vouchers, - counts, - carParking, - rvParking, - utv, - iceAccess, + 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, iceBags, paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined, + ages, submissionKey, }); } catch (e: any) { @@ -131,13 +93,9 @@ export async function webhookRoutes(app: FastifyInstance): Promise { return { status: "duplicate", code: result.code }; } - // 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" }; - } + // 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. 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" }; @@ -145,13 +103,14 @@ 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: purchaser, + toName: name, code: result.code, - quantity: scannable, + quantity, 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 deleted file mode 100644 index c2a146c..0000000 --- a/backend/src/routes/webhookDoc.ts +++ /dev/null @@ -1,184 +0,0 @@ -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: "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 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: "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: "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." }, - { 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 { - 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) => ` - ${esc(f.key)} - ${f.req} - ${esc(f.type)} - ${esc(f.desc)} - `, -).join(""); - -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", - "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 @submission.json`); - -const PAGE = ` - - - - - -Camp Scan — Webhook - - - -
-

🐻 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. Send all form fields.
-
- -

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

- - - ${rows} -
KeyRequiredTypeDescription
-

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.

- -

Example payload

-
${exampleJson}
-

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

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

FluentForms setup

-
    -
  1. On the ticket form: Settings & Integrations → Webhook → Add Webhook.
  2. -
  3. Request URL: ${WEBHOOK_URL}  ·  Method: POST  ·  Format: JSON
  4. -
  5. Request Headers: add X-Webhook-Secret = the shared secret.
  6. -
  7. Request Body: send all fields (the field names above are the FluentForms field keys).
  8. -
  9. Save, submit a test purchase, and confirm the QR email arrives.
  10. -
- -

Vendor booth webhooks

-

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.

- - - - - - -
FormEndpointResult
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 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>" \\
-  -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 685e1a7..cd5f362 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -9,14 +9,8 @@ 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"; -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(); @@ -34,14 +28,8 @@ 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); - 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 c6b3a29..05e3b50 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 tableId: string | null; + private readonly tableId: string | null; constructor(cfg: Pick) { this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, ""); @@ -46,56 +46,10 @@ 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 ? "+" : ""; @@ -125,43 +79,6 @@ 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/donors.ts b/backend/src/services/donors.ts index 46e5e53..bd13de8 100644 --- a/backend/src/services/donors.ts +++ b/backend/src/services/donors.ts @@ -1,17 +1,5 @@ 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; @@ -168,87 +156,6 @@ export class DonorService { source: "transactions", }; } - - /** - * Admin-only free-text donor search across the master list + transaction - * tables. Matches the query (substring, case-insensitive) against any - * name / email / phone / address / bear-name column each table exposes — - * columns are discovered from a sample row so it adapts to the schema. - * Results are de-duped by email (then name). PRIVACY: gate this to admins. - */ - async search(rawQuery: string, limit = 40): Promise { - 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 - * entitlement. found=false if donor tables aren't configured or there are no - * transactions for the email at all. - */ - async amountSince(rawEmail: string, cutoff: Date): Promise<{ found: boolean; amount: number }> { - const email = rawEmail.trim(); - if (!email || !this.onlineId || !this.offlineId) return { found: false, amount: 0 }; - const esc = email.replace(/[(),]/g, " "); - const [online, offline] = await Promise.all([ - this.list(this.onlineId, `(Email,eq,${esc})`, 1000), - this.list(this.offlineId, `(Email,eq,${esc})`, 1000), - ]); - const amount = - sumSince(online, "Donation Amount", "Donation Date", cutoff) + - sumSince(offline, "Donation Amount", "Donation Date", cutoff); - return { found: online.length + offline.length > 0, amount }; - } } function num(v: unknown): number { @@ -256,40 +163,6 @@ 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/mailer.ts b/backend/src/services/mailer.ts index a97f10b..a27ce84 100644 --- a/backend/src/services/mailer.ts +++ b/backend/src/services/mailer.ts @@ -9,43 +9,6 @@ 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 { @@ -131,7 +94,8 @@ function esc(s: string): string { function renderHtml(mail: TicketEmail): string { const name = esc(mail.toName || ""); - const { lead, footer } = purchaseSummary(mail); + const qty = mail.quantity; + const ticketWord = qty === 1 ? "ticket" : "tickets"; return ` @@ -144,7 +108,9 @@ function renderHtml(mail: TicketEmail): string {

Hi ${name || "there"},

- Thank you for your purchase! ${lead} + 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.

Ticket QR code

- ${footer} + 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!

@@ -167,16 +134,17 @@ function renderHtml(mail: TicketEmail): string { } function renderText(mail: TicketEmail): string { - const { lead, footer } = purchaseSummaryText(mail); + const qty = mail.quantity; + const ticketWord = qty === 1 ? "ticket" : "tickets"; return [ `Hi ${mail.toName || "there"},`, "", - `Thank you for your purchase! ${lead}`, + `Thank you for your purchase! This is your ticket for ${qty} ${ticketWord} to the 2026 Beartaria Campgrounds event.`, "", `Your ticket code: ${mail.code}`, "", "Show this code (or the QR code in the HTML version of this email) at the gate.", - footer, + `It is good for all ${qty} ${ticketWord} on this purchase.`, "", "See you there!", "Beartaria Campgrounds · beartariacampgrounds.com", diff --git a/backend/src/services/nocodb.ts b/backend/src/services/nocodb.ts index 9e3416e..2378b24 100644 --- a/backend/src/services/nocodb.ts +++ b/backend/src/services/nocodb.ts @@ -8,24 +8,16 @@ import { COL, type NocoRecord } from "../fields.js"; export class NocoDBClient { private readonly base: string; private readonly token: string; - private _tableId: string; + private readonly tableId: string; constructor(cfg: Pick) { this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, ""); this.token = cfg.NOCODB_API_TOKEN; - 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; + this.tableId = cfg.NOCODB_TABLE_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 { @@ -84,22 +76,6 @@ 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", @@ -110,15 +86,6 @@ export class NocoDBClient { /** Patch fields on a record identified by its NocoDB Id. */ async update(id: number, fields: Record): Promise { - // Fail safe: without a valid primary key, a v2 PATCH /records applies to - // EVERY row in the table. Refuse rather than mass-corrupt ticket counts. - // (A table missing its Id column will trip this — use a table with a PK.) - if (id === undefined || id === null || (typeof id === "number" && !Number.isFinite(id))) { - throw new NocoDBError( - "record has no Id — refusing to update (the table is missing its primary key)", - 500, - ); - } const body = await this.request(this.recordsUrl, { method: "PATCH", body: JSON.stringify({ Id: id, ...fields }), @@ -126,67 +93,6 @@ 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; - } - - /** 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 deleted file mode 100644 index b2cde2a..0000000 --- a/backend/src/services/state.ts +++ /dev/null @@ -1,32 +0,0 @@ -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/backend/src/services/stats.ts b/backend/src/services/stats.ts deleted file mode 100644 index 3e9801f..0000000 --- a/backend/src/services/stats.ts +++ /dev/null @@ -1,131 +0,0 @@ -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/test/fakeNocodb.ts b/backend/src/test/fakeNocodb.ts index d8fe66b..46cbd89 100644 --- a/backend/src/test/fakeNocodb.ts +++ b/backend/src/test/fakeNocodb.ts @@ -41,17 +41,6 @@ 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; @@ -97,13 +86,14 @@ export function fakeContext(db: FakeNocoDB): AppContext { export async function seedTicket( db: FakeNocoDB, - opts: { code: string; name?: string; email?: string; adults?: number; redeemed?: number }, + opts: { code: string; name?: string; email?: string; ages?: Record; 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 be0a4f1..2287a11 100644 --- a/backend/src/test/fields.test.ts +++ b/backend/src/test/fields.test.ts @@ -2,52 +2,45 @@ import { describe, it, expect } from "vitest"; import { computeTotal, toView, COL } from "../fields.js"; describe("computeTotal", () => { - it("sums adults + youth 13-16 only; all kids 12 & under are free", () => { + it("sums age brackets but excludes Ages 0-3 (free)", () => { const rec = { Id: 1, - [COL.adults]: 2, - [COL.youth]: 1, - [COL.kids12]: 1, // free, not counted - [COL.kids9]: 1, // free, not counted - [COL.kids4]: 3, // free, not counted + "Ages 0-3": 2, // free, not counted + "Ages 4-7": 1, + "Ages 18-25": 2, + "Ages 26-45": 1, }; - expect(computeTotal(rec)).toBe(3); + expect(computeTotal(rec)).toBe(4); }); it("coerces string counts and treats blanks as 0", () => { - const rec = { Id: 1, [COL.adults]: "3", [COL.youth]: "" } as any; + const rec = { Id: 1, "Ages 18-25": "3", "Ages 26-45": "" } as any; expect(computeTotal(rec)).toBe(3); }); }); describe("toView", () => { - it("derives remaining and surfaces adult names, donor tier, and extras", () => { + it("derives remaining and surfaces 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", - [COL.donorTier]: "member", - [COL.vouchers]: 2, + "Ages 0-3": 1, + "Ages 18-25": 2, + "Ages 26-45": 3, }; 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.donorTier).toBe("member"); - expect(v.extras.vouchers).toBe(2); - expect(v.extras.freeKids).toBe(1); - expect(v.ages.find((a) => a.bracket === "Kids 0-4")?.free).toBe(true); + expect(v.extras.freeUnder4).toBe(1); + expect(v.ages.find((a) => a.bracket === "0-3")?.free).toBe(true); }); }); diff --git a/backend/src/test/fluentforms.test.ts b/backend/src/test/fluentforms.test.ts deleted file mode 100644 index 9023e24..0000000 --- a/backend/src/test/fluentforms.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { nameGroup, qty, selected, addressLine, readDonor, iceBagsFromPayment } from "../fluentforms.js"; - -const ICE = { bagsPerTicket: 3, ticketPrice: 20 }; - -// Food vendors are the only vendor tickets; two pass-holder name slots. -const FOOD_SLOTS = ["names", "names_1"]; - -/** Mirror the food vendor handler's pass count: one per named person, min 1. */ -function passCount(body: Record, slots: string[]): number { - const holders = slots.map((b) => nameGroup(body, b)).filter(Boolean); - return Math.max(1, holders.length); -} - -describe("nameGroup", () => { - it("reads flattened bracket keys", () => { - const body = { "names[first_name]": "Joe", "names[last_name]": "Taco" }; - expect(nameGroup(body, "names")).toBe("Joe Taco"); - }); - it("reads a nested object and includes the middle name", () => { - const body = { names: { first_name: "Ann", middle_name: "B", last_name: "Cole" } }; - expect(nameGroup(body, "names")).toBe("Ann B Cole"); - }); - it("returns empty string when the group is blank", () => { - expect(nameGroup({}, "names_1")).toBe(""); - }); -}); - -describe("food vendor pass counting", () => { - it("food booth with two named holders gets 2 passes", () => { - const body = { - input_text: "Joe's Tacos", - "names[first_name]": "Joe", - "names[last_name]": "Taco", - "names_1[first_name]": "Jane", - "names_1[last_name]": "Taco", - }; - expect(passCount(body, FOOD_SLOTS)).toBe(2); - }); - it("food booth with only the first name gets 1 pass", () => { - const body = { input_text: "Solo BBQ", "names[first_name]": "Sam", "names[last_name]": "Que" }; - expect(passCount(body, FOOD_SLOTS)).toBe(1); - }); - it("booth with no names still gets 1 pass", () => { - expect(passCount({ input_text: "Nameless Booth" }, FOOD_SLOTS)).toBe(1); - }); -}); - -describe("iceBagsFromPayment", () => { - it("reads '(N total bags)' from the real form label", () => { - expect(iceBagsFromPayment("One Ice ticket good for one bag per day (3 total bags)", ICE)).toBe(3); - expect(iceBagsFromPayment("Two Ice tickets good for one bag per day (6 total bags)", ICE)).toBe(6); - }); - it("falls back to a worded ice-ticket count", () => { - expect(iceBagsFromPayment("Two Ice tickets", ICE)).toBe(6); // 2 × 3 - expect(iceBagsFromPayment("Four Ice tickets", ICE)).toBe(12); - }); - it("falls back to a dollar total at the ticket price", () => { - expect(iceBagsFromPayment("$40.00", ICE)).toBe(6); // 2 tickets × 3 - expect(iceBagsFromPayment(20, ICE)).toBe(3); // 1 ticket × 3 - }); - it("treats a small plain count as ticket count", () => { - expect(iceBagsFromPayment(2, ICE)).toBe(6); // 2 tickets × 3 - }); - it("is 0 for blank / no ice", () => { - expect(iceBagsFromPayment("", ICE)).toBe(0); - expect(iceBagsFromPayment(undefined, ICE)).toBe(0); - expect(iceBagsFromPayment(0, ICE)).toBe(0); - }); -}); - -describe("readDonor", () => { - it("treats donor_tier=member as a donor", () => { - expect(readDonor({ donor_tier: "member" })).toEqual({ isDonor: true, donorTier: "member" }); - }); - it("honors the donor_eligible hidden flag", () => { - expect(readDonor({ donor_eligible: "true" }).isDonor).toBe(true); - }); - it("is not a donor when nothing indicates it", () => { - expect(readDonor({ input_radio: "No" })).toEqual({ isDonor: false, donorTier: "" }); - }); -}); - -describe("qty / selected / addressLine", () => { - it("parses money strings and nested quantities", () => { - expect(qty("$40.00")).toBe(40); - expect(qty({ quantity: 2 })).toBe(2); - expect(qty("")).toBe(0); - }); - it("selected() treats $0.00 / no / blank as unselected", () => { - expect(selected("$0.00")).toBe(false); - expect(selected("No")).toBe(false); - expect(selected("Yes")).toBe(true); - }); - it("flattens a compound address", () => { - expect(addressLine({ address_line_1: "1 Main", city: "Boise", state: "ID" })).toBe("1 Main, Boise, ID"); - }); -}); diff --git a/backend/src/test/redeem.test.ts b/backend/src/test/redeem.test.ts index b2255e1..72738a6 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", adults: 4 }); + await seedTicket(db, { code: "BC26-AAAA-1111", ages: { "Ages 26-45": 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", adults: 5 }); + await seedTicket(db, { code: "BC26-FAM-0001", ages: { "Ages 0-3": 2, "Ages 26-45": 2, "Ages 8-12": 3 } }); 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", adults: 2 }); + await seedTicket(db, { code: "BC26-BBBB-2222", ages: { "Ages 26-45": 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", adults: 3, redeemed: 2 }); + await seedTicket(db, { code: "BC26-CCCC-3333", ages: { "Ages 26-45": 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", adults: 4 }); + await seedTicket(db, { code: "BC26-AUDT-0001", ages: { "Ages 26-45": 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", adults: 3, redeemed: 0 }); + await seedTicket(db, { code: "BC26-AUDT-0002", ages: { "Ages 26-45": 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", - adults: 2, + ages: { "Ages 26-45": 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", adults: 3 }); + await seedTicket(db, { code: "BC26-DDDD-4444", ages: { "Ages 26-45": 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", adults: 5 }); + await seedTicket(db, { code: "BC26-RACE-0005", ages: { "Ages 26-45": 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", adults: 3 }); + await seedTicket(db, { code: "BC26-LOOK-0001", ages: { "Ages 26-45": 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", - counts: { adults: 2, youth: 0, kids12: 0, kids9: 0, kids4: 0 }, + ages: { "Ages 26-45": 2 }, submissionKey: "sub:412", }; const a = await createTicket(ctx, input); diff --git a/backend/src/test/vouchers.test.ts b/backend/src/test/vouchers.test.ts deleted file mode 100644 index 497887a..0000000 --- a/backend/src/test/vouchers.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -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/backend/src/ticketService.ts b/backend/src/ticketService.ts index 040ae07..76fa4dc 100644 --- a/backend/src/ticketService.ts +++ b/backend/src/ticketService.ts @@ -128,21 +128,15 @@ export async function search(ctx: AppContext, query: string): Promise; // NocoDB age-column title -> count submissionKey: string; } @@ -164,31 +158,20 @@ 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.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; - 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; diff --git a/docker-compose.yml b/docker-compose.yml index dc62d8d..20e1aa0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,9 +3,7 @@ services: build: . image: camptickets:latest container_name: camptickets - # 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 + env_file: .env environment: # Container always listens on 8080 internally; the host mapping below is # what nginx proxies to. Keep this fixed regardless of .env PORT. @@ -19,11 +17,4 @@ 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/docs/fluentforms-donor-discount.md b/docs/fluentforms-donor-discount.md deleted file mode 100644 index 6872a0f..0000000 --- a/docs/fluentforms-donor-discount.md +++ /dev/null @@ -1,152 +0,0 @@ -# FluentForms → donor lookup APIs - -FluentForms has no native way to query an external database from a field. These -wire it up with a small Custom JS block that calls a secret-gated endpoint on -the ticketing backend. Two endpoints are available (same key, CORS, and rate -limit): - -| Endpoint | Purpose | -|---|---| -| `GET /api/public/donor-eligibility` | Is this email a donor/member? → unlock a discount | -| `GET /api/public/ticket-vouchers` | How many free tickets has this donor earned? → 0 / 1 / 2 | - -Both require `?key=`, are rate-limited (30/min/IP), and -CORS-restricted to `PUBLIC_LOOKUP_ORIGIN` (default -`https://tickets.beartariacampgrounds.com`). Neither returns names or dollar -amounts. The secret is visible in page source, so treat it as deterrence, not -security; rotate it by changing `PUBLIC_LOOKUP_SECRET` and redeploying. - ---- - -## Donor discount lookup - -Unlocks a discount when the entered email belongs to a donor/member. - -## Endpoint - -``` -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 (conditional pricing) - -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/donor-eligibility?key=&email=" -# donor/member -> {"eligible":true,"tier":"member"} -# anyone else -> {"eligible":false,"tier":null} -``` - -If member and donor get the **same** discounted price, simplify: set the donor -price to show when `donor_tier` **is not** `regular`, and you can ignore the -member/donor distinction. - ---- - -## Ticket-voucher entitlement - -The donor **ticket-voucher lookup** (how many free tickets a donor earned) is a -separate endpoint documented on its own page: -[`fluentforms-ticket-vouchers.md`](./fluentforms-ticket-vouchers.md). diff --git a/docs/fluentforms-ticket-vouchers.md b/docs/fluentforms-ticket-vouchers.md deleted file mode 100644 index af66c45..0000000 --- a/docs/fluentforms-ticket-vouchers.md +++ /dev/null @@ -1,131 +0,0 @@ -# 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 the **remaining** free-ticket count — never names or dollar amounts: - -```json -{ "vouchers": 1, "entitled": 2, "used": 1, "remaining": 1 } -``` - -- `vouchers` / `remaining` — how many free tickets are **still available** (this - is what the form should grant). Use `vouchers`; `remaining` is an alias. -- `entitled` — the tier entitlement earned from giving (0/1/2). -- `used` — vouchers already consumed by this donor's prior ticket orders. -- `key` = the value of `PUBLIC_LOOKUP_SECRET` (set in the backend `.env`). -- `email` = the donor's email (URL-encoded). -- Rate-limited (30 requests / minute / IP) and CORS-restricted to - `PUBLIC_LOOKUP_ORIGIN` (`tickets.` + `vendors.beartariacampgrounds.com`). - -### Vouchers decrement as they're used - -`remaining = entitled − used`, where `used` is the sum of the **Vouchers** -column across every ticket order placed with that email. Each checkout stores -the vouchers it applied, so the next lookup returns fewer — a donor can't keep -claiming free tickets by re-submitting the form. Once `used ≥ entitled`, -`vouchers` is `0`. - -**To reset for testing:** in NocoDB, zero out (or delete) the **Vouchers** -value on that donor's ticket order row(s). `used` drops and `remaining` rises on -the next lookup — no redeploy needed. - -> The secret is visible in page source, so treat it as **deterrence, not -> security** — it only gates a 0/1/2 count. Rotate it by changing -> `PUBLIC_LOOKUP_SECRET` and redeploying. - -## Rules - -Donations are summed for the email across the online + offline transaction -tables, counting only **Paid** rows dated **on/after `VOUCHER_SINCE`**: - -| Total since the cutoff | Vouchers | -|---|---| -| ≥ $1000 | 2 | -| ≥ $400 | 1 | -| otherwise | 0 | - -Configurable in the backend `.env`: - -| Var | Default | Meaning | -|---|---|---| -| `VOUCHER_SINCE` | `2025-09-04` | Only donations on/after this date count. Bump each year. | -| `VOUCHER_TIER1_MIN` | `400` | Dollar total for 1 voucher | -| `VOUCHER_TIER2_MIN` | `1000` | Dollar total for 2 vouchers | - -The count comes from the **dated transaction tables** (the donor master-list -rollups have no dates), so donations must exist in those tables for the window. - -## Form snippet - -Add a **Custom HTML** element to the form and paste this, setting `KEY` to your -`PUBLIC_LOOKUP_SECRET` (and `EMAIL_SELECTOR` if the email field isn't named -`email`). It shows the earned count on email blur and writes it into a hidden -field `free_tickets` you can use for conditional logic or to cap a quantity. - -```html -
- -``` - -## Test - -``` -curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=&email=" -# entitled 2, none used yet -> {"vouchers":2,"entitled":2,"used":0,"remaining":2} -# after a checkout using 2 -> {"vouchers":0,"entitled":2,"used":2,"remaining":0} -``` - -Related: [`fluentforms-donor-discount.md`](./fluentforms-donor-discount.md) — the -companion donor-discount eligibility lookup (same key / CORS / rate limit). diff --git a/scripts/switch-event.sh b/scripts/switch-event.sh deleted file mode 100755 index 862ad54..0000000 --- a/scripts/switch-event.sh +++ /dev/null @@ -1,73 +0,0 @@ -#!/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 deleted file mode 100755 index 58fc3a8..0000000 --- a/scripts/wipe-slate.sh +++ /dev/null @@ -1,57 +0,0 @@ -#!/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."