diff --git a/.env.example b/.env.example index 3b51ff4..d52597f 100644 --- a/.env.example +++ b/.env.example @@ -12,6 +12,14 @@ NOCODB_TABLE_ID= # Optional: table ID of "2026 Ticket Audit Logs". If unset, audit logging is skipped. NOCODB_AUDIT_TABLE_ID= +# Banquet mode — donor tables (optional; if the master list is unset, banquet is disabled) +NOCODB_DONORS_TABLE_ID= +NOCODB_DONOR_ONLINE_TABLE_ID= +NOCODB_DONOR_OFFLINE_TABLE_ID= + +# Ice bags granted when a purchase includes ice but the webhook sends only a boolean +ICE_BAGS_DEFAULT=3 + # MailerSend MAILERSEND_API_TOKEN= MAIL_FROM_EMAIL=tickets@beartariacampgrounds.com diff --git a/INSTALL.md b/INSTALL.md index b875e85..1b2a66c 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -53,5 +53,9 @@ Android users can also "Add to Home Screen" from Chrome instead of using Obtaini ## Using it -- **Scan:** point the camera at a ticket QR. Pick how many people are entering now (a family can split arrivals across the day on one code), then **Check in**. Green + chime = success and the count is saved; red + buzz = a problem (already fully redeemed, not a valid ticket, or a network/database error — the reason is shown). -- **Admin** (top-right): if scanning won't work, search by **name, email, or ticket code**, then use the buttons to check people in or undo a mistaken check-in. +Pick a **mode** with the buttons under the title: + +- **Check-in** — scan a ticket QR, pick how many people are entering now (a family can split arrivals across the day on one code), then **Check in**. Green + chime = saved; red + buzz = a problem (already fully redeemed, not a valid ticket, or a network/database error — the reason is shown). +- **Ice** — scan a ticket, hand out prepaid ice bags (defaults to all remaining; adjust if they only want some). +- **Banquet** — scan a ticket to see that buyer's total donations, or type an email and tap **Look up**. +- **Admin** (top-right): if scanning won't work, search by **name, email, or ticket code**, use the buttons to check people in or undo, and view check-in history. diff --git a/README.md b/README.md index b918b65..75ff7ed 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,10 @@ End-to-end ticketing for the 2026 event: 1. **Purchase** — a FluentForms checkout on `tickets.beartariacampgrounds.com` POSTs a webhook to this backend, which writes a row to NocoDB, generates a unique ticket **QR code**, and emails it to the buyer via MailerSend (subject *"2026 Beartaria Campgrounds Tickets"*). -2. **Gate** — staff scan the QR with **Camp Scan** (Android app + iPhone PWA, one Expo codebase). It validates the code, lets staff check in however many people are arriving on that visit, decrements the remaining count in NocoDB, and flashes **green + chime** / **red + buzz** with the name, counts, extras (parking/ice), and DB-update confirmation. +2. **Gate** — staff scan the QR with **Camp Scan** (Android app + iPhone PWA, one Expo codebase). It flashes **green + chime** / **red + buzz** with name, counts, extras, and DB-update confirmation. Three scan modes: + - **Check-in** — check in however many people are arriving on that visit; decrements the ticket count. + - **Ice** — hand out prepaid ice bags (all at once or some now); decrements a separate ice count. + - **Banquet** — show the scanned buyer's total donations (online + offline), looked up by their email; also supports a manual email lookup. 3. **Admin** — a panel in the same app for manual lookup by name/email/code and button-based check-in/undo when scanning fails. One QR per purchase is **reusable across visits** until all its tickets are redeemed (e.g. a family arriving in two groups on one code). Children under 4 are free and not counted. @@ -45,8 +48,10 @@ The app expects the **2026 Campground Tickets** table to be a clone of the 2025 | `Redeemed` | Number (default 0) | | `SubmissionKey` | SingleLineText | | `LastScanAt` | DateTime | +| `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). 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" @@ -62,7 +67,19 @@ Every check-in and undo is recorded to a separate table so the crew can review w | `Name` | SingleLineText | | `Remaining After` | Number | -Put its table ID in `NOCODB_AUDIT_TABLE_ID`. Leave the var empty to disable audit logging (check-ins still work). The admin panel shows global recent activity and per-ticket history from this table. +Put its table ID in `NOCODB_AUDIT_TABLE_ID`. Leave the var empty to disable audit logging (check-ins still work). The admin panel shows global recent activity and per-ticket history from this table. Ice pickups are logged too (actions `ice` / `ice-undo`). + +### Banquet mode — donor tables + +Banquet mode reads existing donor tables (no new columns). Set these table IDs: + +| Var | Table | +|---|---| +| `NOCODB_DONORS_TABLE_ID` | Donors Master List (authoritative `Total Donations` / `Total Online Donations` / `Total Offline Donations`, matched by `Email` or `Alternate Email`) | +| `NOCODB_DONOR_ONLINE_TABLE_ID` | Donor Online Transactions (fallback sum of `Donation Amount` by email) | +| `NOCODB_DONOR_OFFLINE_TABLE_ID` | Donor Offline Transactions (fallback) | + +If `NOCODB_DONORS_TABLE_ID` is unset, banquet mode is disabled. Column names are mapped in [`backend/src/services/donors.ts`](./backend/src/services/donors.ts). > `CampTickets TEST` and `CampTickets Audit TEST` tables already exist in NocoDB for testing. Point `NOCODB_TABLE_ID` / `NOCODB_AUDIT_TABLE_ID` at them for dry runs, then switch to the real 2026 tables for production. @@ -150,6 +167,7 @@ On the ticket form: **Settings & Integrations → Webhook → Add Webhook**. | `submission_id` | the entry/submission ID (for idempotency; content-hash fallback if omitted) | | `ages_0_3`, `ages_4_7`, `ages_8_12`, `ages_13_17`, `ages_18_25`, `ages_26_45`, `ages_46_64`, `ages_65` | headcount per bracket | | `car_parking`, `rv_parking`, `ice_access`, `is_donor` | yes/no or 1/0 | +| `ice_bags` | prepaid ice bag count (optional; if omitted and `ice_access` is truthy, defaults to `ICE_BAGS_DEFAULT`) | | `address`, `payment_method` | optional | On success the buyer receives the QR email. Re-sends of the same submission are idempotent (no duplicate rows/emails). If an email fails, the row is still created and returns HTTP 502 (visible in FluentForms' log); re-send later with `POST /api/tickets/{code}/resend-email` (staff-auth'd). @@ -174,8 +192,9 @@ The runner runs jobs in a `node:22-bookworm` container and installs the Android |---|---| | `POST /api/auth/login` `{pin}` | Exchange PIN for a token | | `POST /webhook` (secret header) | FluentForms purchase → create ticket + email | -| `POST /api/lookup` `{code}` | Read a ticket by code (no mutation) | -| `POST /api/redeem` `{code, count}` | Check in `count` people (negative undoes); serialized per code | +| `POST /api/lookup` `{code}` | Read a ticket by code (includes ice counts; no mutation) | +| `POST /api/redeem` `{code, count, resource?}` | Redeem `count` of `resource` (`tickets` default, or `ice`); negative undoes; serialized per code+resource | +| `POST /api/banquet` `{code?\|email?}` | Donor total for a scanned ticket's email or a manual email | | `GET /api/tickets?q=` | Search by name/email or exact code | | `GET /api/audit?code=&limit=` | Recent check-in log (all, or one code) | | `POST /api/tickets/{code}/resend-email` | Re-send the QR email | diff --git a/app/app/index.tsx b/app/app/index.tsx index 4bc181c..ff13689 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -1,21 +1,32 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { StyleSheet, View, Text, Pressable, ScrollView } from "react-native"; +import { StyleSheet, View, Text, Pressable, ScrollView, TextInput } from "react-native"; import { router } from "expo-router"; import { SafeAreaView } from "react-native-safe-area-context"; import QRScanner from "../components/QRScanner"; -import ResultOverlay, { OverlayStatus } from "../components/ResultOverlay"; -import { lookup, redeem, logout, type TicketView } from "../lib/api"; +import ResultOverlay from "../components/ResultOverlay"; +import { lookup, redeem, banquet, logout, type TicketView, type DonorLookup } from "../lib/api"; import { feedbackSuccess, feedbackError } from "../lib/feedback"; import { theme } from "../lib/theme"; -type Phase = "scanning" | "busy" | "confirm" | "success" | "error"; +type Mode = "tickets" | "ice" | "banquet"; +type Phase = "scanning" | "busy" | "confirm" | "success" | "error" | "banquet"; + +const MODES: { key: Mode; label: string; icon: string }[] = [ + { key: "tickets", label: "Check-in", icon: "🎟️" }, + { key: "ice", label: "Ice", icon: "🧊" }, + { key: "banquet", label: "Banquet", icon: "🍽️" }, +]; export default function ScannerScreen() { + const [mode, setMode] = useState("tickets"); const [phase, setPhase] = useState("scanning"); const [ticket, setTicket] = useState(null); const [count, setCount] = useState(1); const [message, setMessage] = useState(""); const [checkedIn, setCheckedIn] = useState(0); + const [donor, setDonor] = useState(null); + const [donorTicketName, setDonorTicketName] = useState(""); + const [manualEmail, setManualEmail] = useState(""); const resumeTimer = useRef | null>(null); const resume = useCallback(() => { @@ -24,12 +35,25 @@ export default function ScannerScreen() { setMessage(""); setCheckedIn(0); setCount(1); + setDonor(null); + setDonorTicketName(""); setPhase("scanning"); }, []); - useEffect(() => () => { - if (resumeTimer.current) clearTimeout(resumeTimer.current); - }, []); + const switchMode = useCallback( + (m: Mode) => { + setMode(m); + resume(); + }, + [resume], + ); + + useEffect( + () => () => { + if (resumeTimer.current) clearTimeout(resumeTimer.current); + }, + [], + ); const showError = useCallback((msg: string) => { feedbackError(); @@ -37,46 +61,73 @@ export default function ScannerScreen() { setPhase("error"); }, []); - const handleScan = useCallback( - async (raw: string) => { + const runBanquet = useCallback( + async (input: { code?: string; email?: string }) => { setPhase("busy"); try { - const res = await lookup(raw); + const res = await banquet(input); if (!res.ok) { - showError(`Database error: ${res.detail}`); + const msgs: Record = { + not_found: "That ticket wasn't found.", + no_email: "No email to look up.", + banquet_disabled: "Banquet lookup isn't configured.", + db_error: `Database error: ${res.detail ?? ""}`, + }; + showError(msgs[res.reason] ?? "Lookup failed"); return; } - if (!res.found) { - showError(`Not a valid ticket:\n${raw.slice(0, 40)}`); - return; - } - setTicket(res.ticket); - setCount(Math.min(1, res.ticket.remaining)); - setPhase("confirm"); + setDonor(res.donor); + setDonorTicketName(res.ticketName); + if (res.donor.found) feedbackSuccess(); + else feedbackError(); + setPhase("banquet"); } catch (e: any) { - if (e?.name === "AuthError") { - router.replace("/login"); - return; - } + if (e?.name === "AuthError") return router.replace("/login"); showError(e?.message ?? "Lookup failed"); } }, [showError], ); - const handleCheckIn = useCallback(async () => { + const handleScan = useCallback( + async (raw: string) => { + if (mode === "banquet") { + runBanquet({ code: raw }); + return; + } + setPhase("busy"); + try { + const res = await lookup(raw); + if (!res.ok) return showError(`Database error: ${res.detail}`); + if (!res.found) return showError(`Not a valid ticket:\n${raw.slice(0, 40)}`); + const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining; + setTicket(res.ticket); + // Ice: default to grabbing all remaining bags at once. Tickets: default 1. + setCount(mode === "ice" ? Math.max(1, remaining) : Math.min(1, remaining)); + setPhase("confirm"); + } catch (e: any) { + if (e?.name === "AuthError") return router.replace("/login"); + showError(e?.message ?? "Lookup failed"); + } + }, + [mode, runBanquet, showError], + ); + + const handleRedeem = useCallback(async () => { if (!ticket || count < 1) return; setPhase("busy"); try { - const res = await redeem(ticket.code, count); + const res = await redeem(ticket.code, count, mode === "ice" ? "ice" : "tickets"); if (!res.ok) { + const remaining = mode === "ice" ? res.ticket?.ice.remaining ?? 0 : res.ticket?.remaining ?? 0; + const noun = mode === "ice" ? "ice bags" : "tickets"; const reasons: Record = { - exhausted: "All tickets on this code are already redeemed.", - insufficient: `Only ${res.ticket?.remaining ?? 0} left on this ticket.`, + exhausted: `All ${noun} on this code are already redeemed.`, + insufficient: `Only ${remaining} ${noun} left on this code.`, not_found: "Ticket not found.", db_error: `Database error: ${res.detail ?? ""}`, }; - showError(reasons[res.reason] ?? "Check-in failed"); + showError(reasons[res.reason] ?? "Redemption failed"); if (res.ticket) setTicket(res.ticket); return; } @@ -86,19 +137,19 @@ export default function ScannerScreen() { setPhase("success"); resumeTimer.current = setTimeout(resume, 4000); } catch (e: any) { - if (e?.name === "AuthError") { - router.replace("/login"); - return; - } - showError(e?.message ?? "Check-in failed"); + if (e?.name === "AuthError") return router.replace("/login"); + showError(e?.message ?? "Redemption failed"); } - }, [ticket, count, resume, showError]); + }, [ticket, count, mode, resume, showError]); const doLogout = useCallback(async () => { await logout(); router.replace("/login"); }, []); + const isIce = mode === "ice"; + const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : ""; + return ( @@ -113,22 +164,43 @@ export default function ScannerScreen() { + + {MODES.map((m) => ( + switchMode(m.key)} + > + + {m.icon} {m.label} + + + ))} + + {phase === "scanning" && ( - Point the camera at a ticket QR code + + {mode === "banquet" + ? "Scan a ticket to see donation total" + : isIce + ? "Scan a ticket to hand out ice" + : "Point the camera at a ticket QR code"} + )} {phase === "confirm" && ticket && ( - + @@ -137,27 +209,43 @@ export default function ScannerScreen() { {phase === "success" && ticket && ( - Checked in {checkedIn} - {ticket.name} - - {ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining - - + {isIce ? ( + <> + + {checkedIn} {successNoun} + + {ticket.name} + + {ticket.ice.redeemed} of {ticket.ice.total} bags taken · {ticket.ice.remaining} left + + + ) : ( + <> + Checked in {checkedIn} + {ticket.name} + + {ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining + + + + )} Database updated Tap to scan the next ticket )} + {phase === "banquet" && ( + + + Tap to scan again + + )} + {phase === "error" && ( Problem {message} - {ticket && ( - - {ticket.name} · {ticket.remaining} remaining - - )} Tap to try again )} @@ -168,15 +256,68 @@ export default function ScannerScreen() { )} + + {mode === "banquet" && (phase === "scanning" || phase === "banquet") && ( + + manualEmail.trim() && runBanquet({ email: manualEmail.trim() })} + /> + manualEmail.trim() && runBanquet({ email: manualEmail.trim() })} + > + Look up + + + )} ); } +function money(n: number): string { + return "$" + (Math.round(n * 100) / 100).toLocaleString(undefined, { maximumFractionDigits: 2 }); +} + +function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticketName: string }) { + if (!donor) return null; + if (!donor.found) { + return ( + <> + + No donations found + {donor.email} + {!!ticketName && Ticket: {ticketName}} + + ); + } + return ( + <> + {donor.name || ticketName || donor.email} + {money(donor.total)} + total donated + + Online {money(donor.online)} + Offline {money(donor.offline)} + + {donor.email} + + ); +} + function ExtrasRow({ ticket }: { ticket: TicketView }) { const tags: string[] = []; if (ticket.extras.carParking) tags.push("🚗 Car parking"); if (ticket.extras.rvParking) tags.push("🚐 RV parking"); - if (ticket.extras.iceAccess) tags.push("🧊 Ice access"); + 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; @@ -193,45 +334,50 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) { function ConfirmCard({ ticket, + isIce, count, setCount, - onCheckIn, + onConfirm, onCancel, }: { ticket: TicketView; + isIce: boolean; count: number; setCount: (n: number) => void; - onCheckIn: () => void; + onConfirm: () => void; onCancel: () => void; }) { - const exhausted = ticket.remaining <= 0; + const remaining = isIce ? ticket.ice.remaining : ticket.remaining; + const total = isIce ? ticket.ice.total : ticket.total; + const redeemed = isIce ? ticket.ice.redeemed : ticket.redeemed; + const exhausted = remaining <= 0; + const unit = isIce ? "bags of ice" : "tickets"; + const question = isIce ? "How many ice bags?" : "How many are entering now?"; + const cta = isIce ? `Give ${count}` : `Check in ${count}`; + return ( {ticket.name} {ticket.code} - {ticket.remaining} of{" "} - {ticket.total} remaining + {remaining} of {total} {unit} remaining - {ticket.redeemed} already redeemed - + {redeemed} already redeemed + {!isIce && } + {isIce && total === 0 && This ticket did not prepay for ice.} {exhausted ? ( - All tickets on this code are already redeemed. + total > 0 && All {unit} on this code are already redeemed. ) : ( <> - How many are entering now? + {question} setCount(Math.max(1, count - 1))} disabled={count <= 1} /> {count} - setCount(Math.min(ticket.remaining, count + 1))} - disabled={count >= ticket.remaining} - /> + setCount(Math.min(remaining, count + 1))} disabled={count >= remaining} /> - - Check in {count} + + {cta} )} @@ -262,71 +408,74 @@ const styles = StyleSheet.create({ brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, topActions: { flexDirection: "row", gap: 18 }, link: { color: theme.textDim, fontSize: 15, fontWeight: "600" }, + + modeBar: { flexDirection: "row", gap: 8, paddingHorizontal: 12, paddingBottom: 8 }, + modeBtn: { + flex: 1, + paddingVertical: 10, + borderRadius: 10, + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + alignItems: "center", + }, + modeBtnActive: { backgroundColor: theme.primary, borderColor: theme.primary }, + modeText: { color: theme.textDim, fontSize: 15, fontWeight: "700" }, + modeTextActive: { color: "#fff" }, + scannerArea: { flex: 1, position: "relative", overflow: "hidden" }, reticle: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center" }, - reticleBox: { - width: 240, - height: 240, - borderWidth: 3, - borderColor: "rgba(255,255,255,0.85)", - borderRadius: 24, - }, - hint: { color: "#fff", marginTop: 20, fontSize: 15, textShadowColor: "#000", textShadowRadius: 4 }, - busy: { - position: "absolute", top: 0, left: 0, right: 0, bottom: 0, - alignItems: "center", - justifyContent: "center", - backgroundColor: "rgba(0,0,0,0.4)", - }, + reticleBox: { width: 240, height: 240, borderWidth: 3, borderColor: "rgba(255,255,255,0.85)", borderRadius: 24 }, + hint: { color: "#fff", marginTop: 20, fontSize: 15, textAlign: "center", paddingHorizontal: 20, textShadowColor: "#000", textShadowRadius: 4 }, + busy: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, alignItems: "center", justifyContent: "center", backgroundColor: "rgba(0,0,0,0.4)" }, busyText: { color: "#fff", fontSize: 18, fontWeight: "600" }, bigIcon: { color: "#fff", fontSize: 96, fontWeight: "900", lineHeight: 104 }, - bigTitle: { color: "#fff", fontSize: 34, fontWeight: "800", marginTop: 4 }, + bigTitle: { color: "#fff", fontSize: 32, fontWeight: "800", marginTop: 4, textAlign: "center" }, name: { color: "#fff", fontSize: 24, fontWeight: "700", marginTop: 12, textAlign: "center" }, counts: { color: "rgba(255,255,255,0.95)", fontSize: 18, marginTop: 8, textAlign: "center" }, dbConfirm: { color: "#fff", fontSize: 15, marginTop: 16, fontWeight: "600" }, errorMsg: { color: "#fff", fontSize: 18, marginTop: 12, textAlign: "center", lineHeight: 24 }, tapHint: { color: "rgba(255,255,255,0.75)", fontSize: 14, marginTop: 24 }, + donorTotal: { color: "#fff", fontSize: 64, fontWeight: "900", marginTop: 10 }, + donorBreak: { flexDirection: "row", gap: 18, marginTop: 14 }, + donorBreakItem: { color: "rgba(255,255,255,0.95)", fontSize: 16, fontWeight: "600" }, + donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 14 }, + tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 }, - tag: { - color: "#fff", - backgroundColor: "rgba(255,255,255,0.18)", - paddingHorizontal: 10, - paddingVertical: 5, - borderRadius: 999, - fontSize: 13, - overflow: "hidden", - }, + tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" }, card: { maxHeight: "100%", width: "100%" }, cardContent: { alignItems: "center", paddingVertical: 8 }, cardName: { color: theme.text, fontSize: 26, fontWeight: "800", textAlign: "center" }, cardCode: { color: theme.textDim, fontSize: 15, marginTop: 4, letterSpacing: 1 }, - cardCounts: { color: theme.text, fontSize: 22, marginTop: 16 }, + cardCounts: { color: theme.text, fontSize: 22, marginTop: 16, textAlign: "center" }, cardSub: { color: theme.textDim, fontSize: 14, marginTop: 4 }, exhausted: { color: theme.dangerBright, fontSize: 17, marginTop: 20, textAlign: "center", fontWeight: "600" }, stepperLabel: { color: theme.text, fontSize: 16, marginTop: 22 }, stepper: { flexDirection: "row", alignItems: "center", gap: 24, marginTop: 12 }, - stepBtn: { - width: 64, - height: 64, - borderRadius: 32, - backgroundColor: theme.primary, - alignItems: "center", - justifyContent: "center", - }, + stepBtn: { width: 64, height: 64, borderRadius: 32, backgroundColor: theme.primary, alignItems: "center", justifyContent: "center" }, stepBtnDisabled: { backgroundColor: theme.cardBorder }, stepBtnText: { color: "#fff", fontSize: 32, fontWeight: "800", lineHeight: 36 }, stepValue: { color: theme.text, fontSize: 44, fontWeight: "800", minWidth: 64, textAlign: "center" }, - checkinBtn: { - backgroundColor: theme.successBright, - paddingHorizontal: 40, - paddingVertical: 16, - borderRadius: 14, - marginTop: 24, - }, + checkinBtn: { backgroundColor: theme.successBright, paddingHorizontal: 40, paddingVertical: 16, borderRadius: 14, marginTop: 24 }, checkinText: { color: "#06210f", fontSize: 22, fontWeight: "800" }, cancelBtn: { marginTop: 16, padding: 10 }, cancelText: { color: theme.textDim, fontSize: 16 }, + + emailBar: { flexDirection: "row", gap: 10, paddingHorizontal: 12, paddingVertical: 10, backgroundColor: theme.bg }, + emailInput: { + flex: 1, + backgroundColor: theme.card, + borderWidth: 1, + borderColor: theme.cardBorder, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + color: theme.text, + fontSize: 16, + }, + emailBtn: { backgroundColor: theme.primary, borderRadius: 12, paddingHorizontal: 18, justifyContent: "center" }, + emailBtnText: { color: "#fff", fontSize: 16, fontWeight: "700" }, }); diff --git a/app/lib/api.ts b/app/lib/api.ts index 57fe6b6..c678207 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -11,6 +11,12 @@ export const API_BASE = ? "" : (process.env.EXPO_PUBLIC_API_URL ?? "https://scan.beartariacampgrounds.com").replace(/\/+$/, ""); +export interface ResourceCount { + total: number; + redeemed: number; + remaining: number; +} + export interface TicketView { code: string; name: string; @@ -18,6 +24,7 @@ export interface TicketView { total: number; redeemed: number; remaining: number; + ice: ResourceCount; extras: { carParking: boolean; rvParking: boolean; @@ -108,10 +115,33 @@ export type RedeemResult = detail?: string; }; -export function redeem(code: string, count: number): Promise { +export type Resource = "tickets" | "ice"; + +export function redeem(code: string, count: number, resource: Resource = "tickets"): Promise { return authed("/api/redeem", { method: "POST", - body: JSON.stringify({ code, count }), + body: JSON.stringify({ code, count, resource }), + }); +} + +export interface DonorLookup { + found: boolean; + email: string; + name: string; + online: number; + offline: number; + total: number; + source: "master" | "transactions" | "none"; +} + +export type BanquetResult = + | { ok: true; ticketName: string; donor: DonorLookup } + | { ok: false; reason: "not_found" | "no_email" | "db_error" | "banquet_disabled"; detail?: string }; + +export function banquet(input: { code?: string; email?: string }): Promise { + return authed("/api/banquet", { + method: "POST", + body: JSON.stringify(input), }); } diff --git a/backend/src/config.ts b/backend/src/config.ts index 5831f35..d0b790e 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -10,6 +10,17 @@ const schema = z.object({ // Optional "2026 Ticket Audit Logs" table. If unset, audit logging is skipped. NOCODB_AUDIT_TABLE_ID: z.string().optional(), + // 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. + NOCODB_DONORS_TABLE_ID: z.string().optional(), + NOCODB_DONOR_ONLINE_TABLE_ID: z.string().optional(), + NOCODB_DONOR_OFFLINE_TABLE_ID: z.string().optional(), + + // Prepaid ice bags granted when a purchase includes ice but the webhook only + // sends a boolean (not an explicit bag count). + ICE_BAGS_DEFAULT: z.coerce.number().default(3), + MAILERSEND_API_TOKEN: z.string().min(1), MAIL_FROM_EMAIL: z.string().email(), MAIL_FROM_NAME: z.string().default("Beartaria Campgrounds"), diff --git a/backend/src/context.ts b/backend/src/context.ts index 310442a..fcf2c43 100644 --- a/backend/src/context.ts +++ b/backend/src/context.ts @@ -3,6 +3,7 @@ import { NocoDBClient } from "./services/nocodb.js"; import { Mailer } from "./services/mailer.js"; import { RedeemQueue } from "./services/redeemQueue.js"; import { AuditLogger } from "./services/audit.js"; +import { DonorService } from "./services/donors.js"; /** Shared services wired once at startup and hung off the Fastify instance. */ export interface AppContext { @@ -11,6 +12,7 @@ export interface AppContext { mailer: Mailer; queue: RedeemQueue; audit: AuditLogger; + donors: DonorService; } export function buildContext(config: Config): AppContext { @@ -20,6 +22,7 @@ export function buildContext(config: Config): AppContext { mailer: new Mailer(config), queue: new RedeemQueue(), audit: new AuditLogger(config), + donors: new DonorService(config), }; } diff --git a/backend/src/fields.ts b/backend/src/fields.ts index 4d653ca..c34bff7 100644 --- a/backend/src/fields.ts +++ b/backend/src/fields.ts @@ -24,6 +24,8 @@ export const COL = { redeemed: "Redeemed", submissionKey: "SubmissionKey", lastScanAt: "LastScanAt", + iceTotal: "Ice Total", // prepaid ice bags + iceRedeemed: "Ice Redeemed", // bags picked up } as const; /** Age-bracket columns, in order. */ @@ -74,6 +76,12 @@ export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number; })).filter((b) => b.count > 0); } +export interface ResourceCount { + total: number; + redeemed: number; + remaining: number; +} + export interface TicketView { code: string; name: string; @@ -81,6 +89,7 @@ export interface TicketView { total: number; redeemed: number; remaining: number; + ice: ResourceCount; extras: { carParking: boolean; rvParking: boolean; @@ -91,9 +100,15 @@ export interface TicketView { 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]); + const iceTotal = computeIceTotal(rec); + const iceRedeemed = num(rec[COL.iceRedeemed]); return { code: String(rec[COL.code] ?? ""), name: String(rec[COL.name] ?? ""), @@ -101,6 +116,11 @@ export function toView(rec: NocoRecord): TicketView { total, redeemed, remaining: Math.max(0, total - redeemed), + ice: { + total: iceTotal, + redeemed: iceRedeemed, + remaining: Math.max(0, iceTotal - iceRedeemed), + }, extras: { carParking: bool(rec[COL.carParking]), rvParking: bool(rec[COL.rvParking]), diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index 5c76f19..8411718 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -76,13 +76,54 @@ export async function ticketRoutes(app: FastifyInstance): Promise { properties: { code: { type: "string", minLength: 1, maxLength: 64 }, count: { type: "integer", minimum: -100, maximum: 100 }, + resource: { type: "string", enum: ["tickets", "ice"] }, }, }, }, }, async (req) => { - const { code, count } = req.body as { code: string; count?: number }; - return redeem(app.ctx, normalizeCode(code), count ?? 1); + const { code, count, resource } = req.body as { + code: string; + count?: number; + resource?: "tickets" | "ice"; + }; + return redeem(app.ctx, normalizeCode(code), count ?? 1, resource ?? "tickets"); + }, + ); + + // Banquet mode: total donations for the email on a ticket (or a manual email). + app.post( + "/api/banquet", + { + preHandler: requireStaff, + schema: { + body: { + type: "object", + properties: { + code: { type: "string", maxLength: 64 }, + email: { type: "string", maxLength: 200 }, + }, + }, + }, + }, + async (req, reply) => { + if (!app.ctx.donors.enabled) { + return reply.code(422).send({ ok: false, reason: "banquet_disabled" }); + } + const { code, email } = req.body as { code?: string; email?: string }; + let lookupEmail = (email ?? "").trim(); + let ticketName = ""; + // If a ticket code was scanned, resolve its email. + if (!lookupEmail && code) { + const res = await lookupByCode(app.ctx, normalizeCode(code)); + if (!res.ok) return { ok: false, reason: "db_error", detail: res.detail }; + if (!res.found) return { ok: false, reason: "not_found" }; + lookupEmail = res.ticket.email; + ticketName = res.ticket.name; + } + if (!lookupEmail) return { ok: false, reason: "no_email" }; + const donor = await app.ctx.donors.lookup(lookupEmail); + return { ok: true, ticketName, donor }; }, ); diff --git a/backend/src/routes/webhook.ts b/backend/src/routes/webhook.ts index 7948c22..b1352a8 100644 --- a/backend/src/routes/webhook.ts +++ b/backend/src/routes/webhook.ts @@ -60,6 +60,15 @@ export async function webhookRoutes(app: FastifyInstance): Promise { .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, { @@ -70,6 +79,7 @@ export async function webhookRoutes(app: FastifyInstance): Promise { 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, diff --git a/backend/src/services/audit.ts b/backend/src/services/audit.ts index 14e6b0e..58b80f8 100644 --- a/backend/src/services/audit.ts +++ b/backend/src/services/audit.ts @@ -17,7 +17,7 @@ export interface AuditEntry { name: string; remainingAfter: number; at: string; // ISO - action: "check-in" | "undo"; + action: "check-in" | "undo" | "ice" | "ice-undo"; } export interface AuditRow extends AuditEntry { diff --git a/backend/src/services/donors.ts b/backend/src/services/donors.ts new file mode 100644 index 0000000..580ad7b --- /dev/null +++ b/backend/src/services/donors.ts @@ -0,0 +1,110 @@ +import type { Config } from "../config.js"; + +export interface DonorLookup { + found: boolean; + email: string; + name: string; + online: number; + offline: number; + total: number; + source: "master" | "transactions" | "none"; +} + +/** + * Looks up a donor's total giving for Banquet mode. Primary source is the + * "Donors Master List" (which carries pre-rolled Total Donations / Total + * Online / Total Offline). Falls back to summing the online + offline + * transaction tables by email when the donor isn't in the master list. + */ +export class DonorService { + private readonly base: string; + private readonly token: string; + private readonly masterId: string | null; + private readonly onlineId: string | null; + private readonly offlineId: string | null; + + constructor( + cfg: Pick< + Config, + | "NOCODB_BASE_URL" + | "NOCODB_API_TOKEN" + | "NOCODB_DONORS_TABLE_ID" + | "NOCODB_DONOR_ONLINE_TABLE_ID" + | "NOCODB_DONOR_OFFLINE_TABLE_ID" + >, + ) { + this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, ""); + this.token = cfg.NOCODB_API_TOKEN; + this.masterId = cfg.NOCODB_DONORS_TABLE_ID ?? null; + this.onlineId = cfg.NOCODB_DONOR_ONLINE_TABLE_ID ?? null; + this.offlineId = cfg.NOCODB_DONOR_OFFLINE_TABLE_ID ?? null; + } + + get enabled(): boolean { + return this.masterId !== null || (this.onlineId !== null && this.offlineId !== null); + } + + private async list(tableId: string, where: string, limit = 50): Promise { + const url = new URL(`${this.base}/api/v2/tables/${tableId}/records`); + if (where) url.searchParams.set("where", where); + url.searchParams.set("limit", String(limit)); + const res = await fetch(url.toString(), { + headers: { "xc-token": this.token, "Content-Type": "application/json" }, + }); + if (!res.ok) throw new Error(`NocoDB ${res.status} on donor lookup`); + const body: any = await res.json(); + return body?.list ?? []; + } + + async lookup(rawEmail: string): Promise { + const email = rawEmail.trim(); + const esc = email.replace(/[(),]/g, " "); + const empty: DonorLookup = { found: false, email, name: "", online: 0, offline: 0, total: 0, source: "none" }; + if (!email) return empty; + + // 1) Master list (authoritative rolled-up totals), matching either email. + if (this.masterId) { + const rows = await this.list( + this.masterId, + `(Email,eq,${esc})~or(Alternate Email,eq,${esc})`, + 1, + ); + if (rows.length) { + const r = rows[0]; + const online = num(r["Total Online Donations"]); + const offline = num(r["Total Offline Donations"]); + const total = r["Total Donations"] !== undefined ? num(r["Total Donations"]) : online + offline; + const name = + r["Display Name"] || + [r["First Name"], r["Last Name"]].filter(Boolean).join(" ") || + r["Bear Name"] || + ""; + return { found: true, email, name, online, offline, total, source: "master" }; + } + } + + // 2) Fallback: sum transaction tables by email. + if (this.onlineId && this.offlineId) { + const [onlineRows, offlineRows] = await Promise.all([ + this.list(this.onlineId, `(Email,eq,${esc})`, 200), + this.list(this.offlineId, `(Email,eq,${esc})`, 200), + ]); + const online = sum(onlineRows, "Donation Amount"); + const offline = sum(offlineRows, "Donation Amount"); + const name = onlineRows[0]?.["Bear Name"] || onlineRows[0]?.["Name"] || offlineRows[0]?.["Name"] || ""; + const found = online + offline > 0 || onlineRows.length + offlineRows.length > 0; + return { found, email, name, online, offline, total: online + offline, source: found ? "transactions" : "none" }; + } + + return empty; + } +} + +function num(v: unknown): number { + const n = Number(v); + return Number.isFinite(n) ? n : 0; +} + +function sum(rows: any[], field: string): number { + return rows.reduce((acc, r) => acc + num(r[field]), 0); +} diff --git a/backend/src/test/fakeNocodb.ts b/backend/src/test/fakeNocodb.ts index 65ed0fe..46cbd89 100644 --- a/backend/src/test/fakeNocodb.ts +++ b/backend/src/test/fakeNocodb.ts @@ -80,6 +80,7 @@ export function fakeContext(db: FakeNocoDB): AppContext { }, recent: async () => auditEntries, } as any, + donors: { enabled: false, lookup: async () => ({ found: false }) } as any, }; } diff --git a/backend/src/test/redeem.test.ts b/backend/src/test/redeem.test.ts index 51e71fe..72738a6 100644 --- a/backend/src/test/redeem.test.ts +++ b/backend/src/test/redeem.test.ts @@ -73,6 +73,36 @@ describe("redeem", () => { expect((ctx.audit as any).entries).toHaveLength(0); }); + it("redeems ice bags independently of ticket check-ins", async () => { + const db = new FakeNocoDB(); + await seedTicket(db, { + code: "BC26-ICE-0003", + ages: { "Ages 26-45": 2 }, + }); + // Give the ticket 3 prepaid ice bags. + db.rows[0]["Ice Total"] = 3; + db.rows[0]["Ice Redeemed"] = 0; + const ctx = fakeContext(db); + + // Grab all 3 bags at once. + const ice = await redeem(ctx, "BC26-ICE-0003", 3, "ice"); + expect(ice.ok).toBe(true); + if (ice.ok) expect(ice.ticket.ice.remaining).toBe(0); + expect(db.rows[0]["Ice Redeemed"]).toBe(3); + // Ticket check-ins are untouched by ice redemption. + expect(db.rows[0].Redeemed ?? 0).toBe(0); + + // No ice left. + const again = await redeem(ctx, "BC26-ICE-0003", 1, "ice"); + expect(again.ok).toBe(false); + if (!again.ok) expect(again.reason).toBe("exhausted"); + + // Ticket check-in still works after ice is gone. + const checkin = await redeem(ctx, "BC26-ICE-0003", 2, "tickets"); + expect(checkin.ok).toBe(true); + if (checkin.ok) expect(checkin.ticket.remaining).toBe(0); + }); + it("returns not_found for unknown codes", async () => { const ctx = fakeContext(new FakeNocoDB()); const r = await redeem(ctx, "BC26-ZZZZ-9999", 1); diff --git a/backend/src/ticketService.ts b/backend/src/ticketService.ts index 76dce8e..60d27e5 100644 --- a/backend/src/ticketService.ts +++ b/backend/src/ticketService.ts @@ -1,9 +1,23 @@ import type { AppContext } from "./context.js"; import { generateCode } from "./services/code.js"; -import { COL, toView, computeTotal, type NocoRecord, type TicketView } from "./fields.js"; +import { COL, toView, computeTotal, computeIceTotal, type NocoRecord, type TicketView } from "./fields.js"; export type { TicketView }; +export type Resource = "tickets" | "ice"; + +interface ResourceConfig { + totalFn: (rec: NocoRecord) => number; + redeemedCol: string; + auditIn: "check-in" | "ice"; + auditUndo: "undo" | "ice-undo"; +} + +const RESOURCES: Record = { + tickets: { totalFn: computeTotal, redeemedCol: COL.redeemed, auditIn: "check-in", auditUndo: "undo" }, + ice: { totalFn: computeIceTotal, redeemedCol: COL.iceRedeemed, auditIn: "ice", auditUndo: "ice-undo" }, +}; + export type LookupResult = | { ok: true; found: true; ticket: TicketView } | { ok: true; found: false } @@ -41,13 +55,21 @@ export type RedeemResult = * check in more people than the ticket allows). Negative count undoes a * mistaken check-in, clamped so Redeemed never drops below 0. */ -export async function redeem(ctx: AppContext, code: string, count: number): Promise { +export async function redeem( + ctx: AppContext, + code: string, + count: number, + resource: Resource = "tickets", +): Promise { const n = Math.trunc(count); if (!Number.isFinite(n) || n === 0) { return { ok: false, reason: "insufficient", detail: "count must be a non-zero integer" }; } + const cfg = RESOURCES[resource]; - return ctx.queue.run(code, async () => { + // Serialize per (code, resource) so ice and ticket redemptions don't block + // each other but same-resource scans still can't double-spend. + return ctx.queue.run(`${resource}:${code}`, async () => { let rec: NocoRecord | null; try { rec = await ctx.nocodb.findByCode(code); @@ -56,8 +78,8 @@ export async function redeem(ctx: AppContext, code: string, count: number): Prom } if (!rec) return { ok: false, reason: "not_found" }; - const total = computeTotal(rec); - const redeemed = Number(rec[COL.redeemed]) || 0; + const total = cfg.totalFn(rec); + const redeemed = Number(rec[cfg.redeemedCol]) || 0; const remaining = Math.max(0, total - redeemed); if (n > 0 && remaining === 0) { @@ -70,22 +92,23 @@ export async function redeem(ctx: AppContext, code: string, count: number): Prom const next = Math.min(total, Math.max(0, redeemed + n)); try { const updated = await ctx.nocodb.update(rec.Id, { - [COL.redeemed]: next, + [cfg.redeemedCol]: next, [COL.lastScanAt]: new Date().toISOString(), }); // Trust our computed `next` but prefer the DB's echoed value if present. - const confirmed = { ...rec, [COL.redeemed]: Number(updated?.[COL.redeemed] ?? next) }; + const confirmed = { ...rec, [cfg.redeemedCol]: Number(updated?.[cfg.redeemedCol] ?? next) }; const view = toView(confirmed); const delta = next - redeemed; + const remainingAfter = resource === "ice" ? view.ice.remaining : view.remaining; if (delta !== 0) { // Non-fatal: audit failures never block a check-in. await ctx.audit.log({ code: view.code, people: delta, name: view.name, - remainingAfter: view.remaining, + remainingAfter, at: new Date().toISOString(), - action: delta >= 0 ? "check-in" : "undo", + action: delta >= 0 ? cfg.auditIn : cfg.auditUndo, }); } return { ok: true, ticket: view, checkedIn: delta }; @@ -109,6 +132,7 @@ export interface WebhookInput { carParking?: boolean; rvParking?: boolean; iceAccess?: boolean; + iceBags?: number; // prepaid ice bags paymentMethod?: string; ages: Record; // NocoDB age-column title -> count submissionKey: string; @@ -137,6 +161,8 @@ export async function createTicket( [COL.email]: input.email, [COL.code]: code, [COL.redeemed]: 0, + [COL.iceTotal]: input.iceBags ?? 0, + [COL.iceRedeemed]: 0, [COL.submissionKey]: input.submissionKey, ...input.ages, };