Add Check-in / Ice / Banquet scan modes

- Ice mode: prepaid ice bags (Ice Total / Ice Redeemed columns) redeemed
  independently of ticket check-ins; grab all bags at once or some now.
- Banquet mode: donor total (online + offline) looked up by the ticket's
  email via the Donors Master List, with a manual email override. New
  DonorService + POST /api/banquet.
- Redeem generalized over a resource (tickets|ice); audit records ice actions.
- App gains a mode selector; webhook maps ice_bags (defaults to
  ICE_BAGS_DEFAULT when only a boolean ice option is present).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-08 04:07:51 +00:00
parent b36d63a6a4
commit d293e53ee6
15 changed files with 587 additions and 125 deletions

View file

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

View file

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

View file

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

View file

@ -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<Mode>("tickets");
const [phase, setPhase] = useState<Phase>("scanning");
const [ticket, setTicket] = useState<TicketView | null>(null);
const [count, setCount] = useState(1);
const [message, setMessage] = useState("");
const [checkedIn, setCheckedIn] = useState(0);
const [donor, setDonor] = useState<DonorLookup | null>(null);
const [donorTicketName, setDonorTicketName] = useState("");
const [manualEmail, setManualEmail] = useState("");
const resumeTimer = useRef<ReturnType<typeof setTimeout> | 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<string, string> = {
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<string, string> = {
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 (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}>
@ -113,22 +164,43 @@ export default function ScannerScreen() {
</View>
</View>
<View style={styles.modeBar}>
{MODES.map((m) => (
<Pressable
key={m.key}
style={[styles.modeBtn, mode === m.key && styles.modeBtnActive]}
onPress={() => switchMode(m.key)}
>
<Text style={[styles.modeText, mode === m.key && styles.modeTextActive]}>
{m.icon} {m.label}
</Text>
</Pressable>
))}
</View>
<View style={styles.scannerArea}>
<QRScanner onScan={handleScan} active={phase === "scanning"} />
{phase === "scanning" && (
<View pointerEvents="none" style={styles.reticle}>
<View style={styles.reticleBox} />
<Text style={styles.hint}>Point the camera at a ticket QR code</Text>
<Text style={styles.hint}>
{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"}
</Text>
</View>
)}
{phase === "confirm" && ticket && (
<ResultOverlay status="neutral" onDismiss={undefined}>
<ResultOverlay status="neutral">
<ConfirmCard
ticket={ticket}
isIce={isIce}
count={count}
setCount={setCount}
onCheckIn={handleCheckIn}
onConfirm={handleRedeem}
onCancel={resume}
/>
</ResultOverlay>
@ -137,27 +209,43 @@ export default function ScannerScreen() {
{phase === "success" && ticket && (
<ResultOverlay status="success" onDismiss={resume}>
<Text style={styles.bigIcon}></Text>
<Text style={styles.bigTitle}>Checked in {checkedIn}</Text>
<Text style={styles.name}>{ticket.name}</Text>
<Text style={styles.counts}>
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
</Text>
<ExtrasRow ticket={ticket} />
{isIce ? (
<>
<Text style={styles.bigTitle}>
{checkedIn} {successNoun}
</Text>
<Text style={styles.name}>{ticket.name}</Text>
<Text style={styles.counts}>
{ticket.ice.redeemed} of {ticket.ice.total} bags taken · {ticket.ice.remaining} left
</Text>
</>
) : (
<>
<Text style={styles.bigTitle}>Checked in {checkedIn}</Text>
<Text style={styles.name}>{ticket.name}</Text>
<Text style={styles.counts}>
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
</Text>
<ExtrasRow ticket={ticket} />
</>
)}
<Text style={styles.dbConfirm}>Database updated</Text>
<Text style={styles.tapHint}>Tap to scan the next ticket</Text>
</ResultOverlay>
)}
{phase === "banquet" && (
<ResultOverlay status={donor?.found ? "success" : "error"} onDismiss={resume}>
<BanquetResult donor={donor} ticketName={donorTicketName} />
<Text style={styles.tapHint}>Tap to scan again</Text>
</ResultOverlay>
)}
{phase === "error" && (
<ResultOverlay status="error" onDismiss={resume}>
<Text style={styles.bigIcon}></Text>
<Text style={styles.bigTitle}>Problem</Text>
<Text style={styles.errorMsg}>{message}</Text>
{ticket && (
<Text style={styles.counts}>
{ticket.name} · {ticket.remaining} remaining
</Text>
)}
<Text style={styles.tapHint}>Tap to try again</Text>
</ResultOverlay>
)}
@ -168,15 +256,68 @@ export default function ScannerScreen() {
</View>
)}
</View>
{mode === "banquet" && (phase === "scanning" || phase === "banquet") && (
<View style={styles.emailBar}>
<TextInput
style={styles.emailInput}
placeholder="Or look up an email manually"
placeholderTextColor={theme.textDim}
value={manualEmail}
onChangeText={setManualEmail}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
returnKeyType="search"
onSubmitEditing={() => manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
/>
<Pressable
style={styles.emailBtn}
onPress={() => manualEmail.trim() && runBanquet({ email: manualEmail.trim() })}
>
<Text style={styles.emailBtnText}>Look up</Text>
</Pressable>
</View>
)}
</SafeAreaView>
);
}
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 (
<>
<Text style={styles.bigIcon}></Text>
<Text style={styles.bigTitle}>No donations found</Text>
<Text style={styles.name}>{donor.email}</Text>
{!!ticketName && <Text style={styles.counts}>Ticket: {ticketName}</Text>}
</>
);
}
return (
<>
<Text style={styles.bigTitle}>{donor.name || ticketName || donor.email}</Text>
<Text style={styles.donorTotal}>{money(donor.total)}</Text>
<Text style={styles.counts}>total donated</Text>
<View style={styles.donorBreak}>
<Text style={styles.donorBreakItem}>Online {money(donor.online)}</Text>
<Text style={styles.donorBreakItem}>Offline {money(donor.offline)}</Text>
</View>
<Text style={styles.donorEmail}>{donor.email}</Text>
</>
);
}
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 (
<ScrollView style={styles.card} contentContainerStyle={styles.cardContent}>
<Text style={styles.cardName}>{ticket.name}</Text>
<Text style={styles.cardCode}>{ticket.code}</Text>
<Text style={styles.cardCounts}>
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{ticket.remaining}</Text> of{" "}
{ticket.total} remaining
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining
</Text>
<Text style={styles.cardSub}>{ticket.redeemed} already redeemed</Text>
<ExtrasRow ticket={ticket} />
<Text style={styles.cardSub}>{redeemed} already redeemed</Text>
{!isIce && <ExtrasRow ticket={ticket} />}
{isIce && total === 0 && <Text style={styles.exhausted}>This ticket did not prepay for ice.</Text>}
{exhausted ? (
<Text style={styles.exhausted}>All tickets on this code are already redeemed.</Text>
total > 0 && <Text style={styles.exhausted}>All {unit} on this code are already redeemed.</Text>
) : (
<>
<Text style={styles.stepperLabel}>How many are entering now?</Text>
<Text style={styles.stepperLabel}>{question}</Text>
<View style={styles.stepper}>
<StepBtn label="" onPress={() => setCount(Math.max(1, count - 1))} disabled={count <= 1} />
<Text style={styles.stepValue}>{count}</Text>
<StepBtn
label="+"
onPress={() => setCount(Math.min(ticket.remaining, count + 1))}
disabled={count >= ticket.remaining}
/>
<StepBtn label="+" onPress={() => setCount(Math.min(remaining, count + 1))} disabled={count >= remaining} />
</View>
<Pressable style={styles.checkinBtn} onPress={onCheckIn}>
<Text style={styles.checkinText}>Check in {count}</Text>
<Pressable style={styles.checkinBtn} onPress={onConfirm}>
<Text style={styles.checkinText}>{cta}</Text>
</Pressable>
</>
)}
@ -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" },
});

View file

@ -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<RedeemResult> {
export type Resource = "tickets" | "ice";
export function redeem(code: string, count: number, resource: Resource = "tickets"): Promise<RedeemResult> {
return authed<RedeemResult>("/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<BanquetResult> {
return authed<BanquetResult>("/api/banquet", {
method: "POST",
body: JSON.stringify(input),
});
}

View file

@ -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"),

View file

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

View file

@ -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]),

View file

@ -76,13 +76,54 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
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 };
},
);

View file

@ -60,6 +60,15 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
.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<ReturnType<typeof createTicket>>;
try {
result = await createTicket(app.ctx, {
@ -70,6 +79,7 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
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,

View file

@ -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 {

View file

@ -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<any[]> {
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<DonorLookup> {
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);
}

View file

@ -80,6 +80,7 @@ export function fakeContext(db: FakeNocoDB): AppContext {
},
recent: async () => auditEntries,
} as any,
donors: { enabled: false, lookup: async () => ({ found: false }) } as any,
};
}

View file

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

View file

@ -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<Resource, ResourceConfig> = {
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<RedeemResult> {
export async function redeem(
ctx: AppContext,
code: string,
count: number,
resource: Resource = "tickets",
): Promise<RedeemResult> {
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<string, number>; // 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,
};