Compare commits

..

No commits in common. "main" and "v0.0.8" have entirely different histories.
main ... v0.0.8

38 changed files with 253 additions and 2352 deletions

View file

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

View file

@ -32,11 +32,8 @@ ENV WEB_DIR=/srv/web
ENV PORT=8080 ENV PORT=8080
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
# Run as the non-root node user shipped in the base image. /data is a mount # Run as the non-root node user shipped in the base image.
# point for the runtime state volume — create it owned by node so a fresh named RUN chown -R node:node /srv
# volume inherits writable ownership.
RUN chown -R node:node /srv && mkdir -p /data && chown node:node /data
ENV STATE_DIR=/data
USER node USER node
EXPOSE 8080 EXPOSE 8080

View file

@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "Camp Scan", "name": "Camp Scan",
"slug": "camptickets", "slug": "camptickets",
"version": "0.3.0", "version": "0.1.0",
"orientation": "portrait", "orientation": "portrait",
"scheme": "campscan", "scheme": "campscan",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
@ -10,7 +10,7 @@
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"android": { "android": {
"package": "top.mowden.campscan", "package": "top.mowden.campscan",
"versionCode": 3, "versionCode": 1,
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0f1a12" "backgroundColor": "#0f1a12"

View file

@ -4,7 +4,6 @@ import { Stack, useRouter, useSegments } from "expo-router";
import { SafeAreaProvider } from "react-native-safe-area-context"; import { SafeAreaProvider } from "react-native-safe-area-context";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { AuthProvider, useAuth } from "../lib/auth"; import { AuthProvider, useAuth } from "../lib/auth";
import { MenuProvider } from "../lib/menu";
import { theme } from "../lib/theme"; import { theme } from "../lib/theme";
export default function RootLayout() { export default function RootLayout() {
@ -12,9 +11,7 @@ export default function RootLayout() {
<SafeAreaProvider> <SafeAreaProvider>
<StatusBar style="light" /> <StatusBar style="light" />
<AuthProvider> <AuthProvider>
<MenuProvider> <AuthGate />
<AuthGate />
</MenuProvider>
</AuthProvider> </AuthProvider>
</SafeAreaProvider> </SafeAreaProvider>
); );

View file

@ -4,7 +4,6 @@ import { router } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context"; import { SafeAreaView } from "react-native-safe-area-context";
import { searchTickets, redeem, getAudit, type TicketView, type AuditEntry } from "../lib/api"; import { searchTickets, redeem, getAudit, type TicketView, type AuditEntry } from "../lib/api";
import { feedbackSuccess, feedbackError } from "../lib/feedback"; import { feedbackSuccess, feedbackError } from "../lib/feedback";
import { useMenu } from "../lib/menu";
import { theme } from "../lib/theme"; import { theme } from "../lib/theme";
function fmtTime(iso: string): string { function fmtTime(iso: string): string {
@ -44,7 +43,6 @@ function AuditList({ entries }: { entries: AuditEntry[] }) {
} }
export default function AdminScreen() { export default function AdminScreen() {
const { open: openMenu } = useMenu();
const [q, setQ] = useState(""); const [q, setQ] = useState("");
const [results, setResults] = useState<TicketView[]>([]); const [results, setResults] = useState<TicketView[]>([]);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
@ -121,8 +119,10 @@ export default function AdminScreen() {
return ( return (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}> <SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}> <View style={styles.topbar}>
<Pressable onPress={openMenu} hitSlop={12}> <Pressable onPress={() => router.replace("/")} hitSlop={10}>
<Text style={styles.hamburger}></Text> <Text style={styles.link} numberOfLines={1}>
Scanner
</Text>
</Pressable> </Pressable>
<Text style={styles.brand}>Admin lookup</Text> <Text style={styles.brand}>Admin lookup</Text>
<View style={{ width: 72 }} /> <View style={{ width: 72 }} />
@ -209,7 +209,7 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti
if (e.rvParking) tags.push("🚐 RV"); if (e.rvParking) tags.push("🚐 RV");
if (e.utv) tags.push("🏍️ UTV"); if (e.utv) tags.push("🏍️ UTV");
if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total}`); 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 (e.freeUnder5 > 0) tags.push(`👶 ${e.freeUnder5} free`);
return ( return (
<View style={styles.card}> <View style={styles.card}>
@ -276,7 +276,6 @@ const styles = StyleSheet.create({
paddingVertical: 10, paddingVertical: 10,
}, },
brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" },
link: { color: theme.textDim, fontSize: 16, fontWeight: "600" }, link: { color: theme.textDim, fontSize: 16, fontWeight: "600" },
searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 }, searchRow: { flexDirection: "row", gap: 10, paddingHorizontal: 16, marginTop: 6 },
input: { input: {

View file

@ -6,7 +6,6 @@ import QRScanner from "../components/QRScanner";
import ResultOverlay from "../components/ResultOverlay"; import ResultOverlay from "../components/ResultOverlay";
import { lookup, redeem, banquet, type TicketView, type DonorLookup } from "../lib/api"; import { lookup, redeem, banquet, type TicketView, type DonorLookup } from "../lib/api";
import { useAuth } from "../lib/auth"; import { useAuth } from "../lib/auth";
import { useMenu } from "../lib/menu";
import { feedbackSuccess, feedbackError } from "../lib/feedback"; import { feedbackSuccess, feedbackError } from "../lib/feedback";
import { theme } from "../lib/theme"; import { theme } from "../lib/theme";
@ -20,8 +19,7 @@ const MODES: { key: Mode; label: string; icon: string }[] = [
]; ];
export default function ScannerScreen() { export default function ScannerScreen() {
const { operator } = useAuth(); const { signOut, operator } = useAuth();
const { open: openMenu } = useMenu();
const [mode, setMode] = useState<Mode>("tickets"); const [mode, setMode] = useState<Mode>("tickets");
const [phase, setPhase] = useState<Phase>("scanning"); const [phase, setPhase] = useState<Phase>("scanning");
const [ticket, setTicket] = useState<TicketView | null>(null); const [ticket, setTicket] = useState<TicketView | null>(null);
@ -109,9 +107,8 @@ export default function ScannerScreen() {
feedbackSuccess(); feedbackSuccess();
const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining; const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining;
setTicket(res.ticket); setTicket(res.ticket);
// Default to 1 (people usually grab ice a bag at a time); staff can bump // Ice: default to grabbing all remaining bags at once. Tickets: default 1.
// the count up. Clamp to what's left so a 0-remaining ticket stays at 0. setCount(mode === "ice" ? Math.max(1, remaining) : Math.min(1, remaining));
setCount(Math.min(1, remaining));
setPhase("confirm"); setPhase("confirm");
} catch (e: any) { } catch (e: any) {
if (e?.name === "AuthError") return router.replace("/login"); if (e?.name === "AuthError") return router.replace("/login");
@ -150,19 +147,29 @@ export default function ScannerScreen() {
} }
}, [ticket, count, mode, resume, showError]); }, [ticket, count, mode, resume, showError]);
const doLogout = useCallback(async () => {
await signOut();
// The auth gate redirects to /login when signedIn flips to false.
}, [signOut]);
const isIce = mode === "ice"; const isIce = mode === "ice";
const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : ""; const successNoun = isIce ? (checkedIn === 1 ? "bag of ice" : "bags of ice") : "";
return ( return (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}> <SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}> <View style={styles.topbar}>
<Pressable onPress={openMenu} hitSlop={12}> <View>
<Text style={styles.hamburger}></Text>
</Pressable>
<View style={styles.titleWrap}>
<Text style={styles.brand}>🐻 Camp Scan</Text> <Text style={styles.brand}>🐻 Camp Scan</Text>
{!!operator && <Text style={styles.operator}>{operator}</Text>} {!!operator && <Text style={styles.operator}>{operator}</Text>}
</View> </View>
<View style={styles.topActions}>
<Pressable onPress={() => router.push("/admin")} hitSlop={10}>
<Text style={styles.link}>Admin</Text>
</Pressable>
<Pressable onPress={doLogout} hitSlop={10}>
<Text style={styles.link}>Sign out</Text>
</Pressable>
</View>
</View> </View>
<View style={styles.modeBar}> <View style={styles.modeBar}>
@ -253,7 +260,6 @@ export default function ScannerScreen() {
<Text style={styles.counts}> <Text style={styles.counts}>
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining {ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
</Text> </Text>
<PartyPanel ticket={ticket} />
<AdultNames names={ticket.adultNames} /> <AdultNames names={ticket.adultNames} />
<ExtrasRow ticket={ticket} /> <ExtrasRow ticket={ticket} />
</> </>
@ -348,7 +354,7 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) {
if (e.rvParking) tags.push("🚐 RV parking"); if (e.rvParking) tags.push("🚐 RV parking");
if (e.utv) tags.push("🏍️ UTV/ATV"); 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.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 (e.freeUnder5 > 0) tags.push(`👶 ${e.freeUnder5} under 5 (free)`);
if (!tags.length) return null; if (!tags.length) return null;
return ( return (
<View style={styles.tags}> <View style={styles.tags}>
@ -367,8 +373,6 @@ const TYPE_ICON: Record<string, string> = {
Performer: "🎭", Performer: "🎭",
Volunteer: "🙌", Volunteer: "🙌",
Speaker: "🎤", Speaker: "🎤",
"Food Vendor": "🍔",
Vendor: "🛒",
}; };
function TypeBadge({ type }: { type: string }) { function TypeBadge({ type }: { type: string }) {
@ -395,45 +399,6 @@ function AdultNames({ names }: { names: string[] }) {
); );
} }
/**
* Big Adults / Youth / Kids breakdown so gate staff can eyeball the party
* against the ticket a deterrent for adults signing up under a (free/cheaper)
* younger bracket. Adults (18+) and Youth (13-16) are the paid tickets; Kids
* (0-12) are free. A detail line breaks the kids into their age bands.
*/
function PartyPanel({ ticket }: { ticket: TicketView }) {
const get = (b: string) => ticket.ages.find((a) => a.bracket === b)?.count ?? 0;
const adults = get("Adults");
const youth = get("Youth 13-16");
const kidBrackets = ticket.ages.filter((a) => a.bracket.startsWith("Kids"));
const kids = kidBrackets.reduce((s, a) => s + a.count, 0);
return (
<View style={styles.party}>
<View style={styles.partyRow}>
<View style={styles.partyCell}>
<Text style={styles.partyNum}>{adults}</Text>
<Text style={styles.partyLbl}>ADULTS{"\n"}18+</Text>
</View>
<View style={styles.partyDivider} />
<View style={styles.partyCell}>
<Text style={styles.partyNum}>{youth}</Text>
<Text style={styles.partyLbl}>YOUTH{"\n"}13-16</Text>
</View>
<View style={styles.partyDivider} />
<View style={styles.partyCell}>
<Text style={styles.partyNum}>{kids}</Text>
<Text style={styles.partyLbl}>KIDS{"\n"}0-12</Text>
</View>
</View>
{kidBrackets.length > 0 && (
<Text style={styles.partyDetail}>
kids: {kidBrackets.map((a) => `${a.count}× ${a.bracket.replace(/^Kids\s*/, "")}`).join(" · ")}
</Text>
)}
</View>
);
}
function ConfirmCard({ function ConfirmCard({
ticket, ticket,
isIce, isIce,
@ -462,7 +427,6 @@ function ConfirmCard({
<Text style={styles.cardName}>{ticket.name}</Text> <Text style={styles.cardName}>{ticket.name}</Text>
<TypeBadge type={ticket.ticketType} /> <TypeBadge type={ticket.ticketType} />
<Text style={styles.cardCode}>{ticket.code}</Text> <Text style={styles.cardCode}>{ticket.code}</Text>
{!isIce && <PartyPanel ticket={ticket} />}
<Text style={styles.cardCounts}> <Text style={styles.cardCounts}>
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining <Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining
</Text> </Text>
@ -510,8 +474,6 @@ const styles = StyleSheet.create({
paddingHorizontal: 16, paddingHorizontal: 16,
paddingVertical: 10, paddingVertical: 10,
}, },
hamburger: { color: theme.text, fontSize: 26, fontWeight: "700", paddingRight: 4 },
titleWrap: { flex: 1, marginLeft: 12 },
brand: { color: theme.text, fontSize: 18, fontWeight: "700" }, brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
operator: { color: theme.textDim, fontSize: 13, marginTop: 1 }, operator: { color: theme.textDim, fontSize: 13, marginTop: 1 },
topActions: { flexDirection: "row", gap: 18, alignItems: "center" }, topActions: { flexDirection: "row", gap: 18, alignItems: "center" },
@ -576,23 +538,6 @@ const styles = StyleSheet.create({
typeBadgeText: { color: "#fff", fontSize: 20, fontWeight: "900", letterSpacing: 1 }, typeBadgeText: { color: "#fff", fontSize: 20, fontWeight: "900", letterSpacing: 1 },
namesBox: { marginTop: 12, alignItems: "center", gap: 3 }, namesBox: { marginTop: 12, alignItems: "center", gap: 3 },
nameLine: { color: "#fff", fontSize: 18, fontWeight: "600", textAlign: "center" }, nameLine: { color: "#fff", fontSize: 18, fontWeight: "600", textAlign: "center" },
party: {
alignSelf: "stretch",
backgroundColor: "#1d2a1f",
borderWidth: 2,
borderColor: theme.warn,
borderRadius: 14,
paddingVertical: 10,
paddingHorizontal: 10,
marginTop: 10,
marginBottom: 2,
},
partyRow: { flexDirection: "row", alignItems: "center", justifyContent: "center" },
partyCell: { flex: 1, alignItems: "center" },
partyNum: { color: theme.text, fontSize: 36, fontWeight: "900", lineHeight: 40 },
partyLbl: { color: theme.warn, fontSize: 11, fontWeight: "800", letterSpacing: 0.5, marginTop: 1, textAlign: "center", lineHeight: 13 },
partyDivider: { width: 1.5, height: 42, backgroundColor: theme.cardBorder },
partyDetail: { color: theme.textDim, fontSize: 12, textAlign: "center", marginTop: 8, fontWeight: "600" },
tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 }, tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 },
tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" }, tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" },

View file

@ -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<string, string> = {
Regular: "🎟️",
Guest: "🎫",
Worker: "🛠️",
Performer: "🎭",
Volunteer: "🙌",
Speaker: "🎤",
"Food Vendor": "🍔",
Vendor: "🛒",
};
const MEDAL = ["🥇", "🥈", "🥉"];
function Bar({ pct, color }: { pct: number; color?: string }) {
return (
<View style={styles.barTrack}>
<View style={[styles.barFill, { width: `${Math.min(100, Math.max(0, pct))}%`, backgroundColor: color ?? theme.successBright }]} />
</View>
);
}
function Tile({ value, label, accent }: { value: string | number; label: string; accent?: boolean }) {
return (
<View style={styles.tile}>
<Text style={[styles.tileValue, accent && { color: theme.successBright }]}>{value}</Text>
<Text style={styles.tileLabel}>{label}</Text>
</View>
);
}
export default function StatsScreen() {
const { open: openMenu } = useMenu();
const [stats, setStats] = useState<Stats | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState("");
const load = useCallback(async (force = false) => {
setError("");
try {
setStats(await getStats(force));
} catch (e: any) {
if (e?.name === "AuthError") return router.replace("/login");
setError(e?.message ?? "Failed to load report");
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
const onRefresh = () => {
setRefreshing(true);
load(true);
};
const peakHour = stats?.checkinsByHour.length
? stats.checkinsByHour.reduce((a, b) => (b.count > a.count ? b : a))
: null;
const maxHour = stats ? Math.max(1, ...stats.checkinsByHour.map((h) => h.count)) : 1;
return (
<SafeAreaView style={styles.root} edges={["top", "bottom"]}>
<View style={styles.topbar}>
<Pressable onPress={openMenu} hitSlop={12}>
<Text style={styles.hamburger}></Text>
</Pressable>
<Text style={styles.brand}>Event Report</Text>
<Pressable onPress={onRefresh} hitSlop={10}>
<Text style={styles.link}></Text>
</Pressable>
</View>
{loading ? (
<ActivityIndicator color={theme.successBright} size="large" style={{ marginTop: 40 }} />
) : error ? (
<Text style={styles.error}>{error}</Text>
) : stats ? (
<ScrollView
contentContainerStyle={{ padding: 16, paddingBottom: 48 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} tintColor={theme.successBright} />}
>
{/* Hero: check-in progress */}
<View style={styles.hero}>
<Text style={styles.heroPct}>{stats.tickets.pct}%</Text>
<Text style={styles.heroSub}>checked in</Text>
<Bar pct={stats.tickets.pct} />
<Text style={styles.heroCounts}>
{stats.tickets.redeemed} of {stats.tickets.total} tickets · {stats.tickets.remaining} to go
</Text>
</View>
{/* Core tiles */}
<View style={styles.tileRow}>
<Tile value={stats.orders} label="orders" />
<Tile value={stats.tickets.total} label="tickets sold" />
<Tile value={stats.tickets.redeemed} label="checked in" accent />
<Tile value={stats.tickets.remaining} label="remaining" />
</View>
{/* Ice */}
<View style={styles.card}>
<Text style={styles.cardTitle}>🧊 Ice</Text>
<Bar pct={stats.ice.pct} color="#4fc3f7" />
<Text style={styles.cardSub}>
{stats.ice.redeemed} of {stats.ice.total} bags handed out · {stats.ice.remaining} left · {stats.ice.ticketsSold} ice tickets sold
</Text>
</View>
{/* Ticket types */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Ticket types</Text>
{stats.types.map((t) => (
<View key={t.type} style={styles.typeRow}>
<Text style={styles.typeName}>
{(TYPE_ICON[t.type] ?? "🎫") + " " + t.type}
</Text>
<View style={styles.typeBarWrap}>
<Bar pct={t.total ? (t.redeemed / t.total) * 100 : 0} />
</View>
<Text style={styles.typeCount}>
{t.redeemed}/{t.total}
<Text style={styles.typeOrders}> · {t.count}×</Text>
</Text>
</View>
))}
</View>
{/* People breakdown */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Who's coming</Text>
<View style={styles.tileRow}>
<Tile value={stats.people.adults} label="adults (paid)" />
<Tile value={stats.people.youth} label="youth 13-16 (paid)" />
<Tile value={stats.people.kids12 + stats.people.kids9} label="kids 5-12 (free)" />
<Tile value={stats.people.kids4Free} label="under 5 (free)" />
</View>
</View>
{/* Extras + donors */}
<View style={styles.card}>
<Text style={styles.cardTitle}>Add-ons & donors</Text>
<View style={styles.chips}>
<Text style={styles.chip}>🚗 {stats.extras.carParking} parking</Text>
<Text style={styles.chip}>🚐 {stats.extras.rvParking} RV</Text>
<Text style={styles.chip}>🏍 {stats.extras.utv} UTV</Text>
<Text style={styles.chip}>🐻 {stats.donors.members} members</Text>
<Text style={styles.chip}> {stats.donors.orders} donor orders</Text>
<Text style={styles.chip}>🎟 {stats.donors.vouchers} vouchers</Text>
</View>
</View>
{/* Operator leaderboard */}
{stats.operators.length > 0 && (
<View style={styles.card}>
<Text style={styles.cardTitle}>Gate crew leaderboard</Text>
{stats.operators.slice(0, 8).map((o, i) => (
<View key={o.name} style={styles.opRow}>
<Text style={styles.opRank}>{MEDAL[i] ?? `${i + 1}.`}</Text>
<Text style={styles.opName} numberOfLines={1}>
{o.name}
</Text>
<Text style={styles.opStat}>
{o.checkins} check-ins{o.ice ? ` · ${o.ice} ice` : ""}
{o.undos ? ` · ${o.undos} undo` : ""}
</Text>
</View>
))}
</View>
)}
{/* Comp tickets issued */}
{stats.comps.total > 0 && (
<View style={styles.card}>
<Text style={styles.cardTitle}>🎟 Comp tickets issued ({stats.comps.total})</Text>
{stats.comps.byCreator.map((c) => (
<View key={c.name} style={styles.opRow}>
<Text style={styles.opName} numberOfLines={1}>
{c.name}
</Text>
<Text style={styles.opStat}>{c.count} issued</Text>
</View>
))}
</View>
)}
{/* Check-in timeline */}
{stats.checkinsByHour.length > 0 && (
<View style={styles.card}>
<Text style={styles.cardTitle}>Check-ins by hour</Text>
<View style={styles.spark}>
{stats.checkinsByHour.map((h) => (
<View key={h.hour} style={styles.sparkCol}>
<Text style={styles.sparkVal}>{h.count}</Text>
<View style={[styles.sparkBar, { height: 6 + (h.count / maxHour) * 80 }]} />
<Text style={styles.sparkLabel}>{h.hour.slice(11)}h</Text>
</View>
))}
</View>
{peakHour && (
<Text style={styles.cardSub}>Busiest hour: {peakHour.count} checked in around {peakHour.hour.slice(11)}:00</Text>
)}
</View>
)}
<Text style={styles.stamp}>Updated {new Date(stats.generatedAt).toLocaleTimeString()} · pull to refresh</Text>
</ScrollView>
) : null}
</SafeAreaView>
);
}
const styles = StyleSheet.create({
root: { flex: 1, backgroundColor: theme.bg },
topbar: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
paddingHorizontal: 16,
paddingVertical: 10,
},
brand: { color: theme.text, fontSize: 18, fontWeight: "700" },
hamburger: { color: theme.text, fontSize: 26, fontWeight: "700" },
link: { color: theme.textDim, fontSize: 16, fontWeight: "700" },
error: { color: theme.dangerBright, textAlign: "center", marginTop: 40, fontSize: 15 },
hero: {
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
borderRadius: 18,
padding: 22,
alignItems: "center",
},
heroPct: { color: theme.successBright, fontSize: 64, fontWeight: "900", lineHeight: 66 },
heroSub: { color: theme.textDim, fontSize: 15, marginBottom: 14 },
heroCounts: { color: theme.text, fontSize: 15, marginTop: 10, textAlign: "center" },
barTrack: { width: "100%", height: 12, borderRadius: 6, backgroundColor: theme.cardBorder, overflow: "hidden" },
barFill: { height: "100%", borderRadius: 6 },
tileRow: { flexDirection: "row", flexWrap: "wrap", gap: 10, marginTop: 12 },
tile: {
flexGrow: 1,
flexBasis: "22%",
minWidth: 74,
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
borderRadius: 12,
paddingVertical: 12,
alignItems: "center",
},
tileValue: { color: theme.text, fontSize: 24, fontWeight: "800" },
tileLabel: { color: theme.textDim, fontSize: 11, marginTop: 2, textAlign: "center" },
card: {
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
borderRadius: 16,
padding: 16,
marginTop: 14,
},
cardTitle: { color: theme.text, fontSize: 16, fontWeight: "800", marginBottom: 10 },
cardSub: { color: theme.textDim, fontSize: 13, marginTop: 8, lineHeight: 18 },
typeRow: { flexDirection: "row", alignItems: "center", gap: 10, marginVertical: 5 },
typeName: { color: theme.text, fontSize: 14, fontWeight: "600", width: 120 },
typeBarWrap: { flex: 1 },
typeCount: { color: theme.text, fontSize: 13, fontWeight: "700", minWidth: 66, textAlign: "right" },
typeOrders: { color: theme.textDim, fontWeight: "400" },
chips: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
chip: {
color: theme.text,
backgroundColor: theme.cardBorder,
borderRadius: 999,
paddingHorizontal: 12,
paddingVertical: 7,
fontSize: 13,
fontWeight: "600",
overflow: "hidden",
},
opRow: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 6 },
opRank: { fontSize: 16, width: 28, textAlign: "center", color: theme.textDim, fontWeight: "800" },
opName: { color: theme.text, fontSize: 15, fontWeight: "600", flex: 1 },
opStat: { color: theme.textDim, fontSize: 13 },
spark: { flexDirection: "row", alignItems: "flex-end", justifyContent: "space-between", gap: 4, height: 118, marginTop: 4 },
sparkCol: { flex: 1, alignItems: "center", justifyContent: "flex-end" },
sparkVal: { color: theme.textDim, fontSize: 10, marginBottom: 3 },
sparkBar: { width: "70%", minWidth: 8, backgroundColor: theme.successBright, borderRadius: 3 },
sparkLabel: { color: theme.textDim, fontSize: 9, marginTop: 3 },
stamp: { color: theme.textDim, fontSize: 12, textAlign: "center", marginTop: 20 },
});

View file

@ -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 (
<View pointerEvents={visible ? "auto" : "none"} style={StyleSheet.absoluteFill}>
<Animated.View style={[styles.scrim, { opacity: fade }]}>
<Pressable style={StyleSheet.absoluteFill} onPress={onClose} />
</Animated.View>
<Animated.View style={[styles.panel, { width: panelW, transform: [{ translateX: tx }] }]}>
<View style={styles.header}>
<Text style={styles.logo}>🐻 Camp Scan</Text>
{!!operator && <Text style={styles.operator}>{operator}</Text>}
</View>
<View style={styles.items}>
{ITEMS.map((it) => {
const active = it.seg === current;
return (
<Pressable key={it.route} style={[styles.item, active && styles.itemActive]} onPress={() => go(it)}>
<Text style={styles.itemIcon}>{it.icon}</Text>
<Text style={[styles.itemText, active && styles.itemTextActive]}>{it.label}</Text>
</Pressable>
);
})}
</View>
<View style={styles.spacer} />
<Pressable
style={styles.signout}
onPress={() => {
onClose();
signOut();
}}
>
<Text style={styles.itemIcon}>🚪</Text>
<Text style={styles.signoutText}>Sign out</Text>
</Pressable>
</Animated.View>
</View>
);
}
const styles = StyleSheet.create({
scrim: { position: "absolute", top: 0, left: 0, right: 0, bottom: 0, backgroundColor: "rgba(0,0,0,0.55)" },
panel: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
backgroundColor: theme.card,
borderRightWidth: 1,
borderRightColor: theme.cardBorder,
paddingTop: 54,
paddingHorizontal: 14,
paddingBottom: 28,
},
header: { paddingHorizontal: 8, paddingBottom: 14, borderBottomWidth: 1, borderBottomColor: theme.cardBorder },
logo: { color: theme.text, fontSize: 20, fontWeight: "800" },
operator: { color: theme.textDim, fontSize: 14, marginTop: 3 },
items: { marginTop: 14, gap: 4 },
item: { flexDirection: "row", alignItems: "center", gap: 14, paddingVertical: 14, paddingHorizontal: 12, borderRadius: 12 },
itemActive: { backgroundColor: theme.primary },
itemIcon: { fontSize: 20, width: 26, textAlign: "center" },
itemText: { color: theme.text, fontSize: 17, fontWeight: "600" },
itemTextActive: { color: "#fff", fontWeight: "800" },
spacer: { flex: 1 },
signout: {
flexDirection: "row",
alignItems: "center",
gap: 14,
paddingVertical: 14,
paddingHorizontal: 12,
borderRadius: 12,
borderTopWidth: 1,
borderTopColor: theme.cardBorder,
},
signoutText: { color: theme.dangerBright, fontSize: 17, fontWeight: "700" },
});

View file

@ -22,7 +22,6 @@ export interface TicketView {
name: string; name: string;
email: string; email: string;
ticketType: string; ticketType: string;
createdBy: string;
total: number; total: number;
redeemed: number; redeemed: number;
remaining: number; remaining: number;
@ -36,7 +35,7 @@ export interface TicketView {
isDonor: boolean; isDonor: boolean;
donorTier: string; donorTier: string;
vouchers: number; vouchers: number;
freeKids: number; freeUnder5: number;
}; };
ages: { bracket: string; count: number; free: boolean }[]; ages: { bracket: string; count: number; free: boolean }[];
} }
@ -195,113 +194,6 @@ export interface AuditEntry {
action: "check-in" | "undo" | "ice" | "ice-undo"; action: "check-in" | "undo" | "ice" | "ice-undo";
} }
export interface Stats {
orders: number;
tickets: { total: number; redeemed: number; remaining: number; pct: number };
people: { adults: number; youth: number; kids12: number; kids9: number; kids4Free: number };
ice: { total: number; redeemed: number; remaining: number; pct: number; ticketsSold: number };
types: { type: string; count: number; total: number; redeemed: number }[];
donors: { orders: number; members: number; vouchers: number };
extras: { carParking: number; rvParking: number; utv: number };
comps: { total: number; byCreator: { name: string; count: number }[] };
operators: { name: string; checkins: number; ice: number; undos: number }[];
checkinsByHour: { hour: string; count: number }[];
generatedAt: string;
}
export function getStats(force = false): Promise<Stats> {
return authed<Stats>(`/api/stats${force ? "?force=1" : ""}`);
}
// Comp-ticket portal (password-gated; separate from the staff PIN).
export async function portalVerify(password: string): Promise<{ ok: boolean; types: string[] }> {
const res = await fetch(`${API_BASE}/api/portal/verify`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (res.status === 401) throw new AuthError("Wrong password");
if (!res.ok) throw new ApiError(`Verify failed (${res.status})`);
return res.json();
}
export interface PortalTicket {
ok: boolean;
code: string;
type: string;
name: string;
emailSent: boolean;
qr: string; // data URL
}
export async function portalCreate(input: {
password: string;
name: string;
email: string;
type: string;
createdBy?: string;
}): Promise<PortalTicket> {
const res = await fetch(`${API_BASE}/api/portal/create-ticket`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (res.status === 401) throw new AuthError("Wrong password");
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new ApiError(body?.detail ?? body?.error ?? `Create failed (${res.status})`);
return body;
}
// ---- Admin actions (all gated by the portal password) ----
async function adminPost<T>(path: string, password: string, extra: Record<string, unknown> = {}): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password, ...extra }),
});
if (res.status === 401) throw new AuthError("Wrong password");
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new ApiError(body?.detail ?? body?.error ?? `Request failed (${res.status})`);
return body as T;
}
export interface AdminStatus {
tickets: { tableId: string; count: number };
audit: { tableId: string | null; count: number; enabled: boolean };
defaults: { ticketsTableId: string; auditTableId: string | null };
}
export function adminStatus(password: string): Promise<AdminStatus> {
return adminPost<AdminStatus>("/api/admin/status", password);
}
export function adminWipe(password: string): Promise<{ ok: boolean; ticketsDeleted: number; auditDeleted: number }> {
return adminPost("/api/admin/wipe", password);
}
export function adminSwitchTable(
password: string,
ticketsTableId: string,
auditTableId?: string,
): Promise<{ ok: boolean; tickets: { tableId: string }; audit: { tableId: string | null } }> {
return adminPost("/api/admin/switch-table", password, { ticketsTableId, auditTableId });
}
export interface DonorSearchResult {
name: string;
bearName: string;
email: string;
altEmail: string;
phone: string;
address: string;
lifetime: number | null;
tags: string[];
source: "master" | "transactions";
}
export function adminDonorSearch(password: string, query: string): Promise<{ results: DonorSearchResult[]; query: string }> {
return adminPost("/api/admin/donor-search", password, { query });
}
export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{ export function getAudit(opts: { code?: string; limit?: number } = {}): Promise<{
enabled: boolean; enabled: boolean;
entries: AuditEntry[]; entries: AuditEntry[];

View file

@ -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<MenuState>({ open: () => {}, close: () => {} });
export function MenuProvider({ children }: { children: ReactNode }) {
const [visible, setVisible] = useState(false);
return (
<Ctx.Provider value={{ open: () => setVisible(true), close: () => setVisible(false) }}>
{children}
<SideMenu visible={visible} onClose={() => setVisible(false)} />
</Ctx.Provider>
);
}
export const useMenu = () => useContext(Ctx);

View file

@ -10,10 +10,6 @@ const schema = z.object({
// Optional "2026 Ticket Audit Logs" table. If unset, audit logging is skipped. // Optional "2026 Ticket Audit Logs" table. If unset, audit logging is skipped.
NOCODB_AUDIT_TABLE_ID: z.string().optional(), NOCODB_AUDIT_TABLE_ID: z.string().optional(),
// Writable dir (mounted volume) for small runtime state — e.g. the active
// event table override set from the admin area, so it survives redeploys.
STATE_DIR: z.string().default("/data"),
// Donor tables for Banquet mode. If the master-list id is unset, banquet is // Donor tables for Banquet mode. If the master-list id is unset, banquet is
// disabled. Online/offline are used as a fallback when a donor is not in the // disabled. Online/offline are used as a fallback when a donor is not in the
// master list. // master list.
@ -31,17 +27,7 @@ const schema = z.object({
// Disabled unless a secret is set. Returns only eligibility + tier, never // Disabled unless a secret is set. Returns only eligibility + tier, never
// names or dollar amounts. Rate-limited + CORS-restricted. // names or dollar amounts. Rate-limited + CORS-restricted.
PUBLIC_LOOKUP_SECRET: z.string().optional(), PUBLIC_LOOKUP_SECRET: z.string().optional(),
// Comma-separated allowlist of browser origins permitted to call the public PUBLIC_LOOKUP_ORIGIN: z.string().default("https://tickets.beartariacampgrounds.com"),
// 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 // Ticket-voucher entitlement: donations on/after VOUCHER_SINCE totalling
// >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the date each year. // >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the date each year.

View file

@ -4,7 +4,6 @@ import { Mailer } from "./services/mailer.js";
import { RedeemQueue } from "./services/redeemQueue.js"; import { RedeemQueue } from "./services/redeemQueue.js";
import { AuditLogger } from "./services/audit.js"; import { AuditLogger } from "./services/audit.js";
import { DonorService } from "./services/donors.js"; import { DonorService } from "./services/donors.js";
import { loadActiveTables } from "./services/state.js";
/** Shared services wired once at startup and hung off the Fastify instance. */ /** Shared services wired once at startup and hung off the Fastify instance. */
export interface AppContext { export interface AppContext {
@ -17,23 +16,12 @@ export interface AppContext {
} }
export function buildContext(config: Config): AppContext { export function buildContext(config: Config): AppContext {
const nocodb = new NocoDBClient(config);
const audit = new AuditLogger(config);
// Apply a persisted "active event table" override (set from the admin area),
// so switching the event survives redeploys without editing .env.
const override = loadActiveTables(config.STATE_DIR);
if (override) {
nocodb.setTableId(override.ticketsTableId);
audit.setTableId(override.auditTableId ?? null);
}
return { return {
config, config,
nocodb, nocodb: new NocoDBClient(config),
mailer: new Mailer(config), mailer: new Mailer(config),
queue: new RedeemQueue(), queue: new RedeemQueue(),
audit, audit: new AuditLogger(config),
donors: new DonorService(config), donors: new DonorService(config),
}; };
} }

View file

@ -25,7 +25,6 @@ export const COL = {
iceAccess: "Ice Access", iceAccess: "Ice Access",
paymentMethod: "Payment Method", paymentMethod: "Payment Method",
ticketType: "Ticket Type", // "" for regular; Guest/Worker/Performer/Volunteer/Speaker for portal comps 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 manages:
code: "Ticket Code", code: "Ticket Code",
@ -51,17 +50,11 @@ function bool(v: unknown): boolean {
} }
/** /**
* Total scannable (paid) tickets = adults + youth 13-16. Children 12 and under * Total scannable tickets = everyone except kids 0-4 (who are free):
* (kids 10-12 / 5-9 / 0-4) are admitted free and not counted; charging starts * adults + youth (13-16) + kids 10-12 + kids 5-9.
* at age 13.
*/ */
export function computeTotal(rec: NocoRecord): number { export function computeTotal(rec: NocoRecord): number {
return num(rec[COL.adults]) + num(rec[COL.youth]); return num(rec[COL.adults]) + num(rec[COL.youth]) + num(rec[COL.kids12]) + num(rec[COL.kids9]);
}
/** 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 { export function computeIceTotal(rec: NocoRecord): number {
@ -73,8 +66,8 @@ export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number;
return [ return [
{ bracket: "Adults", count: num(rec[COL.adults]), free: false }, { bracket: "Adults", count: num(rec[COL.adults]), free: false },
{ bracket: "Youth 13-16", count: num(rec[COL.youth]), 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 10-12", count: num(rec[COL.kids12]), free: false },
{ bracket: "Kids 5-9", count: num(rec[COL.kids9]), free: true }, { bracket: "Kids 5-9", count: num(rec[COL.kids9]), free: false },
{ bracket: "Kids 0-4", count: num(rec[COL.kids4]), free: true }, { bracket: "Kids 0-4", count: num(rec[COL.kids4]), free: true },
].filter((b) => b.count > 0); ].filter((b) => b.count > 0);
} }
@ -103,7 +96,6 @@ export interface TicketView {
name: string; name: string;
email: string; email: string;
ticketType: string; // "" for regular; Guest/Worker/... for special tickets ticketType: string; // "" for regular; Guest/Worker/... for special tickets
createdBy: string; // who issued a comp ticket
total: number; total: number;
redeemed: number; redeemed: number;
remaining: number; remaining: number;
@ -117,7 +109,7 @@ export interface TicketView {
isDonor: boolean; isDonor: boolean;
donorTier: string; donorTier: string;
vouchers: number; vouchers: number;
freeKids: number; // children 12 & under (free admission) freeUnder5: number;
}; };
ages: { bracket: string; count: number; free: boolean }[]; ages: { bracket: string; count: number; free: boolean }[];
} }
@ -132,7 +124,6 @@ export function toView(rec: NocoRecord): TicketView {
name: String(rec[COL.name] ?? ""), name: String(rec[COL.name] ?? ""),
email: String(rec[COL.email] ?? ""), email: String(rec[COL.email] ?? ""),
ticketType: String(rec[COL.ticketType] ?? ""), ticketType: String(rec[COL.ticketType] ?? ""),
createdBy: String(rec[COL.createdBy] ?? ""),
total, total,
redeemed, redeemed,
remaining: Math.max(0, total - redeemed), remaining: Math.max(0, total - redeemed),
@ -150,7 +141,7 @@ export function toView(rec: NocoRecord): TicketView {
isDonor: bool(rec[COL.isDonor]), isDonor: bool(rec[COL.isDonor]),
donorTier: String(rec[COL.donorTier] ?? ""), donorTier: String(rec[COL.donorTier] ?? ""),
vouchers: num(rec[COL.vouchers]), vouchers: num(rec[COL.vouchers]),
freeKids: freeKidsCount(rec), freeUnder5: num(rec[COL.kids4]),
}, },
ages: ageBreakdown(rec), ages: ageBreakdown(rec),
}; };

View file

@ -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<string, any>, base: string): string {
const obj = body[base];
let first: any, middle: any, last: any;
if (obj && typeof obj === "object") {
({ first_name: first, middle_name: middle, last_name: last } = obj);
} else {
first = body[`${base}[first_name]`];
middle = body[`${base}[middle_name]`];
last = body[`${base}[last_name]`];
}
return [first, middle, last]
.map((x) => (x == null ? "" : String(x).trim()))
.filter(Boolean)
.join(" ");
}
/** Read an item_quantity / payment field's numeric value (handles nested
* objects like {quantity} / {value} and money strings like "$40.00"). */
export function qty(v: any): number {
if (v == null || v === "") return 0;
if (typeof v === "object") return toNumber(v.quantity ?? v.value ?? v.item_quantity ?? v.amount ?? 0);
if (typeof v === "string") return toNumber(v.replace(/[^0-9.\-]/g, ""));
return toNumber(v);
}
/** A payment/extra field counts as "selected" if it has a meaningful value.
* Donor (free) items can be $0, so a non-empty, non-"no"/"0" value also counts. */
export function selected(v: any): boolean {
if (v == null || v === "") return false;
if (typeof v === "object") {
if ("selected" in v) return toBool((v as any).selected);
return qty(v) > 0 || Object.keys(v).length > 0;
}
const s = String(v).trim().toLowerCase();
if (!s || s === "no" || s === "0" || s === "$0" || s === "$0.00" || s === "false" || s === "none") return false;
return true;
}
/** Flatten a FluentForms compound address (`address_1`) to a single line. */
export function addressLine(v: any): string | undefined {
if (v && typeof v === "object") return Object.values(v).filter(Boolean).join(", ");
if (v !== undefined) return String(v);
return undefined;
}
const NUMBER_WORDS: Record<string, number> = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 };
/**
* Total bags of ice from the `payment_ice` field. The form sends a descriptive
* option label, e.g. "One Ice ticket good for one bag per day (3 total bags)",
* so the reliable signal is the "(N total bags)" the label states. Falls back to
* a worded ticket count ("Two Ice tickets" 2 × bagsPerTicket), then to a
* numeric dollar-total/ticket-count for forward compatibility.
*/
export function iceBagsFromPayment(
value: unknown,
opts: { bagsPerTicket: number; ticketPrice: number },
): number {
const { bagsPerTicket, ticketPrice } = opts;
const s = typeof value === "string" ? value : "";
// Preferred: the label states the total bags directly.
const bagsMatch = s.match(/(\d+)\s*total\s*bags/i);
if (bagsMatch) return Math.max(0, parseInt(bagsMatch[1], 10));
// Worded ticket count: "One Ice ticket", "Two Ice tickets".
const wordMatch = s.match(/\b(one|two|three|four|five|six)\b\s+ice/i);
if (wordMatch) return NUMBER_WORDS[wordMatch[1].toLowerCase()] * bagsPerTicket;
// Numeric fallback: a dollar total (>= price) → tickets; else a small count.
const n = qty(value);
if (n <= 0) return 0;
const tickets = n >= ticketPrice ? Math.round(n / ticketPrice) : Math.round(n);
return Math.max(0, tickets) * bagsPerTicket;
}
/** Donor status from the hidden lookup fields + the "are you a donor?" radio. */
export function readDonor(body: Record<string, any>): { isDonor: boolean; donorTier: string } {
const donorTier = String(body.donor_tier ?? "").trim();
const isDonor =
donorTier === "member" ||
donorTier === "donor" ||
toBool(body.donor_eligible) ||
selected(body.input_radio); // "Are you a campground donor?"
return { isDonor, donorTier };
}

View file

@ -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<void> {
const cfg = app.ctx.config;
const gate = (req: any, reply: any): boolean => {
if (!cfg.PORTAL_PASSWORD) {
reply.code(404).send({ error: "admin_disabled" });
return false;
}
const pw = (req.body ?? {}).password;
if (typeof pw !== "string" || !safeEqual(pw, cfg.PORTAL_PASSWORD)) {
reply.code(401).send({ error: "bad_password" });
return false;
}
return true;
};
const rl = { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } };
app.post("/api/admin/status", rl, async (req, reply) => {
if (!gate(req, reply)) return;
const [tickets, audit] = await Promise.all([
app.ctx.nocodb.count().catch(() => -1),
app.ctx.audit.count().catch(() => -1),
]);
return {
tickets: { tableId: app.ctx.nocodb.tableId, count: tickets },
audit: { tableId: app.ctx.audit.currentTableId, count: audit, enabled: app.ctx.audit.enabled },
// What .env would use if the override were cleared (for reference).
defaults: { ticketsTableId: cfg.NOCODB_TABLE_ID, auditTableId: cfg.NOCODB_AUDIT_TABLE_ID ?? null },
};
});
app.post("/api/admin/wipe", rl, async (req, reply) => {
if (!gate(req, reply)) return;
let ticketsDeleted = 0;
let auditDeleted = 0;
try {
ticketsDeleted = await app.ctx.nocodb.deleteAll();
} catch (e: any) {
return reply.code(502).send({ error: "wipe_failed", detail: e?.message });
}
try {
auditDeleted = await app.ctx.audit.deleteAll();
} catch {
// Audit wipe is best-effort; tickets are the important part.
}
req.log.warn({ ticketsDeleted, auditDeleted }, "admin: wiped slate");
return { ok: true, ticketsDeleted, auditDeleted };
});
app.post("/api/admin/switch-table", rl, async (req, reply) => {
if (!gate(req, reply)) return;
const b = (req.body ?? {}) as { ticketsTableId?: string; auditTableId?: string };
const ticketsTableId = String(b.ticketsTableId ?? "").trim();
const auditTableId = String(b.auditTableId ?? "").trim();
if (!ticketsTableId) {
return reply.code(400).send({ error: "missing_tickets_table" });
}
// Validate the new tickets table is reachable and has an Id primary key —
// switching to a PK-less table would make check-in updates hit every row.
const probe = await app.ctx.nocodb.probeTable(ticketsTableId);
if (!probe.ok) {
return reply.code(400).send({ error: "tickets_table_unreachable", status: probe.status });
}
if (!probe.hasIdPk) {
return reply.code(400).send({ error: "tickets_table_no_id_pk" });
}
if (auditTableId) {
const ap = await app.ctx.nocodb.probeTable(auditTableId);
if (!ap.ok) return reply.code(400).send({ error: "audit_table_unreachable", status: ap.status });
}
// Hot-swap the live clients, then persist so it survives a redeploy.
app.ctx.nocodb.setTableId(ticketsTableId);
app.ctx.audit.setTableId(auditTableId || app.ctx.audit.currentTableId);
saveActiveTables(cfg.STATE_DIR, {
ticketsTableId,
auditTableId: auditTableId || app.ctx.audit.currentTableId || undefined,
});
req.log.warn({ ticketsTableId, auditTableId }, "admin: switched event table");
return {
ok: true,
tickets: { tableId: app.ctx.nocodb.tableId },
audit: { tableId: app.ctx.audit.currentTableId },
};
});
app.post("/api/admin/donor-search", rl, async (req, reply) => {
if (!gate(req, reply)) return;
if (!app.ctx.donors.enabled) return reply.code(404).send({ error: "donors_unavailable" });
const q = String(((req.body ?? {}) as { query?: string }).query ?? "").trim();
if (q.length < 2) return { results: [], query: q };
try {
const results = await app.ctx.donors.search(q, 40);
return { results, query: q };
} catch (e: any) {
req.log.error({ err: e }, "admin: donor search failed");
return reply.code(502).send({ error: "search_failed", detail: e?.message });
}
});
}

View file

@ -22,21 +22,6 @@ export async function portalRoutes(app: FastifyInstance): Promise<void> {
reply.type("text/html").send(PAGE); 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( app.post(
"/api/portal/create-ticket", "/api/portal/create-ticket",
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } }, { config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
@ -44,21 +29,13 @@ export async function portalRoutes(app: FastifyInstance): Promise<void> {
const cfg = app.ctx.config; const cfg = app.ctx.config;
if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" }); if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" });
const b = (req.body ?? {}) as { const b = (req.body ?? {}) as { password?: string; name?: string; email?: string; type?: string };
password?: string;
name?: string;
email?: string;
type?: string;
createdBy?: string;
};
if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) { if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) {
return reply.code(401).send({ error: "bad_password" }); return reply.code(401).send({ error: "bad_password" });
} }
const name = String(b.name ?? "").trim(); const name = String(b.name ?? "").trim();
const email = String(b.email ?? "").trim(); const email = String(b.email ?? "").trim();
const type = TYPES.includes(String(b.type)) ? String(b.type) : "Guest"; 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) { if (!name || !email) {
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" }); return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" });
} }
@ -70,7 +47,6 @@ export async function portalRoutes(app: FastifyInstance): Promise<void> {
adultNames: [name], adultNames: [name],
email, email,
ticketType: type, ticketType: type,
createdBy,
counts: { adults: 1, youth: 0, kids12: 0, kids9: 0, kids4: 0 }, counts: { adults: 1, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
submissionKey: `portal:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`, submissionKey: `portal:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`,
}); });
@ -103,350 +79,92 @@ const PAGE = `<!doctype html>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f1a12" /> <meta name="theme-color" content="#0f1a12" />
<title>Camp Scan Admin (crush33)</title> <title>Camp Scan Comp Tickets</title>
<style> <style>
:root { color-scheme: dark; } :root { color-scheme: dark; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; } body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; }
a { color: #58d68d; } .wrap { max-width: 460px; margin: 0 auto; padding: 28px 20px 64px; }
header { text-align: center; margin-bottom: 22px; }
.logo { font-size: 52px; }
h1 { font-size: 22px; margin: 8px 0 2px; }
.sub { color: #9db3a4; font-size: 14px; margin: 0; }
label { display: block; font-size: 13px; color: #9db3a4; margin: 14px 0 5px; } label { display: block; font-size: 13px; color: #9db3a4; margin: 14px 0 5px; }
input, select { width: 100%; background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 12px 14px; color: #eaf2ec; font-size: 15px; } input, select { width: 100%; background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 14px; color: #eaf2ec; font-size: 16px; }
.btn { background: #25c05a; color: #06210f; font-weight: 800; font-size: 16px; border: none; padding: 13px 18px; border-radius: 12px; cursor: pointer; } button { width: 100%; background: #25c05a; color: #06210f; font-weight: 800; font-size: 18px; border: none; padding: 15px; border-radius: 13px; margin-top: 18px; }
.btn:disabled { opacity: 0.5; cursor: default; } button:disabled { opacity: 0.5; }
.btn-red { background: #e04343; color: #fff; } .msg { margin-top: 14px; font-size: 15px; font-weight: 600; text-align: center; min-height: 20px; }
.btn-ghost { background: transparent; color: #eaf2ec; border: 1px solid #2e7d32; } .err { color: #e04343; }
.msg { margin-top: 12px; font-size: 14px; font-weight: 600; min-height: 18px; } .ok { color: #58d68d; }
.err { color: #e04343; } .ok { color: #58d68d; } .result { display: none; text-align: center; margin-top: 18px; background: #16241a; border: 1px solid #24382a; border-radius: 14px; padding: 18px; }
.mono { font-family: ui-monospace, Menlo, monospace; } .result img { width: 220px; height: 220px; background: #fff; border-radius: 10px; padding: 8px; }
.result .code { font-family: ui-monospace, Menlo, monospace; font-size: 20px; letter-spacing: 2px; margin: 12px 0 4px; color: #58d68d; }
/* Unlock */ .result .who { font-size: 16px; color: #c4d6c9; }
#unlock { max-width: 420px; margin: 0 auto; padding: 48px 20px; text-align: center; } .hint { color: #6c8f74; font-size: 12px; text-align: center; margin-top: 10px; }
#unlock .logo { font-size: 52px; }
#unlock h1 { font-size: 22px; margin: 8px 0 4px; }
#unlock .sub { color: #9db3a4; font-size: 14px; }
#unlock input { text-align: center; margin-top: 18px; }
#unlock .btn { width: 100%; margin-top: 16px; }
.backlink { display: inline-block; margin-top: 18px; color: #9db3a4; font-size: 14px; text-decoration: none; }
.backlink:hover { color: #eaf2ec; }
.top-back { margin-top: 0; }
/* Hub */
#hub { display: none; min-height: 100vh; }
.top { display: flex; align-items: center; justify-content: space-between; padding: 12px 18px; border-bottom: 1px solid #24382a; }
.top .brand { font-weight: 800; font-size: 17px; }
.top .lock { color: #9db3a4; font-size: 13px; cursor: pointer; }
.layout { display: flex; align-items: flex-start; }
.side { width: 190px; flex: none; border-right: 1px solid #24382a; padding: 12px 0; min-height: calc(100vh - 50px); }
.nav { display: flex; align-items: center; gap: 10px; padding: 13px 18px; color: #9db3a4; cursor: pointer; border-left: 3px solid transparent; font-weight: 700; font-size: 15px; }
.nav .i { font-size: 18px; }
.nav.on { color: #eaf2ec; background: #16241a; border-left-color: #2e7d32; }
.main { flex: 1; padding: 22px 26px 64px; max-width: 760px; }
.sec { display: none; }
.sec.on { display: block; }
h2 { font-size: 22px; margin: 0 0 4px; }
.lead { color: #9db3a4; font-size: 14px; margin: 0 0 8px; line-height: 1.5; }
.card { background: #16241a; border: 1px solid #24382a; border-radius: 14px; padding: 16px; margin-top: 14px; }
.pills { display: flex; flex-wrap: wrap; gap: 8px; }
.pill { border: 1px solid #24382a; background: #16241a; border-radius: 999px; padding: 8px 14px; cursor: pointer; font-weight: 700; font-size: 14px; color: #9db3a4; }
.pill.on { background: #2e7d32; border-color: #2e7d32; color: #fff; }
.row { display: flex; gap: 8px; align-items: center; }
.result { display: none; text-align: center; margin-top: 16px; }
.result img { width: 200px; height: 200px; background: #fff; border-radius: 10px; padding: 8px; }
.result .code { font-size: 20px; letter-spacing: 2px; margin: 10px 0 2px; color: #58d68d; }
/* Donor cards */
.donor { background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 13px 15px; margin-top: 11px; }
.donor .h { display: flex; justify-content: space-between; align-items: center; }
.donor .nm { font-weight: 800; font-size: 16px; }
.donor .amt { color: #58d68d; font-weight: 800; }
.donor .ln { color: #9db3a4; font-size: 14px; margin-top: 3px; }
.tags { margin-top: 8px; }
.tag { display: inline-block; background: #1b5e20; color: #fff; border-radius: 6px; padding: 2px 7px; font-size: 12px; margin-right: 5px; }
.src { display: inline-block; border: 1px solid #24382a; border-radius: 6px; padding: 2px 6px; font-size: 11px; color: #9db3a4; text-transform: uppercase; margin-right: 5px; }
/* Danger */
.danger { background: #241717; border: 1px solid #8f1d1d; border-radius: 14px; padding: 16px; margin-top: 18px; }
.danger h3 { color: #ff9a9a; margin: 0 0 6px; font-size: 17px; }
.danger p, .danger li { color: #e9cfcf; font-size: 14px; line-height: 1.5; }
.danger ul { margin: 6px 0 0; padding-left: 20px; }
.status { background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 14px; }
.status .k { color: #9db3a4; font-size: 12px; text-transform: uppercase; letter-spacing: .5px; }
.status .v { font-size: 14px; margin-top: 5px; }
/* Modal */
.scrim { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.72); align-items: center; justify-content: center; padding: 20px; z-index: 10; }
.scrim.on { display: flex; }
.modal { background: #1a1010; border: 2px solid #e04343; border-radius: 18px; padding: 22px; max-width: 420px; width: 100%; }
.modal .warn { font-size: 40px; text-align: center; }
.modal h3 { text-align: center; margin: 4px 0 12px; font-size: 20px; }
.modal pre { white-space: pre-wrap; color: #f0d9d9; font-size: 14px; line-height: 1.55; font-family: inherit; margin: 0; }
.modal .btn { width: 100%; margin-top: 16px; }
.modal .cancel { width: 100%; margin-top: 8px; background: transparent; border: none; color: #9db3a4; font-weight: 700; font-size: 15px; padding: 12px; cursor: pointer; }
@media (max-width: 640px) {
.side { width: 74px; }
.nav { flex-direction: column; gap: 3px; padding: 12px 4px; font-size: 11px; text-align: center; }
.main { padding: 18px 14px 48px; }
}
</style> </style>
</head> </head>
<body> <body>
<div id="unlock"> <div class="wrap">
<div class="logo">🐻</div> <header>
<h1>Admin · crush33</h1> <div class="logo">🐻</div>
<p class="sub">Admin-only area. Enter the shared portal password.</p> <h1>Comp Ticket Portal</h1>
<input id="pw" type="password" autocomplete="current-password" placeholder="Portal password" /> <p class="sub">Entry-only tickets for workers &amp; guests</p>
<button class="btn" id="unlockBtn">Unlock</button> </header>
<div id="unlockMsg" class="msg" style="text-align:center"></div>
<a class="backlink" href="/"> Back to the scan app</a>
</div>
<div id="hub"> <label>Portal password</label>
<div class="top"> <input id="pw" type="password" autocomplete="current-password" placeholder="Shared admin password" />
<a class="backlink top-back" href="/"> Scanner</a>
<div class="brand">🐻 Admin · crush33</div>
<div class="lock" id="relock">Lock 🔒</div>
</div>
<div class="layout">
<div class="side">
<div class="nav on" data-sec="comp"><span class="i">🎟</span> Comp tickets</div>
<div class="nav" data-sec="donors"><span class="i">🔎</span> Donor lookup</div>
<div class="nav" data-sec="actions"><span class="i"></span> Actions</div>
</div>
<div class="main">
<!-- Comp -->
<div class="sec on" id="sec-comp">
<h2>Comp tickets</h2>
<p class="lead">Entry-only tickets for guests &amp; staff.</p>
<label>Ticket type</label>
<div class="pills" id="typePills">
<span class="pill on">🎫 Guest</span><span class="pill">🛠 Worker</span><span class="pill">🎭 Performer</span><span class="pill">🙌 Volunteer</span><span class="pill">🎤 Speaker</span>
</div>
<label>Full name</label>
<input id="cName" type="text" autocomplete="off" placeholder="Attendee name" />
<label>Email</label>
<input id="cEmail" type="email" autocomplete="off" autocapitalize="none" placeholder="Where to send the ticket" />
<button class="btn" id="cGo" style="width:100%;margin-top:18px">Create ticket</button>
<div id="cMsg" class="msg"></div>
<div id="cResult" class="result">
<img id="cQr" alt="Ticket QR" />
<div class="code mono" id="cCode"></div>
<div class="lead" id="cWho"></div>
<div class="lead" id="cMail"></div>
</div>
</div>
<!-- Donors --> <label>Ticket type</label>
<div class="sec" id="sec-donors"> <select id="type">
<h2>Donor lookup</h2> <option>Guest</option><option>Worker</option><option>Performer</option>
<p class="lead">🔒 Admin only · private donor info. Search by name, email, phone, address, bear name</p> <option>Volunteer</option><option>Speaker</option>
<div class="row"> </select>
<input id="dQ" type="text" autocomplete="off" placeholder="Search donors…" style="flex:1" />
<button class="btn" id="dGo">Search</button>
</div>
<div id="dMsg" class="msg"></div>
<div id="dResults"></div>
</div>
<!-- Actions --> <label>Full name</label>
<div class="sec" id="sec-actions"> <input id="name" type="text" autocomplete="off" placeholder="Attendee name" />
<h2>Actions</h2>
<p class="lead">Event-management tools. These change live data read the warnings.</p>
<div class="status">
<div class="k">Active event table <span id="aRefresh" style="float:right;cursor:pointer"></span></div>
<div class="v mono" id="aStatus">loading</div>
</div>
<div id="aMsg" class="msg"></div>
<div class="danger"> <label>Email</label>
<h3>🧹 Wipe the slate clean</h3> <input id="email" type="email" autocomplete="off" autocapitalize="none" placeholder="Where to send the ticket" />
<p>Permanently deletes <b>every ticket and every check-in</b> in the active event table. Use before a run-through or a fresh event.</p>
<ul><li>Does NOT affect donor data.</li><li>Cannot be undone.</li></ul>
<button class="btn btn-red" id="wipeBtn" style="width:100%">Wipe slate</button>
</div>
<div class="danger"> <button id="go">Create ticket</button>
<h3>🔀 Switch event table</h3> <div id="msg" class="msg"></div>
<p>Point the scanner at a <b>different NocoDB table</b> start a new event on a fresh table while keeping the current one intact.</p>
<ul><li>Create the new table first (duplicate the current one's structure in NocoDB — keep the Id column).</li><li>The current event's data is NOT deleted, just no longer shown.</li></ul>
<label>New tickets table ID</label>
<input id="swTickets" type="text" autocomplete="off" placeholder="e.g. mv1a2b3c…" />
<label>New audit table ID (optional)</label>
<input id="swAudit" type="text" autocomplete="off" placeholder="leave blank to keep current" />
<button class="btn btn-red" id="switchBtn" style="width:100%;margin-top:14px">Switch table</button>
</div>
</div>
</div>
</div>
</div>
<div class="scrim" id="scrim"> <div id="result" class="result">
<div class="modal"> <img id="qr" alt="Ticket QR" />
<div class="warn"></div> <div class="code" id="rcode"></div>
<h3 id="mTitle"></h3> <div class="who" id="rwho"></div>
<pre id="mBody"></pre> <div class="hint" id="rmail"></div>
<button class="btn btn-red" id="mConfirm"></button> <button id="another" style="background:transparent;color:#eaf2ec;border:1px solid #2e7d32;font-size:15px;">Create another</button>
<button class="cancel" id="mCancel">Cancel</button>
</div> </div>
</div> </div>
<script> <script>
var $ = function (id) { return document.getElementById(id); }; var $ = function (id) { return document.getElementById(id); };
var PW = ""; function setMsg(t, ok) { var m = $("msg"); m.textContent = t; m.className = "msg " + (ok ? "ok" : "err"); }
var counts = { tickets: "?", audit: "?", table: "?" };
var pendingAction = null;
function api(path, body) { $("go").addEventListener("click", function () {
return fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, var pw = $("pw").value, name = $("name").value.trim(), email = $("email").value.trim(), type = $("type").value;
body: JSON.stringify(Object.assign({ password: PW }, body || {})) }) if (!pw) return setMsg("Enter the portal password.");
.then(function (r) { return r.json().then(function (d) { return { s: r.status, d: d }; }); }); if (!name || !email) return setMsg("Name and email are required.");
} $("go").disabled = true; setMsg("Creating…", true);
function relock(m) { PW = ""; $("hub").style.display = "none"; $("unlock").style.display = "block"; fetch("/api/portal/create-ticket", {
$("unlockMsg").textContent = m || ""; $("unlockMsg").className = "msg err"; } method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pw, name: name, email: email, type: type })
// ---- Unlock ---- }).then(function (r) { return r.json().then(function (d) { return { s: r.status, d: d }; }); })
function unlock() { .then(function (x) {
var pw = $("pw").value; $("go").disabled = false;
if (!pw) { $("unlockMsg").textContent = "Enter the password."; $("unlockMsg").className = "msg err"; return; } if (x.s === 401) return setMsg("Wrong password.");
$("unlockBtn").disabled = true; $("unlockMsg").textContent = "Checking…"; $("unlockMsg").className = "msg ok"; if (x.s !== 200 || !x.d.ok) return setMsg(x.d.detail || x.d.error || "Failed to create ticket.");
fetch("/api/portal/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: pw }) }) setMsg("");
.then(function (r) { return r.status; }) $("qr").src = x.d.qr; $("rcode").textContent = x.d.code;
.then(function (s) { $("rwho").textContent = x.d.type + " · " + x.d.name;
$("unlockBtn").disabled = false; $("rmail").textContent = x.d.emailSent ? "Emailed to " + email : "Email not sent — show/screenshot this QR.";
if (s !== 200) { $("unlockMsg").textContent = "Wrong password."; $("unlockMsg").className = "msg err"; return; } $("result").style.display = "block";
PW = pw; $("unlockMsg").textContent = ""; $("unlock").style.display = "none"; $("hub").style.display = "block"; $("name").value = ""; $("email").value = "";
loadStatus();
}) })
.catch(function () { $("unlockBtn").disabled = false; $("unlockMsg").textContent = "Network error."; }); .catch(function () { $("go").disabled = false; setMsg("Network error."); });
}
$("unlockBtn").addEventListener("click", unlock);
$("pw").addEventListener("keydown", function (e) { if (e.key === "Enter") unlock(); });
$("relock").addEventListener("click", function () { relock(""); $("unlockMsg").textContent = ""; });
// ---- Nav ----
var navs = document.querySelectorAll(".nav");
for (var i = 0; i < navs.length; i++) navs[i].addEventListener("click", function () {
var sec = this.getAttribute("data-sec");
for (var j = 0; j < navs.length; j++) navs[j].classList.toggle("on", navs[j] === this);
var secs = document.querySelectorAll(".sec");
for (var k = 0; k < secs.length; k++) secs[k].classList.toggle("on", secs[k].id === "sec-" + sec);
}.bind(navs[i]));
// ---- Comp ----
var compType = "Guest";
var pills = document.querySelectorAll("#typePills .pill");
for (var p = 0; p < pills.length; p++) pills[p].addEventListener("click", function () {
for (var q = 0; q < pills.length; q++) pills[q].classList.toggle("on", pills[q] === this);
compType = this.textContent.replace(/^[^A-Za-z]+/, "").trim();
}.bind(pills[p]));
function setC(t, ok) { $("cMsg").textContent = t; $("cMsg").className = "msg " + (ok ? "ok" : "err"); }
$("cGo").addEventListener("click", function () {
var name = $("cName").value.trim(), email = $("cEmail").value.trim();
if (!name || !email) return setC("Name and email are required.");
$("cGo").disabled = true; setC("Creating…", true);
api("/api/portal/create-ticket", { name: name, email: email, type: compType }).then(function (x) {
$("cGo").disabled = false;
if (x.s === 401) return relock("Password changed — unlock again.");
if (x.s !== 200 || !x.d.ok) return setC(x.d.detail || x.d.error || "Failed.");
setC("");
$("cQr").src = x.d.qr; $("cCode").textContent = x.d.code;
$("cWho").textContent = x.d.type + " · " + x.d.name;
$("cMail").textContent = x.d.emailSent ? "Emailed to " + email : "Email not sent — screenshot this QR.";
$("cResult").style.display = "block"; $("cName").value = ""; $("cEmail").value = "";
}).catch(function () { $("cGo").disabled = false; setC("Network error."); });
}); });
$("another").addEventListener("click", function () { $("result").style.display = "none"; $("name").focus(); });
// ---- Donors ----
function esc(s) { return String(s == null ? "" : s).replace(/[&<>]/g, function (c) { return c === "&" ? "&amp;" : c === "<" ? "&lt;" : "&gt;"; }); }
function money(n) { return "$" + Math.round(n).toLocaleString(); }
function searchDonors() {
var q = $("dQ").value.trim();
if (q.length < 2) { $("dMsg").textContent = "Type at least 2 characters."; $("dMsg").className = "msg err"; return; }
$("dGo").disabled = true; $("dMsg").textContent = "Searching…"; $("dMsg").className = "msg ok"; $("dResults").innerHTML = "";
api("/api/admin/donor-search", { query: q }).then(function (x) {
$("dGo").disabled = false;
if (x.s === 401) return relock("Password changed — unlock again.");
if (x.s !== 200) { $("dMsg").textContent = (x.d && (x.d.detail || x.d.error)) || "Search failed."; $("dMsg").className = "msg err"; return; }
var r = x.d.results || [];
$("dMsg").textContent = r.length ? r.length + " result" + (r.length === 1 ? "" : "s") : "No donors match “" + q + "”.";
$("dMsg").className = "msg";
var html = "";
for (var i = 0; i < r.length; i++) {
var d = r[i];
html += '<div class="donor"><div class="h"><span class="nm">' + esc(d.name || d.email || "(unnamed)") + '</span>';
if (d.lifetime != null) html += '<span class="amt">' + money(d.lifetime) + '</span>';
html += '</div>';
if (d.bearName) html += '<div class="ln">🐻 ' + esc(d.bearName) + '</div>';
if (d.email) html += '<div class="ln">✉️ ' + esc(d.email) + '</div>';
if (d.altEmail) html += '<div class="ln">✉️ ' + esc(d.altEmail) + ' (alt)</div>';
if (d.phone) html += '<div class="ln">📞 ' + esc(d.phone) + '</div>';
if (d.address) html += '<div class="ln">🏠 ' + esc(d.address) + '</div>';
html += '<div class="tags"><span class="src">' + (d.source === "master" ? "directory" : "transactions") + '</span>';
for (var t = 0; t < (d.tags || []).length; t++) html += '<span class="tag">' + esc(d.tags[t]) + '</span>';
html += '</div></div>';
}
$("dResults").innerHTML = html;
}).catch(function () { $("dGo").disabled = false; $("dMsg").textContent = "Network error."; $("dMsg").className = "msg err"; });
}
$("dGo").addEventListener("click", searchDonors);
$("dQ").addEventListener("keydown", function (e) { if (e.key === "Enter") searchDonors(); });
// ---- Actions ----
function loadStatus() {
$("aStatus").textContent = "loading…";
api("/api/admin/status", {}).then(function (x) {
if (x.s === 401) return relock("Password changed — unlock again.");
if (x.s !== 200) { $("aStatus").textContent = "error"; return; }
var t = x.d.tickets, a = x.d.audit;
counts = { tickets: t.count, audit: a.count, table: t.tableId };
$("aStatus").textContent = "tickets: " + t.tableId + " · " + t.count + " records\\naudit: " + (a.tableId || "—") + " · " + a.count + " records";
}).catch(function () { $("aStatus").textContent = "network error"; });
}
$("aRefresh").addEventListener("click", loadStatus);
function aMsg(t, ok) { $("aMsg").textContent = t; $("aMsg").className = "msg " + (ok ? "ok" : "err"); }
function openModal(title, body, confirmLabel, action) {
$("mTitle").textContent = title; $("mBody").textContent = body;
$("mConfirm").textContent = confirmLabel; pendingAction = action; $("scrim").classList.add("on");
}
function closeModal() { $("scrim").classList.remove("on"); pendingAction = null; $("mConfirm").disabled = false; }
$("mCancel").addEventListener("click", closeModal);
$("mConfirm").addEventListener("click", function () { if (pendingAction) { $("mConfirm").disabled = true; pendingAction(); } });
$("wipeBtn").addEventListener("click", function () {
openModal("Wipe the slate clean?",
"This will PERMANENTLY DELETE all data in the active event table:\\n" +
"• " + counts.tickets + " ticket records (" + counts.table + ")\\n" +
"• " + counts.audit + " check-in / audit records\\n\\n" +
"Donor data is not touched. This CANNOT be undone.",
"Yes, delete everything", doWipe);
});
function doWipe() {
api("/api/admin/wipe", {}).then(function (x) {
closeModal();
if (x.s === 401) return relock("Password changed — unlock again.");
if (x.s !== 200 || !x.d.ok) return aMsg((x.d && (x.d.detail || x.d.error)) || "Wipe failed.");
aMsg("✓ Wiped " + x.d.ticketsDeleted + " tickets and " + x.d.auditDeleted + " audit rows.", true);
loadStatus();
}).catch(function () { closeModal(); aMsg("Network error."); });
}
$("switchBtn").addEventListener("click", function () {
var t = $("swTickets").value.trim(), a = $("swAudit").value.trim();
if (!t) return aMsg("Enter the new tickets table ID.");
openModal("Switch the active event table?",
"The scanner will start using:\\n• tickets → " + t + "\\n• audit → " + (a || "unchanged") + "\\n\\n" +
"The current event (" + counts.table + ", " + counts.tickets + " records) stays intact but will no longer be shown until you switch back. New purchases and scans go to the new table.",
"Yes, switch table", function () { doSwitch(t, a); });
});
function doSwitch(t, a) {
api("/api/admin/switch-table", { ticketsTableId: t, auditTableId: a || undefined }).then(function (x) {
closeModal();
if (x.s === 401) return relock("Password changed — unlock again.");
if (x.s !== 200 || !x.d.ok) return aMsg((x.d && (x.d.detail || x.d.error)) || "Switch failed.");
aMsg("✓ Now using tickets table " + x.d.tickets.tableId + ".", true);
$("swTickets").value = ""; $("swAudit").value = ""; loadStatus();
}).catch(function () { closeModal(); aMsg("Network error."); });
}
</script> </script>
</body> </body>
</html>`; </html>`;

View file

@ -17,21 +17,17 @@ function safeEqual(a: string, b: string): boolean {
*/ */
export async function publicLookupRoutes(app: FastifyInstance): Promise<void> { export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
const cfg = app.ctx.config; const cfg = app.ctx.config;
const allowed = cfg.PUBLIC_LOOKUP_ORIGIN; // string[] allowlist const origin = cfg.PUBLIC_LOOKUP_ORIGIN;
const cors = (req: any, reply: any) => { const cors = (reply: any) => {
const reqOrigin = String(req.headers?.origin ?? "").replace(/\/+$/, "");
// Echo the caller's origin only if it's on the allowlist; otherwise fall
// back to the first configured origin (keeps non-browser callers working).
const origin = allowed.includes(reqOrigin) ? reqOrigin : allowed[0];
reply.header("Access-Control-Allow-Origin", origin); reply.header("Access-Control-Allow-Origin", origin);
reply.header("Vary", "Origin"); reply.header("Vary", "Origin");
reply.header("Access-Control-Allow-Methods", "GET, OPTIONS"); reply.header("Access-Control-Allow-Methods", "GET, OPTIONS");
}; };
// Preflight (in case the form sends one). // Preflight (in case the form sends one).
const preflight = async (req: any, reply: any) => { const preflight = async (_req: any, reply: any) => {
cors(req, reply); cors(reply);
return reply.code(204).send(); return reply.code(204).send();
}; };
app.options("/api/public/donor-eligibility", preflight); app.options("/api/public/donor-eligibility", preflight);
@ -46,7 +42,7 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
"/api/public/donor-eligibility", "/api/public/donor-eligibility",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
cors(req, reply); cors(reply);
// Disabled unless configured. // Disabled unless configured.
if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) { if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) {
return reply.code(404).send({ error: "not_available" }); return reply.code(404).send({ error: "not_available" });
@ -69,20 +65,14 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
}, },
); );
// Ticket-voucher entitlement: how many FREE tickets a donor has left. This is // Ticket-voucher entitlement: how many free tickets a donor has earned from
// the tier entitlement earned from giving on/after VOUCHER_SINCE, MINUS the // giving on/after VOUCHER_SINCE. Same secret/CORS/rate-limit as above.
// vouchers already consumed by their prior ticket orders (each order stores // Returns only the count (0/1/2) — no dollar amounts.
// 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( app.get(
"/api/public/ticket-vouchers", "/api/public/ticket-vouchers",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
cors(req, reply); cors(reply);
if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) { if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) {
return reply.code(404).send({ error: "not_available" }); return reply.code(404).send({ error: "not_available" });
} }
@ -91,18 +81,16 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
} }
const { email } = (req.query ?? {}) as { email?: string }; const { email } = (req.query ?? {}) as { email?: string };
const addr = String(email ?? "").trim(); const addr = String(email ?? "").trim();
if (!addr) return { vouchers: 0, entitled: 0, used: 0, remaining: 0 }; if (!addr) return { vouchers: 0 };
try { try {
const cutoff = new Date(cfg.VOUCHER_SINCE); const cutoff = new Date(cfg.VOUCHER_SINCE);
const { amount } = await app.ctx.donors.amountSince(addr, cutoff); 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 vouchers = amount >= cfg.VOUCHER_TIER2_MIN ? 2 : amount >= cfg.VOUCHER_TIER1_MIN ? 1 : 0;
const used = await app.ctx.nocodb.vouchersUsedByEmail(addr); return { vouchers };
const remaining = Math.max(0, entitled - used);
return { vouchers: remaining, entitled, used, remaining };
} catch { } catch {
// Fail closed — grant no vouchers rather than error. // Fail closed — grant no vouchers rather than error.
return { vouchers: 0, entitled: 0, used: 0, remaining: 0 }; return { vouchers: 0 };
} }
}, },
); );

View file

@ -37,11 +37,11 @@ const PERSONAS: Persona[] = [
name: "Family Fay", name: "Family Fay",
email: "family@test.beartaria", email: "family@test.beartaria",
adultNames: ["Family Fay", "Frank Fay"], adultNames: ["Family Fay", "Frank Fay"],
counts: C(2, 1, 1, 2, 2), // 2 adults + 1 youth = 3 paid; 5 kids 12 & under free counts: C(2, 0, 0, 3, 2), // 2 adults + 3 kids(5-9) = 5 scannable; 2 kids 0-4 free
iceBags: 3, iceBags: 3,
carParking: true, carParking: true,
blurb: 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 adults + 3 kids 5-9; two 0-4 free), car parking, 3 ice bags. Check-in a few at a time to test QR reuse + see adult names; then Ice mode.",
}, },
{ {
key: "donor2", key: "donor2",
@ -119,8 +119,8 @@ export async function testRoutes(app: FastifyInstance): Promise<void> {
// Keep the "exhausted" persona fully redeemed on every load so its state // Keep the "exhausted" persona fully redeemed on every load so its state
// is deterministic (total = scannable count from the persona's counts). // is deterministic (total = scannable count from the persona's counts).
if (p.exhaust) { if (p.exhaust) {
const { adults, youth } = p.counts; const { adults, youth, kids12, kids9 } = p.counts;
await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth }); await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth + kids12 + kids9 });
} }
cards.push({ cards.push({
code: result.code, code: result.code,

View file

@ -2,7 +2,6 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { normalizeCode, looksLikeCode } from "../services/code.js"; import { normalizeCode, looksLikeCode } from "../services/code.js";
import { lookupByCode, redeem, search, createTicket } from "../ticketService.js"; import { lookupByCode, redeem, search, createTicket } from "../ticketService.js";
import { renderQrPng } from "../services/qrcode.js"; import { renderQrPng } from "../services/qrcode.js";
import { computeStats } from "../services/stats.js";
import { COL } from "../fields.js"; import { COL } from "../fields.js";
async function requireStaff(req: FastifyRequest, reply: FastifyReply): Promise<void> { async function requireStaff(req: FastifyRequest, reply: FastifyReply): Promise<void> {
@ -43,12 +42,6 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
}, },
); );
// Aggregate event report (check-in progress, ice, types, extras, operators).
app.get("/api/stats", { preHandler: requireStaff }, async (req) => {
const force = String((req.query as any)?.force ?? "") === "1";
return computeStats(app.ctx, force);
});
// Recent check-in audit log (all, or filtered to one code via ?code=). // Recent check-in audit log (all, or filtered to one code via ?code=).
app.get("/api/audit", { preHandler: requireStaff }, async (req) => { app.get("/api/audit", { preHandler: requireStaff }, async (req) => {
const code = (req.query as any)?.code ? normalizeCode(String((req.query as any).code)) : undefined; const code = (req.query as any)?.code ? normalizeCode(String((req.query as any).code)) : undefined;

View file

@ -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<string, any>;
const boothName = String(body.input_text ?? "").trim();
// Pass-holder names (non-empty slots, in order).
const passHolders = FOOD_NAME_SLOTS.map((b) => nameGroup(body, b)).filter(Boolean);
const primary = passHolders[0] ?? "";
// Ticket title = booth name (most useful at the gate), else the first person.
const title = boothName || primary;
if (!title) {
return reply.code(400).send({ error: "missing_fields", detail: "booth name or vendor name is required" });
}
const email = String(body.email ?? "").trim();
// One entry pass per named person; a booth with no names still gets 1.
const passes = Math.max(1, passHolders.length);
const { isDonor, donorTier } = readDonor(body);
const address = addressLine(body.address_1);
// Idempotency: prefer a stable submission id, else hash the content.
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id;
const submissionKey = submissionId
? `sub:${String(submissionId)}`
: "hash:" +
createHash("sha256")
.update(`vendor|Food Vendor|${email}|${title}|${passes}`)
.digest("hex")
.slice(0, 32);
// Vendor passes are adult admissions; no youth/kids/ice/parking.
const counts = { adults: passes, youth: 0, kids12: 0, kids9: 0, kids4: 0 };
let result: Awaited<ReturnType<typeof createTicket>>;
try {
result = await createTicket(app.ctx, {
name: title,
adultNames: passHolders,
email,
address,
isDonor,
donorTier,
ticketType: "Food Vendor",
counts,
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
submissionKey,
});
} catch (e: any) {
req.log.error({ err: e }, "vendor webhook: failed to create ticket");
return reply.code(502).send({ error: "db_error", detail: e?.message });
}
if (result.status === "duplicate") {
return { status: "duplicate", code: result.code };
}
// Email the ticket QR (FluentForms sends the receipt separately).
if (!email) {
req.log.warn({ code: result.code }, "vendor webhook: ticket created but no email");
return { status: "created", code: result.code, passes, emailSent: false, emailSkipped: "no_email" };
}
if (app.ctx.mailer.isBlockedRecipient(email)) {
req.log.warn({ email }, "vendor webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send");
return { status: "created", code: result.code, passes, emailSent: false, emailSkipped: "trial_restriction" };
}
try {
const qr = await renderQrPng(result.code);
await app.ctx.mailer.sendTicket({
toEmail: email,
toName: primary || title,
code: result.code,
quantity: passes,
qrPng: qr,
});
} catch (e: any) {
req.log.error({ err: e, code: result.code }, "vendor webhook: created but email failed");
return reply.code(502).send({ status: "created", code: result.code, passes, emailSent: false, error: e?.message });
}
return { status: "created", code: result.code, passes, emailSent: true };
};
}
/** Non-food vendors don't get an entry ticket. Acknowledge and issue nothing
* (so a wired FluentForms feed doesn't error), but never create a ticket. */
function nonFoodHandler(app: FastifyInstance) {
return async (req: any, reply: any) => {
if (!checkSecret(app, req)) {
return reply.code(401).send({ error: "unauthorized" });
}
return { status: "ignored", reason: "non_food_no_ticket" };
};
}
export async function vendorWebhookRoutes(app: FastifyInstance): Promise<void> {
// Configure this URL in the FOOD vendor FluentForms form:
// https://scan.beartariacampgrounds.com/vendor-webhook/food
// Non-food vendors receive no entry ticket; the endpoint below is a safe
// no-op only so an accidentally-wired feed doesn't 404.
app.post("/vendor-webhook/food", foodHandler(app));
app.post("/vendor-webhook/non-food", nonFoodHandler(app));
// Explicit API aliases.
app.post("/api/webhook/vendor-food", foodHandler(app));
app.post("/api/webhook/vendor-non-food", nonFoodHandler(app));
}

View file

@ -1,21 +1,58 @@
import { createHash } from "node:crypto"; import { createHash, timingSafeEqual } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { toBool } from "../fields.js"; import { toBool, toNumber } from "../fields.js";
import { createTicket } from "../ticketService.js"; import { createTicket } from "../ticketService.js";
import { renderQrPng } from "../services/qrcode.js"; import { renderQrPng } from "../services/qrcode.js";
import { safeEqual, nameGroup, qty, selected, addressLine, iceBagsFromPayment } from "../fluentforms.js";
// Regular adult attendee name groups (Adult Ticket #1#10), in order. function safeEqual(a: string, b: string): boolean {
const REGULAR_NAME_BASES = [ const ba = Buffer.from(a || "");
"names", const bb = Buffer.from(b || "");
"names_1", "names_2", "names_3", "names_4", "names_5", "names_6", "names_7", "names_8", "names_9", if (ba.length !== bb.length) return false;
]; return timingSafeEqual(ba, bb);
// Donor voucher ticket name groups. Each FILLED group is one free voucher adult }
// ticket — the voucher tickets live in these two name fields (there's no
// separate quantity field for them). /** Read a FluentForms compound name field, given as a nested object
const DONOR_NAME_BASES = ["names_Donor_1", "names_Donor_2"]; * (`names: {first_name,...}`) or flattened bracket keys (`names[first_name]`). */
// All adult names for the gate display list. function nameGroup(body: Record<string, any>, base: string): string {
const ADULT_NAME_BASES = [...REGULAR_NAME_BASES, ...DONOR_NAME_BASES]; const obj = body[base];
let first: any, middle: any, last: any;
if (obj && typeof obj === "object") {
({ first_name: first, middle_name: middle, last_name: last } = obj);
} else {
first = body[`${base}[first_name]`];
middle = body[`${base}[middle_name]`];
last = body[`${base}[last_name]`];
}
return [first, middle, last]
.map((x) => (x == null ? "" : String(x).trim()))
.filter(Boolean)
.join(" ");
}
/** Read an item_quantity / payment field's numeric value (handles nested
* objects like {quantity} / {value} and money strings like "$40.00"). */
function qty(v: any): number {
if (v == null || v === "") return 0;
if (typeof v === "object") return toNumber(v.quantity ?? v.value ?? v.item_quantity ?? v.amount ?? 0);
if (typeof v === "string") return toNumber(v.replace(/[^0-9.\-]/g, ""));
return toNumber(v);
}
/** A payment/extra field counts as "selected" if it has a meaningful value.
* Donor (free) items can be $0, so a non-empty, non-"no"/"0" value also counts. */
function selected(v: any): boolean {
if (v == null || v === "") return false;
if (typeof v === "object") {
if ("selected" in v) return toBool((v as any).selected);
return qty(v) > 0 || Object.keys(v).length > 0;
}
const s = String(v).trim().toLowerCase();
if (!s || s === "no" || s === "0" || s === "$0" || s === "$0.00" || s === "false" || s === "none") return false;
return true;
}
// Adult name field bases, in order (purchaser first).
const ADULT_NAME_BASES = ["names", "names_1", "names_2", "names_3", "names_4", "names_5", "names_6", "names_7", "names_8", "names_9"];
export async function webhookRoutes(app: FastifyInstance): Promise<void> { export async function webhookRoutes(app: FastifyInstance): Promise<void> {
const handler = async (req: any, reply: any) => { const handler = async (req: any, reply: any) => {
@ -26,37 +63,30 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
const body = (req.body ?? {}) as Record<string, any>; const body = (req.body ?? {}) as Record<string, any>;
// Purchaser = the first adult name group; fall back to a plain `name` field.
const name = nameGroup(body, "names") || String(body.name ?? "").trim();
const email = String(body.email ?? "").trim(); const email = String(body.email ?? "").trim();
if (!name) {
// Billing/customer name (the purchaser — may differ from attendees, e.g. return reply.code(400).send({ error: "missing_fields", detail: "purchaser name is required" });
// 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" });
} }
// Attendee counts. Adults = regular (paid) tickets + additional paid donor // Adult attendee names (non-empty groups, in order).
// tickets + free voucher tickets (one per donor name provided). const adultNames = ADULT_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean);
// Attendee counts.
const counts = { const counts = {
adults: adults: qty(body.item_quantity_adult_ticket_reg) + qty(body.item_quantity_adult_ticket_donor),
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), youth: qty(body.item_quantity_youth_ticket_reg) + qty(body.item_quantity_youth_ticket_donor),
kids12: qty(body.item_quantity_kids_12), kids12: qty(body.item_quantity_kids_12),
kids9: qty(body.item_quantity_kids_9), kids9: qty(body.item_quantity_kids_9),
kids4: qty(body.item_quantity_kids_4), kids4: qty(body.item_quantity_kids_4),
}; };
// Paid/scannable admissions = adults + youth 13-16. Children 12 & under are const scannable = counts.adults + counts.youth + counts.kids12 + counts.kids9;
// free (charging starts at 13) and are stored but not counted at the gate. if (scannable <= 0) {
const scannable = counts.adults + counts.youth; // Nothing to check in at the gate. Log the payload so we can calibrate.
req.log.warn({ body }, "webhook: no scannable tickets in submission");
return reply.code(400).send({ error: "no_tickets", detail: "no scannable tickets (adults/youth/kids 5+)" });
}
// Donor info (hidden fields from the eligibility/voucher lookups) + radio. // Donor info (hidden fields from the eligibility/voucher lookups) + radio.
const donorTier = String(body.donor_tier ?? "").trim(); const donorTier = String(body.donor_tier ?? "").trim();
@ -65,48 +95,40 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
donorTier === "donor" || donorTier === "donor" ||
toBool(body.donor_eligible) || toBool(body.donor_eligible) ||
selected(body.input_radio); // "Are you a campground donor?" selected(body.input_radio); // "Are you a campground donor?"
// Vouchers consumed in this order = the free voucher tickets actually taken const vouchers = qty(body.vouchers);
// (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). // 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 carParking = selected(body.payment_parking_reg) || selected(body.payment_parking_donor);
const rvParking = selected(body.payment_rv_reg) || selected(body.payment_rv_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); const utv = selected(body.payment_utv_reg) || selected(body.payment_utv_donor);
// Ice: payment_ice is a descriptive option label whose "(N total bags)" // Ice: payment_ice is either a ticket count (1-4) or a dollar total
// states the bags. One ice ticket = ICE_BAGS_PER_TICKET bags. // ($20-$80). One ice ticket = ICE_BAGS_PER_TICKET bags.
const iceBags = iceBagsFromPayment(body.payment_ice, { const iceRaw = qty(body.payment_ice);
bagsPerTicket: app.ctx.config.ICE_BAGS_PER_TICKET, const iceTickets = iceRaw >= app.ctx.config.ICE_TICKET_PRICE ? Math.round(iceRaw / app.ctx.config.ICE_TICKET_PRICE) : Math.round(iceRaw);
ticketPrice: app.ctx.config.ICE_TICKET_PRICE, const iceBags = Math.max(0, iceTickets) * app.ctx.config.ICE_BAGS_PER_TICKET;
});
const iceAccess = iceBags > 0 || selected(body.input_radio_7); const iceAccess = iceBags > 0 || selected(body.input_radio_7);
// Tickets are optional: a customer can buy ice/UTV/parking with no admission const address =
// ticket, or buy tickets for others. Only reject a truly empty order — body.address_1 && typeof body.address_1 === "object"
// nothing to check in, redeem, or verify at the gate. ? Object.values(body.address_1).filter(Boolean).join(", ")
const hasIssuable = scannable > 0 || iceBags > 0 || utv || carParking || rvParking; : body.address_1 !== undefined
if (!hasIssuable) { ? String(body.address_1)
req.log.warn({ body }, "webhook: submission has nothing to issue"); : undefined;
return reply.code(400).send({ error: "no_items", detail: "no tickets, ice, or add-ons in submission" });
}
const address = addressLine(body.address_1); // Idempotency: prefer a stable submission id, else hash the content.
// Idempotency: prefer a stable submission id, else hash the content
// (include ice/extras so distinct add-on-only orders don't collide).
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id; const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id;
const submissionKey = submissionId const submissionKey = submissionId
? `sub:${String(submissionId)}` ? `sub:${String(submissionId)}`
: "hash:" + : "hash:" +
createHash("sha256") createHash("sha256")
.update(`${email}|${title}|${JSON.stringify(counts)}|${iceBags}|${carParking}|${rvParking}|${utv}`) .update(`${email}|${name}|${JSON.stringify(counts)}`)
.digest("hex") .digest("hex")
.slice(0, 32); .slice(0, 32);
let result: Awaited<ReturnType<typeof createTicket>>; let result: Awaited<ReturnType<typeof createTicket>>;
try { try {
result = await createTicket(app.ctx, { result = await createTicket(app.ctx, {
name: title, name,
adultNames, adultNames,
email, email,
address, address,
@ -147,11 +169,10 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
const qr = await renderQrPng(result.code); const qr = await renderQrPng(result.code);
await app.ctx.mailer.sendTicket({ await app.ctx.mailer.sendTicket({
toEmail: email, toEmail: email,
toName: purchaser, toName: name,
code: result.code, code: result.code,
quantity: scannable, quantity: scannable,
qrPng: qr, qrPng: qr,
iceBags,
}); });
} catch (e: any) { } catch (e: any) {
req.log.error({ err: e, code: result.code }, "webhook: ticket created but email failed"); req.log.error({ err: e, code: result.code }, "webhook: ticket created but email failed");

View file

@ -11,21 +11,19 @@ interface Field {
} }
const FIELDS: Field[] = [ 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: "required", type: "name (compound)", desc: "Purchaser / Adult #1 — object {first_name, middle_name, last_name}. Also accepts flat names[first_name] keys." },
{ key: "names", req: "optional", type: "name (compound)", desc: "Adult Ticket #1 attendee — object {first_name, middle_name, last_name}. Also accepts flat names[first_name] keys. May be empty when buying only donor tickets or add-ons." }, { key: "names_1 … names_9", req: "optional", type: "name (compound)", desc: "Additional adult attendee names (Adults #2#10). Empty groups are ignored. Stored as the adult-name list shown at the gate." },
{ key: "names_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: "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: "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_reg", req: "required", type: "quantity", desc: "Adult tickets (regular)." },
{ 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_adult_ticket_donor", req: "required", type: "quantity", desc: "Adult tickets (donor). Added to the regular adults." },
{ key: "item_quantity_youth_ticket_reg / _donor", req: "optional", type: "quantity", desc: "Youth 13-16 tickets (regular + donor)." }, { key: "item_quantity_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_12", req: "optional", type: "quantity", desc: "Kids 10-12. Counts toward the scannable total." },
{ key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. FREE — stored but NOT counted toward the scannable ticket total." }, { key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. Counts toward the scannable total." },
{ key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE — stored but NOT counted toward the scannable ticket total." }, { key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE — NOT counted toward the scannable ticket total." },
{ key: "donor_tier", req: "optional", type: "hidden", desc: "member / donor / empty (from the donor-eligibility lookup)." }, { key: "donor_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: "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: "vouchers", req: "optional", type: "hidden", desc: "Integer voucher count (from the ticket-voucher lookup)." },
{ key: "input_radio", req: "optional", type: "choice", desc: "'Are you a campground donor?' — also used as a donor signal." }, { key: "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_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_rv_reg / _donor", req: "optional", type: "payment", desc: "RV. Flagged if either variant is selected." },
@ -56,7 +54,6 @@ const rows = FIELDS.map(
const exampleJson = esc(`{ const exampleJson = esc(`{
"id": "412", "id": "412",
"customer_name": { "first_name": "Jane", "last_name": "Bear" },
"names": { "first_name": "Jane", "last_name": "Bear" }, "names": { "first_name": "Jane", "last_name": "Bear" },
"names_1": { "first_name": "John", "last_name": "Bear" }, "names_1": { "first_name": "John", "last_name": "Bear" },
"email": "jane@example.com", "email": "jane@example.com",
@ -121,22 +118,21 @@ const PAGE = `<!doctype html>
<h2>What it does</h2> <h2>What it does</h2>
<p>On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject <b>"2026 Beartaria Campgrounds Tickets"</b>). FluentForms sends the payment receipt separately.</p> <p>On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject <b>"2026 Beartaria Campgrounds Tickets"</b>). FluentForms sends the payment receipt separately.</p>
<p><b>Scannable ticket total</b> = adults + youth (13-16). <b>Adults</b> = <code>item_quantity_adult_ticket_reg</code> (regular) + <code>item_quantity_adult_ticket_donor</code> (extra paid donor tickets) + the number of donor voucher names (<code>names_Donor_1/2</code> each filled name is one free voucher ticket). <b>Children 12 &amp; under are free</b> (charging starts at 13) stored and shown to gate staff, but not counted toward the total. Each adult name provided is stored and shown on a successful scan; the ticket title is the <code>customer_name</code>.</p> <p><b>Scannable ticket total</b> = adults + youth (13-16) + kids 10-12 + kids 5-9. <b>Kids 0-4 are free</b> and not counted. Each adult name provided is stored and shown to gate staff on a successful scan.</p>
<p><b>Tickets are optional.</b> A customer can buy ice, an ATV/UTV pass, or parking with no admission ticket, or buy tickets for other people. A record + QR is still created as long as there's something to redeem or verify at the gate (a ticket, ice, or an add-on). Only a truly empty order is rejected.</p>
<h2>Fields</h2> <h2>Fields</h2>
<table> <table>
<thead><tr><th>Key</th><th>Required</th><th>Type</th><th>Description</th></tr></thead> <thead><tr><th>Key</th><th>Required</th><th>Type</th><th>Description</th></tr></thead>
<tbody>${rows}</tbody> <tbody>${rows}</tbody>
</table> </table>
<p class="sub">Compound name fields arrive as objects (<code>names: {first_name,}</code>) or flattened <code>names[first_name]</code> keys both handled. Quantity/payment fields accept numbers, money strings ("$40.00"), or <code>{quantity}</code> objects. Counts come from the <code>item_quantity_*</code> fields, so pure pricing line items (<code>payment_adult_reg</code>, <code>payment_youth_*</code>, <code>payment_kids_free</code>, <code>payment_donor_voucher1/2</code>, <code>custom-payment-amount</code>/Tax) are ignored the <code>vouchers</code> hidden count is authoritative for donor vouchers.</p> <p class="sub">Compound name fields arrive as objects (<code>names: {first_name,}</code>) or flattened <code>names[first_name]</code> keys both handled. Quantity/payment fields accept numbers, money strings ("$40.00"), or <code>{quantity}</code> objects.</p>
<h2>Idempotency</h2> <h2>Idempotency</h2>
<p>Send a stable <code>id</code> / <code>submission_id</code>. A repeat returns <code>{"status":"duplicate"}</code> without creating a second ticket or re-emailing safe for retries and double-submits.</p> <p>Send a stable <code>id</code> / <code>submission_id</code>. A repeat returns <code>{"status":"duplicate"}</code> without creating a second ticket or re-emailing safe for retries and double-submits.</p>
<h2>Example payload</h2> <h2>Example payload</h2>
<pre><code>${exampleJson}</code></pre> <pre><code>${exampleJson}</code></pre>
<p class="sub">This issues 3 scannable tickets (2 adults + 1 youth 13-16; all four kids 12 &amp; under are free), member donor with 2 vouchers, car parking, and 6 bags of ice (2 ice tickets).</p> <p class="sub">This issues 5 scannable tickets (2 adults + 1 youth + 2 kids 5-9; the two kids 0-4 are free), member donor with 2 vouchers, car parking, and 6 bags of ice (2 ice tickets).</p>
<h2>Test with curl</h2> <h2>Test with curl</h2>
<pre><code>${exampleCurl}</code></pre> <pre><code>${exampleCurl}</code></pre>
@ -147,8 +143,7 @@ const PAGE = `<!doctype html>
<tbody> <tbody>
<tr><td>200</td><td><code>{"status":"created","code":"BC26-…","emailSent":true}</code></td><td>Ticket created and emailed.</td></tr> <tr><td>200</td><td><code>{"status":"created","code":"BC26-…","emailSent":true}</code></td><td>Ticket created and emailed.</td></tr>
<tr><td>200</td><td><code>{"status":"duplicate","code":"BC26-…"}</code></td><td>Same submission already processed no-op.</td></tr> <tr><td>200</td><td><code>{"status":"duplicate","code":"BC26-…"}</code></td><td>Same submission already processed no-op.</td></tr>
<tr><td>400</td><td><code>{"error":"missing_fields"}</code></td><td>No customer name and no attendee names.</td></tr> <tr><td>400</td><td><code>{"error":"missing_fields"}</code> / <code>"no_tickets"</code></td><td>Missing purchaser name, or zero scannable tickets.</td></tr>
<tr><td>400</td><td><code>{"error":"no_items"}</code></td><td>Empty order no tickets, ice, or add-ons.</td></tr>
<tr><td>401</td><td><code>{"error":"unauthorized"}</code></td><td>Missing or wrong <code>X-Webhook-Secret</code>.</td></tr> <tr><td>401</td><td><code>{"error":"unauthorized"}</code></td><td>Missing or wrong <code>X-Webhook-Secret</code>.</td></tr>
<tr><td>502</td><td><code>{"status":"created","emailSent":false,}</code></td><td>Ticket row created but the email failed re-send from the admin app.</td></tr> <tr><td>502</td><td><code>{"status":"created","emailSent":false,}</code></td><td>Ticket row created but the email failed re-send from the admin app.</td></tr>
</tbody> </tbody>
@ -163,21 +158,6 @@ const PAGE = `<!doctype html>
<li>Save, submit a test purchase, and confirm the QR email arrives.</li> <li>Save, submit a test purchase, and confirm the QR email arrives.</li>
</ol> </ol>
<h2>Vendor booth webhooks</h2>
<p class="sub"><b>Only food vendors receive entry tickets.</b> The vendor forms live on <b>vendors.beartariacampgrounds.com</b> and share the same <code>X-Webhook-Secret</code>. For a food booth, each <b>named</b> person gets one entry pass; the booth name (<code>input_text</code>) becomes the ticket title, and the ticket is tagged with a <b>Food Vendor</b> Ticket Type that shows a badge on scan and rolls up in the event report. Booth size / additional space are logistics and don't affect passes.</p>
<table>
<thead><tr><th>Form</th><th>Endpoint</th><th>Result</th></tr></thead>
<tbody>
<tr><td>Vendor Fee Food 2026</td><td><code>POST /vendor-webhook/food</code></td><td>🍔 up to 2 passes (<code>names</code> + <code>names_1</code>), Food Vendor ticket + QR email</td></tr>
<tr><td>Vendor Fee Non-Food 2026</td><td><code>POST /vendor-webhook/non-food</code></td><td>No ticket acknowledged only (<code>{"status":"ignored"}</code>). You can leave this form's webhook unconfigured.</td></tr>
</tbody>
</table>
<p class="sub">Relevant food keys: <code>input_text</code> (Booth Name), <code>names</code> / <code>names_1</code> (pass-holders), <code>email</code>, <code>address_1</code>, <code>donor_tier</code> / <code>donor_eligible</code> / <code>input_radio</code> (donor), <code>payment_method</code>. Same idempotency (<code>id</code>/<code>submission_id</code>) and response shapes as above, plus a <code>passes</code> count.</p>
<pre><code>curl -X POST https://scan.beartariacampgrounds.com/vendor-webhook/food \\
-H "Content-Type: application/json" \\
-H "X-Webhook-Secret: &lt;your WEBHOOK_SECRET&gt;" \\
-d '{"id":"v-101","input_text":"Joe'\\''s Tacos","names":{"first_name":"Joe","last_name":"Taco"},"names_1":{"first_name":"Jane","last_name":"Taco"},"email":"joe@example.com","donor_tier":"member","payment_method":"stripe"}'</code></pre>
<footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer> <footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer>
</div> </div>
</body> </body>

View file

@ -9,14 +9,12 @@ import { loadConfig } from "./config.js";
import { buildContext } from "./context.js"; import { buildContext } from "./context.js";
import { authRoutes } from "./routes/auth.js"; import { authRoutes } from "./routes/auth.js";
import { webhookRoutes } from "./routes/webhook.js"; import { webhookRoutes } from "./routes/webhook.js";
import { vendorWebhookRoutes } from "./routes/vendorWebhook.js";
import { ticketRoutes } from "./routes/tickets.js"; import { ticketRoutes } from "./routes/tickets.js";
import { testRoutes } from "./routes/test.js"; import { testRoutes } from "./routes/test.js";
import { installRoutes } from "./routes/install.js"; import { installRoutes } from "./routes/install.js";
import { webhookDocRoutes } from "./routes/webhookDoc.js"; import { webhookDocRoutes } from "./routes/webhookDoc.js";
import { publicLookupRoutes } from "./routes/publicLookup.js"; import { publicLookupRoutes } from "./routes/publicLookup.js";
import { portalRoutes } from "./routes/portal.js"; import { portalRoutes } from "./routes/portal.js";
import { adminRoutes } from "./routes/admin.js";
export async function build() { export async function build() {
const config = loadConfig(); const config = loadConfig();
@ -34,14 +32,12 @@ export async function build() {
await app.register(authRoutes); await app.register(authRoutes);
await app.register(webhookRoutes); await app.register(webhookRoutes);
await app.register(vendorWebhookRoutes);
await app.register(ticketRoutes); await app.register(ticketRoutes);
await app.register(testRoutes); await app.register(testRoutes);
await app.register(installRoutes); await app.register(installRoutes);
await app.register(webhookDocRoutes); await app.register(webhookDocRoutes);
await app.register(publicLookupRoutes); await app.register(publicLookupRoutes);
await app.register(portalRoutes); await app.register(portalRoutes);
await app.register(adminRoutes);
// Serve the exported Expo web build (if present) with SPA fallback. // Serve the exported Expo web build (if present) with SPA fallback.
const webDir = config.WEB_DIR ?? join(process.cwd(), "web"); const webDir = config.WEB_DIR ?? join(process.cwd(), "web");

View file

@ -34,7 +34,7 @@ export interface AuditRow extends AuditEntry {
export class AuditLogger { export class AuditLogger {
private readonly base: string; private readonly base: string;
private readonly token: string; private readonly token: string;
private tableId: string | null; private readonly tableId: string | null;
constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_AUDIT_TABLE_ID">) { constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_AUDIT_TABLE_ID">) {
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, ""); this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
@ -46,56 +46,10 @@ export class AuditLogger {
return this.tableId !== null; return this.tableId !== null;
} }
/** The audit table id (switchable at runtime by the admin action). */
get currentTableId(): string | null {
return this.tableId;
}
setTableId(id: string | null): void {
this.tableId = id || null;
}
private get url(): string { private get url(): string {
return `${this.base}/api/v2/tables/${this.tableId}/records`; return `${this.base}/api/v2/tables/${this.tableId}/records`;
} }
/** Total audit row count (cheap — reads pageInfo). */
async count(): Promise<number> {
if (!this.tableId) return 0;
const url = new URL(this.url);
url.searchParams.set("limit", "1");
const res = await fetch(url.toString(), {
headers: { "xc-token": this.token, "Content-Type": "application/json" },
});
if (!res.ok) return 0;
const body: any = await res.json().catch(() => ({}));
return body?.pageInfo?.totalRows ?? (body?.list?.length ?? 0);
}
/** Delete every audit row in the current table. Returns the count deleted. */
async deleteAll(): Promise<number> {
if (!this.tableId) return 0;
let total = 0;
for (;;) {
const url = new URL(this.url);
url.searchParams.set("limit", "1000");
url.searchParams.set("fields", "Id");
const res = await fetch(url.toString(), {
headers: { "xc-token": this.token, "Content-Type": "application/json" },
});
if (!res.ok) break;
const body: any = await res.json().catch(() => ({}));
const list = body?.list ?? [];
if (!list.length) break;
await fetch(this.url, {
method: "DELETE",
headers: { "xc-token": this.token, "Content-Type": "application/json" },
body: JSON.stringify(list.map((r: any) => ({ Id: r.Id }))),
});
total += list.length;
}
return total;
}
async log(entry: AuditEntry): Promise<void> { async log(entry: AuditEntry): Promise<void> {
if (!this.tableId) return; if (!this.tableId) return;
const sign = entry.people >= 0 ? "+" : ""; const sign = entry.people >= 0 ? "+" : "";
@ -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<AuditRow[]> {
if (!this.tableId) return [];
const out: AuditRow[] = [];
const pageSize = 1000;
let offset = 0;
for (;;) {
const url = new URL(this.url);
url.searchParams.set("limit", String(pageSize));
url.searchParams.set("offset", String(offset));
const res = await fetch(url.toString(), {
headers: { "xc-token": this.token, "Content-Type": "application/json" },
});
if (!res.ok) break;
const body: any = await res.json().catch(() => ({}));
const list = body?.list ?? [];
out.push(...list.map((r: any) => this.mapRow(r)));
if (!list.length || body?.pageInfo?.isLastPage || list.length < pageSize) break;
offset += pageSize;
if (offset > 200000) break;
}
return out;
}
/** Recent entries, newest first, optionally filtered to one code. */ /** Recent entries, newest first, optionally filtered to one code. */
async recent(opts: { code?: string; limit?: number } = {}): Promise<AuditRow[]> { async recent(opts: { code?: string; limit?: number } = {}): Promise<AuditRow[]> {
if (!this.tableId) return []; if (!this.tableId) return [];

View file

@ -1,17 +1,5 @@
import type { Config } from "../config.js"; import type { Config } from "../config.js";
export interface DonorSearchResult {
name: string;
bearName: string;
email: string;
altEmail: string;
phone: string;
address: string;
lifetime: number | null;
tags: string[];
source: "master" | "transactions";
}
export interface DonorLookup { export interface DonorLookup {
found: boolean; found: boolean;
email: string; email: string;
@ -169,67 +157,6 @@ export class DonorService {
}; };
} }
/**
* Admin-only free-text donor search across the master list + transaction
* tables. Matches the query (substring, case-insensitive) against any
* name / email / phone / address / bear-name column each table exposes
* columns are discovered from a sample row so it adapts to the schema.
* Results are de-duped by email (then name). PRIVACY: gate this to admins.
*/
async search(rawQuery: string, limit = 40): Promise<DonorSearchResult[]> {
const q = rawQuery.trim();
if (!q || !this.enabled) return [];
const tables: { id: string | null; source: "master" | "transactions" }[] = [
{ id: this.masterId, source: "master" },
{ id: this.onlineId, source: "transactions" },
{ id: this.offlineId, source: "transactions" },
];
const out = new Map<string, DonorSearchResult>();
for (const t of tables) {
if (!t.id || out.size >= limit) continue;
let rows: any[];
try {
rows = await this.searchTable(t.id, q, limit);
} catch {
continue; // a table without matching columns / transient error — skip
}
for (const r of rows) {
const res = mapDonorRow(r, t.source);
const key = (res.email || res.name || JSON.stringify(r)).toLowerCase();
const existing = out.get(key);
// Prefer the master-list record (richer) when the same donor appears twice.
if (!existing || (existing.source === "transactions" && res.source === "master")) {
out.set(key, existing ? { ...res, lifetime: res.lifetime ?? existing.lifetime } : res);
}
if (out.size >= limit) break;
}
}
return [...out.values()].slice(0, limit);
}
private colCache = new Map<string, string[]>();
/** Discover the text columns worth searching (name/contact) from a sample row. */
private async searchableColumns(tableId: string): Promise<string[]> {
const cached = this.colCache.get(tableId);
if (cached) return cached;
const sample = await this.list(tableId, "", 1);
const keys = sample.length ? Object.keys(sample[0]) : [];
const want = /name|email|phone|mobile|cell|address|street|city|state|zip|postal|province|country|bear/i;
const skip = /[(),]/; // field names with filter-grammar chars can't be queried
const cols = keys.filter((k) => want.test(k) && !skip.test(k));
this.colCache.set(tableId, cols);
return cols;
}
private async searchTable(tableId: string, q: string, limit: number): Promise<any[]> {
const cols = await this.searchableColumns(tableId);
if (!cols.length) return [];
const esc = q.replace(/[(),]/g, " ");
const where = cols.map((c) => `(${c},like,%${esc}%)`).join("~or");
return this.list(tableId, where, limit);
}
/** /**
* Total Paid donations for an email on/after `cutoff`, summed from the * Total Paid donations for an email on/after `cutoff`, summed from the
* transaction tables (the only dated source). Used for ticket-voucher * transaction tables (the only dated source). Used for ticket-voucher
@ -256,40 +183,6 @@ function num(v: unknown): number {
return Number.isFinite(n) ? n : 0; return Number.isFinite(n) ? n : 0;
} }
/** First non-empty value whose column name matches `rx`. */
function pick(row: any, rx: RegExp): string {
for (const k of Object.keys(row)) if (rx.test(k) && row[k] != null && row[k] !== "") return String(row[k]);
return "";
}
/** Join all non-empty values whose column name matches `rx` (e.g. address parts). */
function pickAll(row: any, rx: RegExp): string {
const parts: string[] = [];
for (const k of Object.keys(row)) if (rx.test(k) && row[k] != null && row[k] !== "") parts.push(String(row[k]));
return [...new Set(parts)].join(", ");
}
function mapDonorRow(r: any, source: "master" | "transactions"): DonorSearchResult {
const name =
r["Display Name"] ||
r["Name"] ||
[r["First Name"], r["Last Name"]].filter(Boolean).join(" ") ||
r["Bear Name"] ||
pick(r, /name/i) ||
"";
const lifetimeRaw = r["Total Donations"];
return {
name: String(name),
bearName: String(r["Bear Name"] ?? ""),
email: String(r["Email"] ?? pick(r, /email/i)),
altEmail: String(r["Alternate Email"] ?? ""),
phone: pick(r, /phone|mobile|cell/i),
address: pickAll(r, /address|street|city|state|zip|postal|province|country/i),
lifetime: lifetimeRaw !== undefined && lifetimeRaw !== null && lifetimeRaw !== "" ? num(lifetimeRaw) : null,
tags: splitTags(r["Tags"]),
source,
};
}
// Count a transaction unless it's explicitly not paid (refunded/failed/pending). // Count a transaction unless it's explicitly not paid (refunded/failed/pending).
function isPaid(row: any): boolean { function isPaid(row: any): boolean {
const s = String(row["Payment Status"] ?? "").trim(); const s = String(row["Payment Status"] ?? "").trim();

View file

@ -9,43 +9,6 @@ export interface TicketEmail {
code: string; code: string;
quantity: number; quantity: number;
qrPng: Buffer; qrPng: Buffer;
iceBags?: number; // for add-on-only (ticketless) orders
}
/** Describe what a purchase is good for — handles ticketless (ice/UTV) orders. */
function purchaseSummary(mail: TicketEmail): { lead: string; footer: string } {
const qty = mail.quantity;
if (qty > 0) {
const w = qty === 1 ? "ticket" : "tickets";
return {
lead: `This email is your ticket for <strong>${qty} ${w}</strong> to the 2026 Beartaria Campgrounds event. Show the QR code below at the gate.`,
footer: `Each ticket admits one entry. This code is good for all ${qty} ${w} on one purchase — gate staff will check people in against it. See you there!`,
};
}
const bags = mail.iceBags ?? 0;
const extra = bags > 0 ? ` It includes <strong>${bags} bag${bags === 1 ? "" : "s"} of ice</strong>.` : "";
return {
lead: `This email is your gate pass for your 2026 Beartaria Campgrounds purchase (add-ons such as ice, parking, or an ATV/UTV).${extra} Show the QR code below at the gate.`,
footer: `Show this QR at the gate and staff will redeem your add-ons against it. See you there!`,
};
}
/** Plain-text version of purchaseSummary (no HTML tags). */
function purchaseSummaryText(mail: TicketEmail): { lead: string; footer: string } {
const qty = mail.quantity;
if (qty > 0) {
const w = qty === 1 ? "ticket" : "tickets";
return {
lead: `This is your ticket for ${qty} ${w} to the 2026 Beartaria Campgrounds event.`,
footer: `It is good for all ${qty} ${w} on this purchase.`,
};
}
const bags = mail.iceBags ?? 0;
const extra = bags > 0 ? ` It includes ${bags} bag${bags === 1 ? "" : "s"} of ice.` : "";
return {
lead: `This is your gate pass for your purchase (add-ons such as ice, parking, or an ATV/UTV).${extra}`,
footer: `Show this code at the gate and staff will redeem your add-ons against it.`,
};
} }
export class MailerSendError extends Error { export class MailerSendError extends Error {
@ -131,7 +94,8 @@ function esc(s: string): string {
function renderHtml(mail: TicketEmail): string { function renderHtml(mail: TicketEmail): string {
const name = esc(mail.toName || ""); const name = esc(mail.toName || "");
const { lead, footer } = purchaseSummary(mail); const qty = mail.quantity;
const ticketWord = qty === 1 ? "ticket" : "tickets";
return `<!doctype html> return `<!doctype html>
<html> <html>
<body style="margin:0;padding:0;background:#0f1a12;font-family:Arial,Helvetica,sans-serif;color:#0f1a12;"> <body style="margin:0;padding:0;background:#0f1a12;font-family:Arial,Helvetica,sans-serif;color:#0f1a12;">
@ -144,7 +108,9 @@ function renderHtml(mail: TicketEmail): string {
<tr><td style="padding:24px;"> <tr><td style="padding:24px;">
<p style="margin:0 0 12px;font-size:16px;">Hi ${name || "there"},</p> <p style="margin:0 0 12px;font-size:16px;">Hi ${name || "there"},</p>
<p style="margin:0 0 16px;font-size:15px;line-height:1.5;"> <p style="margin:0 0 16px;font-size:15px;line-height:1.5;">
Thank you for your purchase! ${lead} Thank you for your purchase! This email is your ticket for
<strong>${qty} ${ticketWord}</strong> to the 2026 Beartaria Campgrounds event.
Show the QR code below at the gate.
</p> </p>
<div style="text-align:center;margin:20px 0;"> <div style="text-align:center;margin:20px 0;">
<img src="cid:qrcode" alt="Ticket QR code" width="280" height="280" <img src="cid:qrcode" alt="Ticket QR code" width="280" height="280"
@ -155,7 +121,8 @@ function renderHtml(mail: TicketEmail): string {
${esc(mail.code)} ${esc(mail.code)}
</p> </p>
<p style="margin:0;font-size:13px;color:#777;line-height:1.5;"> <p style="margin:0;font-size:13px;color:#777;line-height:1.5;">
${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!
</p> </p>
</td></tr> </td></tr>
</table> </table>
@ -167,16 +134,17 @@ function renderHtml(mail: TicketEmail): string {
} }
function renderText(mail: TicketEmail): string { function renderText(mail: TicketEmail): string {
const { lead, footer } = purchaseSummaryText(mail); const qty = mail.quantity;
const ticketWord = qty === 1 ? "ticket" : "tickets";
return [ return [
`Hi ${mail.toName || "there"},`, `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}`, `Your ticket code: ${mail.code}`,
"", "",
"Show this code (or the QR code in the HTML version of this email) at the gate.", "Show this code (or the QR code in the HTML version of this email) at the gate.",
footer, `It is good for all ${qty} ${ticketWord} on this purchase.`,
"", "",
"See you there!", "See you there!",
"Beartaria Campgrounds · beartariacampgrounds.com", "Beartaria Campgrounds · beartariacampgrounds.com",

View file

@ -8,24 +8,16 @@ import { COL, type NocoRecord } from "../fields.js";
export class NocoDBClient { export class NocoDBClient {
private readonly base: string; private readonly base: string;
private readonly token: string; private readonly token: string;
private _tableId: string; private readonly tableId: string;
constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_TABLE_ID">) { constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_TABLE_ID">) {
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, ""); this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
this.token = cfg.NOCODB_API_TOKEN; this.token = cfg.NOCODB_API_TOKEN;
this._tableId = cfg.NOCODB_TABLE_ID; this.tableId = cfg.NOCODB_TABLE_ID;
}
/** The table this client currently reads/writes (switchable at runtime). */
get tableId(): string {
return this._tableId;
}
setTableId(id: string): void {
this._tableId = id;
} }
private get recordsUrl(): string { private get recordsUrl(): string {
return `${this.base}/api/v2/tables/${this._tableId}/records`; return `${this.base}/api/v2/tables/${this.tableId}/records`;
} }
private async request(url: string, init: RequestInit = {}): Promise<any> { private async request(url: string, init: RequestInit = {}): Promise<any> {
@ -84,22 +76,6 @@ export class NocoDBClient {
return this.list(`(${COL.name},like,%${q}%)~or(${COL.email},like,%${q}%)`, limit); return this.list(`(${COL.name},like,%${q}%)~or(${COL.email},like,%${q}%)`, limit);
} }
/** Every order for an exact email (case-insensitive). */
async findByEmail(email: string, limit = 1000): Promise<NocoRecord[]> {
const rows = await this.list(`(${COL.email},eq,${escapeValue(email)})`, limit);
// Belt-and-suspenders: some NocoDB backends do a case-sensitive eq, so
// narrow/confirm against a lowercased compare in JS.
const target = email.trim().toLowerCase();
const exact = rows.filter((r) => String(r[COL.email] ?? "").trim().toLowerCase() === target);
return exact.length ? exact : rows;
}
/** Sum of ticket vouchers a donor has already consumed across their orders. */
async vouchersUsedByEmail(email: string): Promise<number> {
const rows = await this.findByEmail(email);
return rows.reduce((sum, r) => sum + (Number(r[COL.vouchers]) || 0), 0);
}
async create(fields: Record<string, unknown>): Promise<NocoRecord> { async create(fields: Record<string, unknown>): Promise<NocoRecord> {
const body = await this.request(this.recordsUrl, { const body = await this.request(this.recordsUrl, {
method: "POST", method: "POST",
@ -126,67 +102,6 @@ export class NocoDBClient {
return (Array.isArray(body) ? body[0] : body) as NocoRecord; return (Array.isArray(body) ? body[0] : body) as NocoRecord;
} }
/** Fetch every record in the table, paginating. */
async all(): Promise<NocoRecord[]> {
const out: NocoRecord[] = [];
const pageSize = 1000;
let offset = 0;
for (;;) {
const url = new URL(this.recordsUrl);
url.searchParams.set("limit", String(pageSize));
url.searchParams.set("offset", String(offset));
const body = await this.request(url.toString());
const list = (body?.list ?? []) as NocoRecord[];
out.push(...list);
const info = body?.pageInfo;
if (!list.length || info?.isLastPage || list.length < pageSize) break;
offset += pageSize;
if (offset > 200000) break; // safety
}
return out;
}
/** Total record count in the current table (cheap — reads pageInfo). */
async count(): Promise<number> {
const url = new URL(this.recordsUrl);
url.searchParams.set("limit", "1");
const body = await this.request(url.toString());
return body?.pageInfo?.totalRows ?? (body?.list?.length ?? 0);
}
/** Delete every record in the current table (paginated bulk delete). Returns
* the number deleted. Used by the admin "wipe slate" action. */
async deleteAll(): Promise<number> {
let total = 0;
for (;;) {
const rows = await this.list("", 1000);
if (!rows.length) break;
const ids = rows.map((r) => ({ Id: (r as any).Id }));
await this.request(this.recordsUrl, { method: "DELETE", body: JSON.stringify(ids) });
total += rows.length;
}
return total;
}
/** Reachability + primary-key probe for a candidate table id (admin switch).
* Returns { ok, hasIdPk }. hasIdPk is false only if rows exist without an Id. */
async probeTable(tableId: string): Promise<{ ok: boolean; hasIdPk: boolean; status: number }> {
const url = new URL(`${this.base}/api/v2/tables/${tableId}/records`);
url.searchParams.set("limit", "1");
try {
const res = await fetch(url.toString(), {
headers: { "xc-token": this.token, "Content-Type": "application/json" },
});
if (!res.ok) return { ok: false, hasIdPk: false, status: res.status };
const body: any = await res.json().catch(() => ({}));
const list = body?.list ?? [];
const hasIdPk = list.length === 0 || "Id" in list[0];
return { ok: true, hasIdPk, status: 200 };
} catch {
return { ok: false, hasIdPk: false, status: 0 };
}
}
/** Cheap connectivity probe for healthchecks. */ /** Cheap connectivity probe for healthchecks. */
async ping(): Promise<boolean> { async ping(): Promise<boolean> {
const url = new URL(this.recordsUrl); const url = new URL(this.recordsUrl);

View file

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

View file

@ -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<Stats> {
const now = Date.now();
if (!force && cache && now - cache.at < TTL_MS) return cache.data;
const records = await ctx.nocodb.all();
const bagsPerTicket = ctx.config.ICE_BAGS_PER_TICKET || 3;
let total = 0,
redeemed = 0,
iceTotal = 0,
iceRedeemed = 0;
let adults = 0,
youth = 0,
kids12 = 0,
kids9 = 0,
kids4 = 0;
let carParking = 0,
rvParking = 0,
utv = 0,
donorOrders = 0,
members = 0,
vouchers = 0;
const typeMap = new Map<string, { count: number; total: number; redeemed: number }>();
const compByCreator = new Map<string, number>();
let compTotal = 0;
for (const r of records as NocoRecord[]) {
const v = toView(r);
if (v.ticketType) {
compTotal += 1;
const who = v.createdBy || "(unknown)";
compByCreator.set(who, (compByCreator.get(who) ?? 0) + 1);
}
total += v.total;
redeemed += v.redeemed;
iceTotal += v.ice.total;
iceRedeemed += v.ice.redeemed;
adults += toNumber(r[COL.adults]);
youth += toNumber(r[COL.youth]);
kids12 += toNumber(r[COL.kids12]);
kids9 += toNumber(r[COL.kids9]);
kids4 += toNumber(r[COL.kids4]);
const t = v.ticketType || "Regular";
const e = typeMap.get(t) ?? { count: 0, total: 0, redeemed: 0 };
e.count += 1;
e.total += v.total;
e.redeemed += v.redeemed;
typeMap.set(t, e);
if (v.extras.carParking) carParking += 1;
if (v.extras.rvParking) rvParking += 1;
if (v.extras.utv) utv += 1;
if (v.extras.isDonor) donorOrders += 1;
if (v.extras.donorTier === "member") members += 1;
vouchers += v.extras.vouchers;
}
// Operator activity + check-in timeline from the audit log.
const audit = await ctx.audit.all().catch(() => []);
const opMap = new Map<string, { checkins: number; ice: number; undos: number }>();
const hourMap = new Map<string, number>();
for (const a of audit) {
if (a.operator) {
const o = opMap.get(a.operator) ?? { checkins: 0, ice: 0, undos: 0 };
if (a.action === "check-in") o.checkins += a.people;
else if (a.action === "undo") o.undos += -a.people;
else if (a.action === "ice") o.ice += a.people;
opMap.set(a.operator, o);
}
if (a.action === "check-in" && a.people > 0 && a.at) {
const hour = String(a.at).slice(0, 13); // YYYY-MM-DDTHH
hourMap.set(hour, (hourMap.get(hour) ?? 0) + a.people);
}
}
const data: Stats = {
orders: records.length,
tickets: { total, redeemed, remaining: Math.max(0, total - redeemed), pct: total ? Math.round((redeemed / total) * 100) : 0 },
people: { adults, youth, kids12, kids9, kids4Free: kids4 },
ice: {
total: iceTotal,
redeemed: iceRedeemed,
remaining: Math.max(0, iceTotal - iceRedeemed),
pct: iceTotal ? Math.round((iceRedeemed / iceTotal) * 100) : 0,
ticketsSold: Math.round(iceTotal / bagsPerTicket),
},
types: [...typeMap.entries()]
.map(([type, e]) => ({ type, ...e }))
.sort((a, b) => b.total - a.total),
donors: { orders: donorOrders, members, vouchers },
extras: { carParking, rvParking, utv },
comps: {
total: compTotal,
byCreator: [...compByCreator.entries()]
.map(([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count),
},
operators: [...opMap.entries()]
.map(([name, o]) => ({ name, ...o }))
.sort((a, b) => b.checkins - a.checkins),
checkinsByHour: [...hourMap.entries()]
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
.slice(-12)
.map(([hour, count]) => ({ hour, count })),
generatedAt: new Date().toISOString(),
};
cache = { at: now, data };
return data;
}

View file

@ -41,17 +41,6 @@ export class FakeNocoDB {
); );
} }
async findByEmail(email: string): Promise<NocoRecord[]> {
await this.delay();
const target = email.trim().toLowerCase();
return this.rows.filter((r) => String(r[COL.email] ?? "").trim().toLowerCase() === target);
}
async vouchersUsedByEmail(email: string): Promise<number> {
const rows = await this.findByEmail(email);
return rows.reduce((sum, r) => sum + (Number(r[COL.vouchers]) || 0), 0);
}
async create(fields: Record<string, unknown>): Promise<NocoRecord> { async create(fields: Record<string, unknown>): Promise<NocoRecord> {
await this.delay(); await this.delay();
const rec = { Id: this.nextId++, ...fields } as NocoRecord; const rec = { Id: this.nextId++, ...fields } as NocoRecord;

View file

@ -2,16 +2,16 @@ import { describe, it, expect } from "vitest";
import { computeTotal, toView, COL } from "../fields.js"; import { computeTotal, toView, COL } from "../fields.js";
describe("computeTotal", () => { describe("computeTotal", () => {
it("sums adults + youth 13-16 only; all kids 12 & under are free", () => { it("sums adults + youth + kids 10-12 + kids 5-9, excluding kids 0-4 (free)", () => {
const rec = { const rec = {
Id: 1, Id: 1,
[COL.adults]: 2, [COL.adults]: 2,
[COL.youth]: 1, [COL.youth]: 1,
[COL.kids12]: 1, // free, not counted [COL.kids12]: 1,
[COL.kids9]: 1, // free, not counted [COL.kids9]: 1,
[COL.kids4]: 3, // free, not counted [COL.kids4]: 3, // free, not counted
}; };
expect(computeTotal(rec)).toBe(3); expect(computeTotal(rec)).toBe(5);
}); });
it("coerces string counts and treats blanks as 0", () => { it("coerces string counts and treats blanks as 0", () => {
@ -47,7 +47,7 @@ describe("toView", () => {
expect(v.extras.rvParking).toBe(false); expect(v.extras.rvParking).toBe(false);
expect(v.extras.donorTier).toBe("member"); expect(v.extras.donorTier).toBe("member");
expect(v.extras.vouchers).toBe(2); expect(v.extras.vouchers).toBe(2);
expect(v.extras.freeKids).toBe(1); expect(v.extras.freeUnder5).toBe(1);
expect(v.ages.find((a) => a.bracket === "Kids 0-4")?.free).toBe(true); expect(v.ages.find((a) => a.bracket === "Kids 0-4")?.free).toBe(true);
}); });
}); });

View file

@ -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<string, any>, slots: string[]): number {
const holders = slots.map((b) => nameGroup(body, b)).filter(Boolean);
return Math.max(1, holders.length);
}
describe("nameGroup", () => {
it("reads flattened bracket keys", () => {
const body = { "names[first_name]": "Joe", "names[last_name]": "Taco" };
expect(nameGroup(body, "names")).toBe("Joe Taco");
});
it("reads a nested object and includes the middle name", () => {
const body = { names: { first_name: "Ann", middle_name: "B", last_name: "Cole" } };
expect(nameGroup(body, "names")).toBe("Ann B Cole");
});
it("returns empty string when the group is blank", () => {
expect(nameGroup({}, "names_1")).toBe("");
});
});
describe("food vendor pass counting", () => {
it("food booth with two named holders gets 2 passes", () => {
const body = {
input_text: "Joe's Tacos",
"names[first_name]": "Joe",
"names[last_name]": "Taco",
"names_1[first_name]": "Jane",
"names_1[last_name]": "Taco",
};
expect(passCount(body, FOOD_SLOTS)).toBe(2);
});
it("food booth with only the first name gets 1 pass", () => {
const body = { input_text: "Solo BBQ", "names[first_name]": "Sam", "names[last_name]": "Que" };
expect(passCount(body, FOOD_SLOTS)).toBe(1);
});
it("booth with no names still gets 1 pass", () => {
expect(passCount({ input_text: "Nameless Booth" }, FOOD_SLOTS)).toBe(1);
});
});
describe("iceBagsFromPayment", () => {
it("reads '(N total bags)' from the real form label", () => {
expect(iceBagsFromPayment("One Ice ticket good for one bag per day (3 total bags)", ICE)).toBe(3);
expect(iceBagsFromPayment("Two Ice tickets good for one bag per day (6 total bags)", ICE)).toBe(6);
});
it("falls back to a worded ice-ticket count", () => {
expect(iceBagsFromPayment("Two Ice tickets", ICE)).toBe(6); // 2 × 3
expect(iceBagsFromPayment("Four Ice tickets", ICE)).toBe(12);
});
it("falls back to a dollar total at the ticket price", () => {
expect(iceBagsFromPayment("$40.00", ICE)).toBe(6); // 2 tickets × 3
expect(iceBagsFromPayment(20, ICE)).toBe(3); // 1 ticket × 3
});
it("treats a small plain count as ticket count", () => {
expect(iceBagsFromPayment(2, ICE)).toBe(6); // 2 tickets × 3
});
it("is 0 for blank / no ice", () => {
expect(iceBagsFromPayment("", ICE)).toBe(0);
expect(iceBagsFromPayment(undefined, ICE)).toBe(0);
expect(iceBagsFromPayment(0, ICE)).toBe(0);
});
});
describe("readDonor", () => {
it("treats donor_tier=member as a donor", () => {
expect(readDonor({ donor_tier: "member" })).toEqual({ isDonor: true, donorTier: "member" });
});
it("honors the donor_eligible hidden flag", () => {
expect(readDonor({ donor_eligible: "true" }).isDonor).toBe(true);
});
it("is not a donor when nothing indicates it", () => {
expect(readDonor({ input_radio: "No" })).toEqual({ isDonor: false, donorTier: "" });
});
});
describe("qty / selected / addressLine", () => {
it("parses money strings and nested quantities", () => {
expect(qty("$40.00")).toBe(40);
expect(qty({ quantity: 2 })).toBe(2);
expect(qty("")).toBe(0);
});
it("selected() treats $0.00 / no / blank as unselected", () => {
expect(selected("$0.00")).toBe(false);
expect(selected("No")).toBe(false);
expect(selected("Yes")).toBe(true);
});
it("flattens a compound address", () => {
expect(addressLine({ address_line_1: "1 Main", city: "Boise", state: "ID" })).toBe("1 Main, Boise, ID");
});
});

View file

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

View file

@ -131,7 +131,6 @@ export interface WebhookInput {
adultNames?: string[]; adultNames?: string[];
email: string; email: string;
ticketType?: string; // Guest/Worker/Performer/Volunteer/Speaker for portal comps ticketType?: string; // Guest/Worker/Performer/Volunteer/Speaker for portal comps
createdBy?: string; // gate-staff name who issued a comp
address?: string; address?: string;
isDonor?: boolean; isDonor?: boolean;
donorTier?: string; donorTier?: string;
@ -181,7 +180,6 @@ export async function createTicket(
}; };
if (input.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n"); if (input.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n");
if (input.ticketType) fields[COL.ticketType] = input.ticketType; if (input.ticketType) fields[COL.ticketType] = input.ticketType;
if (input.createdBy) fields[COL.createdBy] = input.createdBy;
if (input.address !== undefined) fields[COL.address] = input.address; if (input.address !== undefined) fields[COL.address] = input.address;
if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor; if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor;
if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier; if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier;

View file

@ -3,9 +3,7 @@ services:
build: . build: .
image: camptickets:latest image: camptickets:latest
container_name: camptickets container_name: camptickets
# Single source of truth for secrets — edit backend/.env, then env_file: .env
# `docker compose up -d`. (Was ./.env; consolidated to avoid a stale copy.)
env_file: backend/.env
environment: environment:
# Container always listens on 8080 internally; the host mapping below is # Container always listens on 8080 internally; the host mapping below is
# what nginx proxies to. Keep this fixed regardless of .env PORT. # what nginx proxies to. Keep this fixed regardless of .env PORT.
@ -19,11 +17,4 @@ services:
# host.docker.internal resolves to the host gateway. # host.docker.internal resolves to the host gateway.
extra_hosts: extra_hosts:
- "host.docker.internal:host-gateway" - "host.docker.internal:host-gateway"
# Small writable volume for runtime state (the active event-table override
# set from the admin area), so it survives redeploys.
volumes:
- camptickets-data:/data
restart: unless-stopped restart: unless-stopped
volumes:
camptickets-data:

View file

@ -14,32 +14,16 @@ ticketing backend.
GET https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<email> GET https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<email>
``` ```
Returns the **remaining** free-ticket count — never names or dollar amounts: Returns only the count — never names or dollar amounts:
```json ```json
{ "vouchers": 1, "entitled": 2, "used": 1, "remaining": 1 } { "vouchers": 0 } // or 1, or 2
``` ```
- `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`). - `key` = the value of `PUBLIC_LOOKUP_SECRET` (set in the backend `.env`).
- `email` = the donor's email (URL-encoded). - `email` = the donor's email (URL-encoded).
- Rate-limited (30 requests / minute / IP) and CORS-restricted to - Rate-limited (30 requests / minute / IP) and CORS-restricted to
`PUBLIC_LOOKUP_ORIGIN` (`tickets.` + `vendors.beartariacampgrounds.com`). `PUBLIC_LOOKUP_ORIGIN` (default `https://tickets.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 > 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 > security** — it only gates a 0/1/2 count. Rotate it by changing
@ -123,8 +107,9 @@ field `free_tickets` you can use for conditional logic or to cap a quantity.
``` ```
curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<a-real-donor-email>" curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=<SECRET>&email=<a-real-donor-email>"
# entitled 2, none used yet -> {"vouchers":2,"entitled":2,"used":0,"remaining":2} # >= $1000 since cutoff -> {"vouchers":2}
# after a checkout using 2 -> {"vouchers":0,"entitled":2,"used":2,"remaining":0} # >= $400 since cutoff -> {"vouchers":1}
# otherwise -> {"vouchers":0}
``` ```
Related: [`fluentforms-donor-discount.md`](./fluentforms-donor-discount.md) — the Related: [`fluentforms-donor-discount.md`](./fluentforms-donor-discount.md) — the

View file

@ -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 <TICKETS_TABLE_ID> [AUDIT_TABLE_ID]
#
# FIRST create the new table(s): in the NocoDB UI, DUPLICATE the current table
# with "structure only" (no records). That preserves every column AND the Id
# primary key — critical, because updates against a table with no primary key
# would hit every row. Then grab the new table id from its URL/API and pass it
# here. (The app also fail-safes: it refuses to update a row that has no Id.)
#
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
ENV_FILE="$ROOT/backend/.env"
CONTAINER="${CONTAINER:-camptickets}"
NEW_TICKETS="${1:-}"
NEW_AUDIT="${2:-}"
[ -n "$NEW_TICKETS" ] || { echo "Usage: $0 <TICKETS_TABLE_ID> [AUDIT_TABLE_ID]" >&2; exit 1; }
get() { grep -E "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2-; }
BASE_URL="$(get NOCODB_BASE_URL)"
TOKEN="$(get NOCODB_API_TOKEN)"
CUR_TICKETS="$(get NOCODB_TABLE_ID)"
CUR_AUDIT="$(get NOCODB_AUDIT_TABLE_ID)"
# Validate a table is reachable and (if it has rows) exposes an Id primary key.
check() {
local table="$1" tmp http
tmp="$(mktemp)"
http="$(curl -s -o "$tmp" -w '%{http_code}' -H "xc-token: $TOKEN" \
"$BASE_URL/api/v2/tables/$table/records?limit=1")"
if [ "$http" != "200" ]; then
echo "$table not reachable (HTTP $http)"; rm -f "$tmp"; return 1
fi
if ! python3 -c 'import sys,json; l=json.load(open(sys.argv[1]))["list"]; sys.exit(0 if (not l or "Id" in l[0]) else 1)' "$tmp"; then
echo "$table has rows without an Id primary key — refusing"; rm -f "$tmp"; return 1
fi
rm -f "$tmp"; echo "$table reachable"
}
echo "Validating new table(s) on $BASE_URL ..."
check "$NEW_TICKETS" || exit 1
[ -n "$NEW_AUDIT" ] && { check "$NEW_AUDIT" || exit 1; }
BK="$ENV_FILE.bak.$(date +%Y%m%d-%H%M%S)"
cp "$ENV_FILE" "$BK"
echo "Backed up env -> $BK"
echo "Switching tables:"
echo " tickets: $CUR_TICKETS -> $NEW_TICKETS"
sed -i -E "s|^NOCODB_TABLE_ID=.*|NOCODB_TABLE_ID=$NEW_TICKETS|" "$ENV_FILE"
if [ -n "$NEW_AUDIT" ]; then
echo " audit: $CUR_AUDIT -> $NEW_AUDIT"
sed -i -E "s|^NOCODB_AUDIT_TABLE_ID=.*|NOCODB_AUDIT_TABLE_ID=$NEW_AUDIT|" "$ENV_FILE"
else
echo " audit: unchanged ($CUR_AUDIT) — pass a second arg to switch it too"
fi
echo "Restarting $CONTAINER ..."
( cd "$ROOT" && docker compose up -d --force-recreate >/dev/null )
sleep 3
echo "Now active:"
echo " NOCODB_TABLE_ID=$(get NOCODB_TABLE_ID)"
echo " NOCODB_AUDIT_TABLE_ID=$(get NOCODB_AUDIT_TABLE_ID)"
echo "Old tickets table $CUR_TICKETS kept intact. (env backup: $BK)"

View file

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