Compare commits

..

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

33 changed files with 464 additions and 1587 deletions

View file

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

View file

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

View file

@ -209,7 +209,7 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti
if (e.rvParking) tags.push("🚐 RV");
if (e.utv) tags.push("🏍️ UTV");
if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total}`);
if (e.freeKids > 0) tags.push(`👶 ${e.freeKids} free kids`);
if (e.freeUnder5 > 0) tags.push(`👶 ${e.freeUnder5} free`);
return (
<View style={styles.card}>

239
app/app/comp.tsx Normal file
View file

@ -0,0 +1,239 @@
import { useState } from "react";
import {
StyleSheet,
View,
Text,
TextInput,
Pressable,
ScrollView,
Image,
KeyboardAvoidingView,
Platform,
} from "react-native";
import { router } from "expo-router";
import { SafeAreaView } from "react-native-safe-area-context";
import { portalVerify, portalCreate, AuthError, type PortalTicket } from "../lib/api";
import { useAuth } from "../lib/auth";
import { useMenu } from "../lib/menu";
import { theme } from "../lib/theme";
const TYPES = ["Guest", "Worker", "Performer", "Volunteer", "Speaker"];
const TYPE_ICON: Record<string, string> = {
Guest: "🎫",
Worker: "🛠️",
Performer: "🎭",
Volunteer: "🙌",
Speaker: "🎤",
};
export default function CompScreen() {
const { operator } = useAuth();
const { open: openMenu } = useMenu();
const [password, setPassword] = useState("");
const [unlocked, setUnlocked] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [type, setType] = useState("Guest");
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [result, setResult] = useState<PortalTicket | null>(null);
async function unlock() {
if (!password || busy) return;
setBusy(true);
setError("");
try {
await portalVerify(password);
setUnlocked(true);
} catch (e: any) {
setError(e instanceof AuthError ? "Wrong password" : (e?.message ?? "Failed"));
} finally {
setBusy(false);
}
}
async function create() {
if (!name.trim() || !email.trim() || busy) return;
setBusy(true);
setError("");
try {
const r = await portalCreate({ password, name: name.trim(), email: email.trim(), type, createdBy: operator });
setResult(r);
setName("");
setEmail("");
} catch (e: any) {
if (e instanceof AuthError) {
setUnlocked(false); // password rotated — re-gate
setError("Password changed — unlock again.");
} else {
setError(e?.message ?? "Failed to create ticket");
}
} finally {
setBusy(false);
}
}
return (
<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}>Comp Tickets</Text>
<View style={{ width: 60 }} />
</View>
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === "ios" ? "padding" : undefined}>
<ScrollView contentContainerStyle={{ padding: 20, paddingBottom: 48 }}>
{!unlocked ? (
<View>
<Text style={styles.lead}>Entry-only tickets for workers &amp; guests. Enter the shared portal password.</Text>
<Text style={styles.label}>Portal password</Text>
<TextInput
style={styles.input}
secureTextEntry
value={password}
onChangeText={setPassword}
placeholder="Shared admin password"
placeholderTextColor={theme.textDim}
autoCapitalize="none"
autoCorrect={false}
returnKeyType="go"
onSubmitEditing={unlock}
/>
{!!error && <Text style={styles.error}>{error}</Text>}
<Pressable style={[styles.btn, (busy || !password) && styles.btnOff]} onPress={unlock} disabled={busy || !password}>
<Text style={styles.btnText}>{busy ? "Checking…" : "Unlock"}</Text>
</Pressable>
</View>
) : (
<View>
<Text style={styles.label}>Ticket type</Text>
<View style={styles.types}>
{TYPES.map((t) => (
<Pressable
key={t}
style={[styles.typePill, type === t && styles.typePillOn]}
onPress={() => setType(t)}
>
<Text style={[styles.typePillText, type === t && styles.typePillTextOn]}>
{(TYPE_ICON[t] ?? "🎫") + " " + t}
</Text>
</Pressable>
))}
</View>
<Text style={styles.label}>Full name</Text>
<TextInput
style={styles.input}
value={name}
onChangeText={setName}
placeholder="Attendee name"
placeholderTextColor={theme.textDim}
autoCapitalize="words"
/>
<Text style={styles.label}>Email</Text>
<TextInput
style={styles.input}
value={email}
onChangeText={setEmail}
placeholder="Where to send the ticket"
placeholderTextColor={theme.textDim}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
/>
{!!error && <Text style={styles.error}>{error}</Text>}
<Pressable
style={[styles.btn, (busy || !name.trim() || !email.trim()) && styles.btnOff]}
onPress={create}
disabled={busy || !name.trim() || !email.trim()}
>
<Text style={styles.btnText}>{busy ? "Creating…" : `Create ${type} ticket`}</Text>
</Pressable>
{result && (
<View style={styles.result}>
<Image source={{ uri: result.qr }} style={styles.qr} />
<Text style={styles.rcode}>{result.code}</Text>
<Text style={styles.rwho}>
{result.type} · {result.name}
</Text>
<Text style={styles.rmail}>
{result.emailSent ? "✓ Emailed the ticket" : "Email not sent — screenshot this QR"}
</Text>
</View>
)}
</View>
)}
</ScrollView>
</KeyboardAvoidingView>
</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: "600", width: 72 },
lead: { color: theme.textDim, fontSize: 15, lineHeight: 21, marginBottom: 8 },
label: { color: theme.textDim, fontSize: 13, marginTop: 16, marginBottom: 6 },
input: {
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
borderRadius: 12,
paddingHorizontal: 14,
paddingVertical: 14,
color: theme.text,
fontSize: 16,
},
error: { color: theme.dangerBright, marginTop: 12, fontSize: 14, fontWeight: "600" },
btn: {
backgroundColor: theme.successBright,
borderRadius: 13,
paddingVertical: 15,
alignItems: "center",
marginTop: 20,
},
btnOff: { opacity: 0.4 },
btnText: { color: "#06210f", fontSize: 18, fontWeight: "800" },
types: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
typePill: {
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
borderRadius: 999,
paddingHorizontal: 14,
paddingVertical: 9,
},
typePillOn: { backgroundColor: theme.primary, borderColor: theme.primary },
typePillText: { color: theme.textDim, fontSize: 14, fontWeight: "700" },
typePillTextOn: { color: "#fff" },
result: {
marginTop: 22,
alignItems: "center",
backgroundColor: theme.card,
borderWidth: 1,
borderColor: theme.cardBorder,
borderRadius: 16,
padding: 20,
},
qr: { width: 220, height: 220, backgroundColor: "#fff", borderRadius: 10 },
rcode: { color: theme.successBright, fontSize: 22, fontWeight: "800", letterSpacing: 2, marginTop: 12 },
rwho: { color: theme.text, fontSize: 16, marginTop: 4 },
rmail: { color: theme.textDim, fontSize: 13, marginTop: 8 },
});

View file

@ -109,9 +109,8 @@ export default function ScannerScreen() {
feedbackSuccess();
const remaining = mode === "ice" ? res.ticket.ice.remaining : res.ticket.remaining;
setTicket(res.ticket);
// Default to 1 (people usually grab ice a bag at a time); staff can bump
// the count up. Clamp to what's left so a 0-remaining ticket stays at 0.
setCount(Math.min(1, remaining));
// Ice: default to grabbing all remaining bags at once. Tickets: default 1.
setCount(mode === "ice" ? Math.max(1, remaining) : Math.min(1, remaining));
setPhase("confirm");
} catch (e: any) {
if (e?.name === "AuthError") return router.replace("/login");
@ -253,7 +252,6 @@ export default function ScannerScreen() {
<Text style={styles.counts}>
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
</Text>
<PartyPanel ticket={ticket} />
<AdultNames names={ticket.adultNames} />
<ExtrasRow ticket={ticket} />
</>
@ -348,7 +346,7 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) {
if (e.rvParking) tags.push("🚐 RV parking");
if (e.utv) tags.push("🏍️ UTV/ATV");
if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`);
if (e.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;
return (
<View style={styles.tags}>
@ -367,8 +365,6 @@ const TYPE_ICON: Record<string, string> = {
Performer: "🎭",
Volunteer: "🙌",
Speaker: "🎤",
"Food Vendor": "🍔",
Vendor: "🛒",
};
function TypeBadge({ type }: { type: string }) {
@ -395,45 +391,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({
ticket,
isIce,
@ -462,7 +419,6 @@ function ConfirmCard({
<Text style={styles.cardName}>{ticket.name}</Text>
<TypeBadge type={ticket.ticketType} />
<Text style={styles.cardCode}>{ticket.code}</Text>
{!isIce && <PartyPanel ticket={ticket} />}
<Text style={styles.cardCounts}>
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining
</Text>
@ -576,23 +532,6 @@ const styles = StyleSheet.create({
typeBadgeText: { color: "#fff", fontSize: 20, fontWeight: "900", letterSpacing: 1 },
namesBox: { marginTop: 12, alignItems: "center", gap: 3 },
nameLine: { color: "#fff", fontSize: 18, fontWeight: "600", textAlign: "center" },
party: {
alignSelf: "stretch",
backgroundColor: "#1d2a1f",
borderWidth: 2,
borderColor: theme.warn,
borderRadius: 14,
paddingVertical: 10,
paddingHorizontal: 10,
marginTop: 10,
marginBottom: 2,
},
partyRow: { flexDirection: "row", alignItems: "center", justifyContent: "center" },
partyCell: { flex: 1, alignItems: "center" },
partyNum: { color: theme.text, fontSize: 36, fontWeight: "900", lineHeight: 40 },
partyLbl: { color: theme.warn, fontSize: 11, fontWeight: "800", letterSpacing: 0.5, marginTop: 1, textAlign: "center", lineHeight: 13 },
partyDivider: { width: 1.5, height: 42, backgroundColor: theme.cardBorder },
partyDetail: { color: theme.textDim, fontSize: 12, textAlign: "center", marginTop: 8, fontWeight: "600" },
tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 },
tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" },

View file

@ -13,8 +13,6 @@ const TYPE_ICON: Record<string, string> = {
Performer: "🎭",
Volunteer: "🙌",
Speaker: "🎤",
"Food Vendor": "🍔",
Vendor: "🛒",
};
const MEDAL = ["🥇", "🥈", "🥉"];
@ -140,9 +138,9 @@ export default function StatsScreen() {
<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.adults} label="adults" />
<Tile value={stats.people.youth} label="youth 13-16" />
<Tile value={stats.people.kids12 + stats.people.kids9} label="kids 5-12" />
<Tile value={stats.people.kids4Free} label="under 5 (free)" />
</View>
</View>

View file

@ -4,12 +4,11 @@ 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" },
{ label: "Comp tickets", icon: "🎟️", route: "/comp", seg: "comp" },
{ label: "Admin lookup", icon: "🔎", route: "/admin", seg: "admin" },
];
export default function SideMenu({ visible, onClose }: { visible: boolean; onClose: () => void }) {

View file

@ -36,7 +36,7 @@ export interface TicketView {
isDonor: boolean;
donorTier: string;
vouchers: number;
freeKids: number;
freeUnder5: number;
};
ages: { bracket: string; count: number; free: boolean }[];
}
@ -252,56 +252,6 @@ export async function portalCreate(input: {
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<{
enabled: boolean;
entries: AuditEntry[];

View file

@ -10,10 +10,6 @@ const schema = z.object({
// Optional "2026 Ticket Audit Logs" table. If unset, audit logging is skipped.
NOCODB_AUDIT_TABLE_ID: z.string().optional(),
// Writable dir (mounted volume) for small runtime state — e.g. the active
// event table override set from the admin area, so it survives redeploys.
STATE_DIR: z.string().default("/data"),
// Donor tables for Banquet mode. If the master-list id is unset, banquet is
// disabled. Online/offline are used as a fallback when a donor is not in the
// master list.
@ -31,17 +27,7 @@ const schema = z.object({
// Disabled unless a secret is set. Returns only eligibility + tier, never
// names or dollar amounts. Rate-limited + CORS-restricted.
PUBLIC_LOOKUP_SECRET: z.string().optional(),
// Comma-separated allowlist of browser origins permitted to call the public
// lookups (the request's Origin is echoed back only if it matches one).
PUBLIC_LOOKUP_ORIGIN: z
.string()
.default("https://tickets.beartariacampgrounds.com,https://vendors.beartariacampgrounds.com")
.transform((s) =>
s
.split(",")
.map((o) => o.trim().replace(/\/+$/, ""))
.filter(Boolean),
),
PUBLIC_LOOKUP_ORIGIN: z.string().default("https://tickets.beartariacampgrounds.com"),
// Ticket-voucher entitlement: donations on/after VOUCHER_SINCE totalling
// >= 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 { AuditLogger } from "./services/audit.js";
import { DonorService } from "./services/donors.js";
import { loadActiveTables } from "./services/state.js";
/** Shared services wired once at startup and hung off the Fastify instance. */
export interface AppContext {
@ -17,23 +16,12 @@ export interface AppContext {
}
export function buildContext(config: Config): AppContext {
const nocodb = new NocoDBClient(config);
const audit = new AuditLogger(config);
// Apply a persisted "active event table" override (set from the admin area),
// so switching the event survives redeploys without editing .env.
const override = loadActiveTables(config.STATE_DIR);
if (override) {
nocodb.setTableId(override.ticketsTableId);
audit.setTableId(override.auditTableId ?? null);
}
return {
config,
nocodb,
nocodb: new NocoDBClient(config),
mailer: new Mailer(config),
queue: new RedeemQueue(),
audit,
audit: new AuditLogger(config),
donors: new DonorService(config),
};
}

View file

@ -51,17 +51,11 @@ function bool(v: unknown): boolean {
}
/**
* Total scannable (paid) tickets = adults + youth 13-16. Children 12 and under
* (kids 10-12 / 5-9 / 0-4) are admitted free and not counted; charging starts
* at age 13.
* Total scannable tickets = everyone except kids 0-4 (who are free):
* adults + youth (13-16) + kids 10-12 + kids 5-9.
*/
export function computeTotal(rec: NocoRecord): number {
return num(rec[COL.adults]) + num(rec[COL.youth]);
}
/** Free children (age 12 and under). */
export function freeKidsCount(rec: NocoRecord): number {
return num(rec[COL.kids12]) + num(rec[COL.kids9]) + num(rec[COL.kids4]);
return num(rec[COL.adults]) + num(rec[COL.youth]) + num(rec[COL.kids12]) + num(rec[COL.kids9]);
}
export function computeIceTotal(rec: NocoRecord): number {
@ -73,8 +67,8 @@ export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number;
return [
{ bracket: "Adults", count: num(rec[COL.adults]), free: false },
{ bracket: "Youth 13-16", count: num(rec[COL.youth]), free: false },
{ bracket: "Kids 10-12", count: num(rec[COL.kids12]), free: true },
{ bracket: "Kids 5-9", count: num(rec[COL.kids9]), free: true },
{ bracket: "Kids 10-12", count: num(rec[COL.kids12]), free: false },
{ bracket: "Kids 5-9", count: num(rec[COL.kids9]), free: false },
{ bracket: "Kids 0-4", count: num(rec[COL.kids4]), free: true },
].filter((b) => b.count > 0);
}
@ -117,7 +111,7 @@ export interface TicketView {
isDonor: boolean;
donorTier: string;
vouchers: number;
freeKids: number; // children 12 & under (free admission)
freeUnder5: number;
};
ages: { bracket: string; count: number; free: boolean }[];
}
@ -150,7 +144,7 @@ export function toView(rec: NocoRecord): TicketView {
isDonor: bool(rec[COL.isDonor]),
donorTier: String(rec[COL.donorTier] ?? ""),
vouchers: num(rec[COL.vouchers]),
freeKids: freeKidsCount(rec),
freeUnder5: num(rec[COL.kids4]),
},
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

@ -103,350 +103,92 @@ const PAGE = `<!doctype html>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#0f1a12" />
<title>Camp Scan Admin (crush33)</title>
<title>Camp Scan Comp Tickets</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; }
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; }
input, select { width: 100%; background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 12px 14px; color: #eaf2ec; font-size: 15px; }
.btn { background: #25c05a; color: #06210f; font-weight: 800; font-size: 16px; border: none; padding: 13px 18px; border-radius: 12px; cursor: pointer; }
.btn:disabled { opacity: 0.5; cursor: default; }
.btn-red { background: #e04343; color: #fff; }
.btn-ghost { background: transparent; color: #eaf2ec; border: 1px solid #2e7d32; }
.msg { margin-top: 12px; font-size: 14px; font-weight: 600; min-height: 18px; }
.err { color: #e04343; } .ok { color: #58d68d; }
.mono { font-family: ui-monospace, Menlo, monospace; }
/* Unlock */
#unlock { max-width: 420px; margin: 0 auto; padding: 48px 20px; text-align: center; }
#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; }
}
input, select { width: 100%; background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 14px; color: #eaf2ec; font-size: 16px; }
button { width: 100%; background: #25c05a; color: #06210f; font-weight: 800; font-size: 18px; border: none; padding: 15px; border-radius: 13px; margin-top: 18px; }
button:disabled { opacity: 0.5; }
.msg { margin-top: 14px; font-size: 15px; font-weight: 600; text-align: center; min-height: 20px; }
.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; }
.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; }
.result .who { font-size: 16px; color: #c4d6c9; }
.hint { color: #6c8f74; font-size: 12px; text-align: center; margin-top: 10px; }
</style>
</head>
<body>
<div id="unlock">
<div class="logo">🐻</div>
<h1>Admin · crush33</h1>
<p class="sub">Admin-only area. Enter the shared portal password.</p>
<input id="pw" type="password" autocomplete="current-password" placeholder="Portal password" />
<button class="btn" id="unlockBtn">Unlock</button>
<div id="unlockMsg" class="msg" style="text-align:center"></div>
<a class="backlink" href="/"> Back to the scan app</a>
</div>
<div class="wrap">
<header>
<div class="logo">🐻</div>
<h1>Comp Ticket Portal</h1>
<p class="sub">Entry-only tickets for workers &amp; guests</p>
</header>
<div id="hub">
<div class="top">
<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>
<label>Portal password</label>
<input id="pw" type="password" autocomplete="current-password" placeholder="Shared admin password" />
<!-- Donors -->
<div class="sec" id="sec-donors">
<h2>Donor lookup</h2>
<p class="lead">🔒 Admin only · private donor info. Search by name, email, phone, address, bear name</p>
<div class="row">
<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>
<label>Ticket type</label>
<select id="type">
<option>Guest</option><option>Worker</option><option>Performer</option>
<option>Volunteer</option><option>Speaker</option>
</select>
<!-- Actions -->
<div class="sec" id="sec-actions">
<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>
<label>Full name</label>
<input id="name" type="text" autocomplete="off" placeholder="Attendee name" />
<div class="danger">
<h3>🧹 Wipe the slate clean</h3>
<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>
<label>Email</label>
<input id="email" type="email" autocomplete="off" autocapitalize="none" placeholder="Where to send the ticket" />
<div class="danger">
<h3>🔀 Switch event table</h3>
<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>
<button id="go">Create ticket</button>
<div id="msg" class="msg"></div>
<div class="scrim" id="scrim">
<div class="modal">
<div class="warn"></div>
<h3 id="mTitle"></h3>
<pre id="mBody"></pre>
<button class="btn btn-red" id="mConfirm"></button>
<button class="cancel" id="mCancel">Cancel</button>
<div id="result" class="result">
<img id="qr" alt="Ticket QR" />
<div class="code" id="rcode"></div>
<div class="who" id="rwho"></div>
<div class="hint" id="rmail"></div>
<button id="another" style="background:transparent;color:#eaf2ec;border:1px solid #2e7d32;font-size:15px;">Create another</button>
</div>
</div>
<script>
var $ = function (id) { return document.getElementById(id); };
var PW = "";
var counts = { tickets: "?", audit: "?", table: "?" };
var pendingAction = null;
function setMsg(t, ok) { var m = $("msg"); m.textContent = t; m.className = "msg " + (ok ? "ok" : "err"); }
function api(path, body) {
return fetch(path, { method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(Object.assign({ password: PW }, body || {})) })
.then(function (r) { return r.json().then(function (d) { return { s: r.status, d: d }; }); });
}
function relock(m) { PW = ""; $("hub").style.display = "none"; $("unlock").style.display = "block";
$("unlockMsg").textContent = m || ""; $("unlockMsg").className = "msg err"; }
// ---- Unlock ----
function unlock() {
var pw = $("pw").value;
if (!pw) { $("unlockMsg").textContent = "Enter the password."; $("unlockMsg").className = "msg err"; return; }
$("unlockBtn").disabled = true; $("unlockMsg").textContent = "Checking…"; $("unlockMsg").className = "msg ok";
fetch("/api/portal/verify", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ password: pw }) })
.then(function (r) { return r.status; })
.then(function (s) {
$("unlockBtn").disabled = false;
if (s !== 200) { $("unlockMsg").textContent = "Wrong password."; $("unlockMsg").className = "msg err"; return; }
PW = pw; $("unlockMsg").textContent = ""; $("unlock").style.display = "none"; $("hub").style.display = "block";
loadStatus();
$("go").addEventListener("click", function () {
var pw = $("pw").value, name = $("name").value.trim(), email = $("email").value.trim(), type = $("type").value;
if (!pw) return setMsg("Enter the portal password.");
if (!name || !email) return setMsg("Name and email are required.");
$("go").disabled = true; setMsg("Creating…", true);
fetch("/api/portal/create-ticket", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pw, name: name, email: email, type: type })
}).then(function (r) { return r.json().then(function (d) { return { s: r.status, d: d }; }); })
.then(function (x) {
$("go").disabled = false;
if (x.s === 401) return setMsg("Wrong password.");
if (x.s !== 200 || !x.d.ok) return setMsg(x.d.detail || x.d.error || "Failed to create ticket.");
setMsg("");
$("qr").src = x.d.qr; $("rcode").textContent = x.d.code;
$("rwho").textContent = x.d.type + " · " + x.d.name;
$("rmail").textContent = x.d.emailSent ? "Emailed to " + email : "Email not sent — show/screenshot this QR.";
$("result").style.display = "block";
$("name").value = ""; $("email").value = "";
})
.catch(function () { $("unlockBtn").disabled = false; $("unlockMsg").textContent = "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."); });
.catch(function () { $("go").disabled = false; setMsg("Network error."); });
});
// ---- 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."); });
}
$("another").addEventListener("click", function () { $("result").style.display = "none"; $("name").focus(); });
</script>
</body>
</html>`;

View file

@ -17,21 +17,17 @@ function safeEqual(a: string, b: string): boolean {
*/
export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
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 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];
const cors = (reply: any) => {
reply.header("Access-Control-Allow-Origin", origin);
reply.header("Vary", "Origin");
reply.header("Access-Control-Allow-Methods", "GET, OPTIONS");
};
// Preflight (in case the form sends one).
const preflight = async (req: any, reply: any) => {
cors(req, reply);
const preflight = async (_req: any, reply: any) => {
cors(reply);
return reply.code(204).send();
};
app.options("/api/public/donor-eligibility", preflight);
@ -46,7 +42,7 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
"/api/public/donor-eligibility",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
cors(req, reply);
cors(reply);
// Disabled unless configured.
if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) {
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
// the tier entitlement earned from giving on/after VOUCHER_SINCE, MINUS the
// vouchers already consumed by their prior ticket orders (each order stores
// how many it used), so a donor can't keep claiming free tickets by
// re-submitting the form. `vouchers` is the remaining count the form should
// grant; `entitled`/`used`/`remaining` are the breakdown. No dollar amounts.
//
// To reset for testing: zero out (or delete) the "Vouchers" value on that
// donor's ticket order row(s) in NocoDB — `used` drops and `remaining` rises.
// Ticket-voucher entitlement: how many free tickets a donor has earned from
// giving on/after VOUCHER_SINCE. Same secret/CORS/rate-limit as above.
// Returns only the count (0/1/2) — no dollar amounts.
app.get(
"/api/public/ticket-vouchers",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => {
cors(req, reply);
cors(reply);
if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) {
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 addr = String(email ?? "").trim();
if (!addr) return { vouchers: 0, entitled: 0, used: 0, remaining: 0 };
if (!addr) return { vouchers: 0 };
try {
const cutoff = new Date(cfg.VOUCHER_SINCE);
const { amount } = await app.ctx.donors.amountSince(addr, cutoff);
const entitled = amount >= cfg.VOUCHER_TIER2_MIN ? 2 : amount >= cfg.VOUCHER_TIER1_MIN ? 1 : 0;
const used = await app.ctx.nocodb.vouchersUsedByEmail(addr);
const remaining = Math.max(0, entitled - used);
return { vouchers: remaining, entitled, used, remaining };
const vouchers = amount >= cfg.VOUCHER_TIER2_MIN ? 2 : amount >= cfg.VOUCHER_TIER1_MIN ? 1 : 0;
return { vouchers };
} catch {
// 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",
email: "family@test.beartaria",
adultNames: ["Family Fay", "Frank Fay"],
counts: C(2, 1, 1, 2, 2), // 2 adults + 1 youth = 3 paid; 5 kids 12 & under free
counts: C(2, 0, 0, 3, 2), // 2 adults + 3 kids(5-9) = 5 scannable; 2 kids 0-4 free
iceBags: 3,
carParking: true,
blurb:
"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",
@ -119,8 +119,8 @@ export async function testRoutes(app: FastifyInstance): Promise<void> {
// Keep the "exhausted" persona fully redeemed on every load so its state
// is deterministic (total = scannable count from the persona's counts).
if (p.exhaust) {
const { adults, youth } = p.counts;
await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth });
const { adults, youth, kids12, kids9 } = p.counts;
await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth + kids12 + kids9 });
}
cards.push({
code: result.code,

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

View file

@ -11,21 +11,19 @@ interface 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: "optional", type: "name (compound)", desc: "Adult Ticket #1 attendee — object {first_name, middle_name, last_name}. Also accepts flat names[first_name] keys. May be empty when buying only donor tickets or add-ons." },
{ key: "names_1 … names_9", req: "optional", type: "name (compound)", desc: "Additional regular adult attendee names (Adults #2#10). Empty groups are ignored. Stored as the adult-name list shown at the gate." },
{ key: "names_Donor_1 / names_Donor_2", req: "optional", type: "name (compound)", desc: "Donor voucher ticket names. Each FILLED group is one FREE voucher adult ticket — this is how voucher tickets are counted (there's no quantity field for them). Also added to the gate name list and recorded as the vouchers consumed." },
{ key: "names", req: "required", type: "name (compound)", desc: "Purchaser / Adult #1 — object {first_name, middle_name, last_name}. Also accepts flat names[first_name] keys." },
{ key: "names_1 … names_9", req: "optional", type: "name (compound)", desc: "Additional adult attendee names (Adults #2#10). Empty groups are ignored. Stored as the adult-name list shown at the gate." },
{ key: "email", req: "optional", type: "email", desc: "Purchaser email — the QR ticket is sent here (FluentForms sends the receipt separately)." },
{ key: "address_1", req: "optional", type: "address (compound)", desc: "Mailing address object; joined into one line." },
{ key: "item_quantity_adult_ticket_reg", req: "required", type: "quantity", desc: "Regular (non-donor) adult tickets." },
{ key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "ADDITIONAL paid donor adult tickets bought beyond the free vouchers. Added to the adult total; does NOT include the voucher tickets (those come from names_Donor_1/2)." },
{ key: "item_quantity_adult_ticket_reg", req: "required", type: "quantity", desc: "Adult tickets (regular)." },
{ key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "Adult tickets (donor). Added to the regular adults." },
{ key: "item_quantity_youth_ticket_reg / _donor", req: "optional", type: "quantity", desc: "Youth 13-16 tickets (regular + donor)." },
{ key: "item_quantity_kids_12", req: "optional", type: "quantity", desc: "Kids 10-12. FREE — stored but NOT counted toward the scannable ticket total." },
{ key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. FREE — stored but NOT counted toward the scannable ticket total." },
{ key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE — stored but NOT counted toward the scannable ticket total." },
{ key: "item_quantity_kids_12", req: "optional", type: "quantity", desc: "Kids 10-12. Counts toward the scannable total." },
{ key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. Counts toward the scannable total." },
{ key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE — NOT counted toward the scannable ticket total." },
{ key: "donor_tier", req: "optional", type: "hidden", desc: "member / donor / empty (from the donor-eligibility lookup)." },
{ key: "donor_eligible", req: "optional", type: "hidden", desc: "true / false (from the donor-eligibility lookup)." },
{ key: "vouchers", req: "optional", type: "hidden", desc: "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: "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." },
@ -56,7 +54,6 @@ const rows = FIELDS.map(
const exampleJson = esc(`{
"id": "412",
"customer_name": { "first_name": "Jane", "last_name": "Bear" },
"names": { "first_name": "Jane", "last_name": "Bear" },
"names_1": { "first_name": "John", "last_name": "Bear" },
"email": "jane@example.com",
@ -121,22 +118,21 @@ const PAGE = `<!doctype html>
<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><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>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>
<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>
<h2>Fields</h2>
<table>
<thead><tr><th>Key</th><th>Required</th><th>Type</th><th>Description</th></tr></thead>
<tbody>${rows}</tbody>
</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>
<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>
<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>
<pre><code>${exampleCurl}</code></pre>
@ -147,8 +143,7 @@ const PAGE = `<!doctype html>
<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":"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":"no_items"}</code></td><td>Empty order no tickets, ice, or add-ons.</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>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>
</tbody>
@ -163,21 +158,6 @@ const PAGE = `<!doctype html>
<li>Save, submit a test purchase, and confirm the QR email arrives.</li>
</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>
</div>
</body>

View file

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

View file

@ -34,7 +34,7 @@ export interface AuditRow extends AuditEntry {
export class AuditLogger {
private readonly base: string;
private readonly token: string;
private tableId: string | null;
private readonly tableId: string | null;
constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_AUDIT_TABLE_ID">) {
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
@ -46,56 +46,10 @@ export class AuditLogger {
return this.tableId !== null;
}
/** The audit table id (switchable at runtime by the admin action). */
get currentTableId(): string | null {
return this.tableId;
}
setTableId(id: string | null): void {
this.tableId = id || null;
}
private get url(): string {
return `${this.base}/api/v2/tables/${this.tableId}/records`;
}
/** Total audit row count (cheap — reads pageInfo). */
async count(): Promise<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> {
if (!this.tableId) return;
const sign = entry.people >= 0 ? "+" : "";

View file

@ -1,17 +1,5 @@
import type { Config } from "../config.js";
export interface DonorSearchResult {
name: string;
bearName: string;
email: string;
altEmail: string;
phone: string;
address: string;
lifetime: number | null;
tags: string[];
source: "master" | "transactions";
}
export interface DonorLookup {
found: boolean;
email: string;
@ -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
* 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;
}
/** First non-empty value whose column name matches `rx`. */
function pick(row: any, rx: RegExp): string {
for (const k of Object.keys(row)) if (rx.test(k) && row[k] != null && row[k] !== "") return String(row[k]);
return "";
}
/** Join all non-empty values whose column name matches `rx` (e.g. address parts). */
function pickAll(row: any, rx: RegExp): string {
const parts: string[] = [];
for (const k of Object.keys(row)) if (rx.test(k) && row[k] != null && row[k] !== "") parts.push(String(row[k]));
return [...new Set(parts)].join(", ");
}
function mapDonorRow(r: any, source: "master" | "transactions"): DonorSearchResult {
const name =
r["Display Name"] ||
r["Name"] ||
[r["First Name"], r["Last Name"]].filter(Boolean).join(" ") ||
r["Bear Name"] ||
pick(r, /name/i) ||
"";
const lifetimeRaw = r["Total Donations"];
return {
name: String(name),
bearName: String(r["Bear Name"] ?? ""),
email: String(r["Email"] ?? pick(r, /email/i)),
altEmail: String(r["Alternate Email"] ?? ""),
phone: pick(r, /phone|mobile|cell/i),
address: pickAll(r, /address|street|city|state|zip|postal|province|country/i),
lifetime: lifetimeRaw !== undefined && lifetimeRaw !== null && lifetimeRaw !== "" ? num(lifetimeRaw) : null,
tags: splitTags(r["Tags"]),
source,
};
}
// Count a transaction unless it's explicitly not paid (refunded/failed/pending).
function isPaid(row: any): boolean {
const s = String(row["Payment Status"] ?? "").trim();

View file

@ -9,43 +9,6 @@ export interface TicketEmail {
code: string;
quantity: number;
qrPng: Buffer;
iceBags?: number; // for add-on-only (ticketless) orders
}
/** Describe what a purchase is good for — handles ticketless (ice/UTV) orders. */
function purchaseSummary(mail: TicketEmail): { lead: string; footer: string } {
const qty = mail.quantity;
if (qty > 0) {
const w = qty === 1 ? "ticket" : "tickets";
return {
lead: `This email is your ticket for <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 {
@ -131,7 +94,8 @@ function esc(s: string): string {
function renderHtml(mail: TicketEmail): string {
const name = esc(mail.toName || "");
const { lead, footer } = purchaseSummary(mail);
const qty = mail.quantity;
const ticketWord = qty === 1 ? "ticket" : "tickets";
return `<!doctype html>
<html>
<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;">
<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;">
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>
<div style="text-align:center;margin:20px 0;">
<img src="cid:qrcode" alt="Ticket QR code" width="280" height="280"
@ -155,7 +121,8 @@ function renderHtml(mail: TicketEmail): string {
${esc(mail.code)}
</p>
<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>
</td></tr>
</table>
@ -167,16 +134,17 @@ function renderHtml(mail: TicketEmail): string {
}
function renderText(mail: TicketEmail): string {
const { lead, footer } = purchaseSummaryText(mail);
const qty = mail.quantity;
const ticketWord = qty === 1 ? "ticket" : "tickets";
return [
`Hi ${mail.toName || "there"},`,
"",
`Thank you for your purchase! ${lead}`,
`Thank you for your purchase! This is your ticket for ${qty} ${ticketWord} to the 2026 Beartaria Campgrounds event.`,
"",
`Your ticket code: ${mail.code}`,
"",
"Show this code (or the QR code in the HTML version of this email) at the gate.",
footer,
`It is good for all ${qty} ${ticketWord} on this purchase.`,
"",
"See you there!",
"Beartaria Campgrounds · beartariacampgrounds.com",

View file

@ -8,24 +8,16 @@ import { COL, type NocoRecord } from "../fields.js";
export class NocoDBClient {
private readonly base: string;
private readonly token: string;
private _tableId: string;
private readonly tableId: string;
constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_TABLE_ID">) {
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
this.token = cfg.NOCODB_API_TOKEN;
this._tableId = cfg.NOCODB_TABLE_ID;
}
/** The table this client currently reads/writes (switchable at runtime). */
get tableId(): string {
return this._tableId;
}
setTableId(id: string): void {
this._tableId = id;
this.tableId = cfg.NOCODB_TABLE_ID;
}
private get recordsUrl(): string {
return `${this.base}/api/v2/tables/${this._tableId}/records`;
return `${this.base}/api/v2/tables/${this.tableId}/records`;
}
private async request(url: string, init: RequestInit = {}): Promise<any> {
@ -84,22 +76,6 @@ export class NocoDBClient {
return this.list(`(${COL.name},like,%${q}%)~or(${COL.email},like,%${q}%)`, limit);
}
/** Every order for an exact email (case-insensitive). */
async findByEmail(email: string, limit = 1000): Promise<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> {
const body = await this.request(this.recordsUrl, {
method: "POST",
@ -146,47 +122,6 @@ export class NocoDBClient {
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. */
async ping(): Promise<boolean> {
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

@ -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> {
await this.delay();
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";
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 = {
Id: 1,
[COL.adults]: 2,
[COL.youth]: 1,
[COL.kids12]: 1, // free, not counted
[COL.kids9]: 1, // free, not counted
[COL.kids12]: 1,
[COL.kids9]: 1,
[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", () => {
@ -47,7 +47,7 @@ describe("toView", () => {
expect(v.extras.rvParking).toBe(false);
expect(v.extras.donorTier).toBe("member");
expect(v.extras.vouchers).toBe(2);
expect(v.extras.freeKids).toBe(1);
expect(v.extras.freeUnder5).toBe(1);
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

@ -19,11 +19,4 @@ services:
# host.docker.internal resolves to the host gateway.
extra_hosts:
- "host.docker.internal:host-gateway"
# Small writable volume for runtime state (the active event-table override
# set from the admin area), so it survives redeploys.
volumes:
- camptickets-data:/data
restart: unless-stopped
volumes:
camptickets-data:

View file

@ -14,32 +14,16 @@ ticketing backend.
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
{ "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`).
- `email` = the donor's email (URL-encoded).
- Rate-limited (30 requests / minute / IP) and CORS-restricted to
`PUBLIC_LOOKUP_ORIGIN` (`tickets.` + `vendors.beartariacampgrounds.com`).
### Vouchers decrement as they're used
`remaining = entitled used`, where `used` is the sum of the **Vouchers**
column across every ticket order placed with that email. Each checkout stores
the vouchers it applied, so the next lookup returns fewer — a donor can't keep
claiming free tickets by re-submitting the form. Once `used ≥ entitled`,
`vouchers` is `0`.
**To reset for testing:** in NocoDB, zero out (or delete) the **Vouchers**
value on that donor's ticket order row(s). `used` drops and `remaining` rises on
the next lookup — no redeploy needed.
`PUBLIC_LOOKUP_ORIGIN` (default `https://tickets.beartariacampgrounds.com`).
> The secret is visible in page source, so treat it as **deterrence, not
> security** — it only gates a 0/1/2 count. Rotate it by changing
@ -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>"
# entitled 2, none used yet -> {"vouchers":2,"entitled":2,"used":0,"remaining":2}
# after a checkout using 2 -> {"vouchers":0,"entitled":2,"used":2,"remaining":0}
# >= $1000 since cutoff -> {"vouchers":2}
# >= $400 since cutoff -> {"vouchers":1}
# otherwise -> {"vouchers":0}
```
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."