Body format JSON (application/json) or form-encoded — both accepted.
+
Body format JSON (application/json) or form-encoded — both accepted. Send all form fields.
What it does
-
On a valid request the backend generates a unique ticket code, creates a row in the "2026 Campground Tickets" NocoDB table, renders a QR code, and emails it to the purchaser (subject: "2026 Beartaria Campgrounds Tickets"). The total number of redeemable tickets is the sum of the age-bracket counts, excluding ages 0–3 (who are free).
+
On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject "2026 Beartaria Campgrounds Tickets"). FluentForms sends the payment receipt separately.
+
Scannable ticket total = adults + youth (13-16). Adults = item_quantity_adult_ticket_reg (regular) + item_quantity_adult_ticket_donor (extra paid donor tickets) + the number of donor voucher names (names_Donor_1/2 — each filled name is one free voucher ticket). Children 12 & under are free (charging starts at 13) — stored and shown to gate staff, but not counted toward the total. Each adult name provided is stored and shown on a successful scan; the ticket title is the customer_name.
+
Tickets are optional. A customer can buy ice, an ATV/UTV pass, or parking with no admission ticket, or buy tickets for other people. A record + QR is still created as long as there's something to redeem or verify at the gate (a ticket, ice, or an add-on). Only a truly empty order is rejected.
Fields
Key
Required
Type
Description
${rows}
-
At least one non-zero age-bracket count is required (otherwise there are no tickets to issue). Booleans accept 1/0, true/false, or yes/no.
+
Compound name fields arrive as objects (names: {first_name,…}) or flattened names[first_name] keys — both handled. Quantity/payment fields accept numbers, money strings ("$40.00"), or {quantity} objects. Counts come from the item_quantity_* fields, so pure pricing line items (payment_adult_reg, payment_youth_*, payment_kids_free, payment_donor_voucher1/2, custom-payment-amount/Tax) are ignored — the vouchers hidden count is authoritative for donor vouchers.
Idempotency
-
Send a stable submission_id. If the backend sees the same one again it returns {"status":"duplicate"} without creating a second ticket or re-sending email — so FluentForms retries and accidental double-submits are safe.
+
Send a stable id / submission_id. A repeat returns {"status":"duplicate"} without creating a second ticket or re-emailing — safe for retries and double-submits.
Example payload
${exampleJson}
-
This issues 5 redeemable tickets (3×8–12 + 2×26–45; the two 0–3 are free), with car parking and 3 ice bags.
+
This issues 3 scannable tickets (2 adults + 1 youth 13-16; all four kids 12 & under are free), member donor with 2 vouchers, car parking, and 6 bags of ice (2 ice tickets).
Request Headers: add X-Webhook-Secret = the shared secret.
-
Request Body: map each form field to the keys in the table above.
-
Save, then submit a test purchase and confirm the QR email arrives.
+
Request Body: send all fields (the field names above are the FluentForms field keys).
+
Save, submit a test purchase, and confirm the QR email arrives.
+
Vendor booth webhooks
+
Only food vendors receive entry tickets. The vendor forms live on vendors.beartariacampgrounds.com and share the same X-Webhook-Secret. For a food booth, each named person gets one entry pass; the booth name (input_text) becomes the ticket title, and the ticket is tagged with a Food Vendor Ticket Type that shows a badge on scan and rolls up in the event report. Booth size / additional space are logistics and don't affect passes.
+
+
Form
Endpoint
Result
+
+
Vendor Fee Food 2026
POST /vendor-webhook/food
🍔 up to 2 passes (names + names_1), Food Vendor ticket + QR email
+
Vendor Fee Non-Food 2026
POST /vendor-webhook/non-food
No ticket — acknowledged only ({"status":"ignored"}). You can leave this form's webhook unconfigured.
+
+
+
Relevant food keys: input_text (Booth Name), names / names_1 (pass-holders), email, address_1, donor_tier / donor_eligible / input_radio (donor), payment_method. Same idempotency (id/submission_id) and response shapes as above, plus a passes count.
diff --git a/backend/src/server.ts b/backend/src/server.ts
index 79fba69..685e1a7 100644
--- a/backend/src/server.ts
+++ b/backend/src/server.ts
@@ -9,11 +9,14 @@ 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();
@@ -31,11 +34,14 @@ export async function build() {
await app.register(authRoutes);
await app.register(webhookRoutes);
+ await app.register(vendorWebhookRoutes);
await app.register(ticketRoutes);
await app.register(testRoutes);
await app.register(installRoutes);
await app.register(webhookDocRoutes);
await app.register(publicLookupRoutes);
+ await app.register(portalRoutes);
+ await app.register(adminRoutes);
// Serve the exported Expo web build (if present) with SPA fallback.
const webDir = config.WEB_DIR ?? join(process.cwd(), "web");
diff --git a/backend/src/services/audit.ts b/backend/src/services/audit.ts
index 05e3b50..c6b3a29 100644
--- a/backend/src/services/audit.ts
+++ b/backend/src/services/audit.ts
@@ -34,7 +34,7 @@ export interface AuditRow extends AuditEntry {
export class AuditLogger {
private readonly base: string;
private readonly token: string;
- private readonly tableId: string | null;
+ private tableId: string | null;
constructor(cfg: Pick) {
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
@@ -46,10 +46,56 @@ export class AuditLogger {
return this.tableId !== null;
}
+ /** The audit table id (switchable at runtime by the admin action). */
+ get currentTableId(): string | null {
+ return this.tableId;
+ }
+ setTableId(id: string | null): void {
+ this.tableId = id || null;
+ }
+
private get url(): string {
return `${this.base}/api/v2/tables/${this.tableId}/records`;
}
+ /** Total audit row count (cheap — reads pageInfo). */
+ async count(): Promise {
+ if (!this.tableId) return 0;
+ const url = new URL(this.url);
+ url.searchParams.set("limit", "1");
+ const res = await fetch(url.toString(), {
+ headers: { "xc-token": this.token, "Content-Type": "application/json" },
+ });
+ if (!res.ok) return 0;
+ const body: any = await res.json().catch(() => ({}));
+ return body?.pageInfo?.totalRows ?? (body?.list?.length ?? 0);
+ }
+
+ /** Delete every audit row in the current table. Returns the count deleted. */
+ async deleteAll(): Promise {
+ if (!this.tableId) return 0;
+ let total = 0;
+ for (;;) {
+ const url = new URL(this.url);
+ url.searchParams.set("limit", "1000");
+ url.searchParams.set("fields", "Id");
+ const res = await fetch(url.toString(), {
+ headers: { "xc-token": this.token, "Content-Type": "application/json" },
+ });
+ if (!res.ok) break;
+ const body: any = await res.json().catch(() => ({}));
+ const list = body?.list ?? [];
+ if (!list.length) break;
+ await fetch(this.url, {
+ method: "DELETE",
+ headers: { "xc-token": this.token, "Content-Type": "application/json" },
+ body: JSON.stringify(list.map((r: any) => ({ Id: r.Id }))),
+ });
+ total += list.length;
+ }
+ return total;
+ }
+
async log(entry: AuditEntry): Promise {
if (!this.tableId) return;
const sign = entry.people >= 0 ? "+" : "";
@@ -79,6 +125,43 @@ export class AuditLogger {
}
}
+ private mapRow(r: any): AuditRow {
+ return {
+ id: r.Id,
+ code: r[AUDIT_COL.code] ?? "",
+ people: Number(r[AUDIT_COL.people]) || 0,
+ name: r[AUDIT_COL.name] ?? "",
+ operator: r[AUDIT_COL.operator] ?? "",
+ remainingAfter: Number(r[AUDIT_COL.remainingAfter]) || 0,
+ at: r[AUDIT_COL.at] ?? r.CreatedAt ?? "",
+ action: (r[AUDIT_COL.action] ?? "check-in") as AuditEntry["action"],
+ };
+ }
+
+ /** Every audit row, paginated (for reporting/aggregation). */
+ async all(): Promise {
+ if (!this.tableId) return [];
+ const out: AuditRow[] = [];
+ const pageSize = 1000;
+ let offset = 0;
+ for (;;) {
+ const url = new URL(this.url);
+ url.searchParams.set("limit", String(pageSize));
+ url.searchParams.set("offset", String(offset));
+ const res = await fetch(url.toString(), {
+ headers: { "xc-token": this.token, "Content-Type": "application/json" },
+ });
+ if (!res.ok) break;
+ const body: any = await res.json().catch(() => ({}));
+ const list = body?.list ?? [];
+ out.push(...list.map((r: any) => this.mapRow(r)));
+ if (!list.length || body?.pageInfo?.isLastPage || list.length < pageSize) break;
+ offset += pageSize;
+ if (offset > 200000) break;
+ }
+ return out;
+ }
+
/** Recent entries, newest first, optionally filtered to one code. */
async recent(opts: { code?: string; limit?: number } = {}): Promise {
if (!this.tableId) return [];
diff --git a/backend/src/services/donors.ts b/backend/src/services/donors.ts
index bd13de8..46e5e53 100644
--- a/backend/src/services/donors.ts
+++ b/backend/src/services/donors.ts
@@ -1,5 +1,17 @@
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;
@@ -156,6 +168,87 @@ export class DonorService {
source: "transactions",
};
}
+
+ /**
+ * Admin-only free-text donor search across the master list + transaction
+ * tables. Matches the query (substring, case-insensitive) against any
+ * name / email / phone / address / bear-name column each table exposes —
+ * columns are discovered from a sample row so it adapts to the schema.
+ * Results are de-duped by email (then name). PRIVACY: gate this to admins.
+ */
+ async search(rawQuery: string, limit = 40): Promise {
+ const q = rawQuery.trim();
+ if (!q || !this.enabled) return [];
+ const tables: { id: string | null; source: "master" | "transactions" }[] = [
+ { id: this.masterId, source: "master" },
+ { id: this.onlineId, source: "transactions" },
+ { id: this.offlineId, source: "transactions" },
+ ];
+ const out = new Map();
+ for (const t of tables) {
+ if (!t.id || out.size >= limit) continue;
+ let rows: any[];
+ try {
+ rows = await this.searchTable(t.id, q, limit);
+ } catch {
+ continue; // a table without matching columns / transient error — skip
+ }
+ for (const r of rows) {
+ const res = mapDonorRow(r, t.source);
+ const key = (res.email || res.name || JSON.stringify(r)).toLowerCase();
+ const existing = out.get(key);
+ // Prefer the master-list record (richer) when the same donor appears twice.
+ if (!existing || (existing.source === "transactions" && res.source === "master")) {
+ out.set(key, existing ? { ...res, lifetime: res.lifetime ?? existing.lifetime } : res);
+ }
+ if (out.size >= limit) break;
+ }
+ }
+ return [...out.values()].slice(0, limit);
+ }
+
+ private colCache = new Map();
+
+ /** Discover the text columns worth searching (name/contact) from a sample row. */
+ private async searchableColumns(tableId: string): Promise {
+ const cached = this.colCache.get(tableId);
+ if (cached) return cached;
+ const sample = await this.list(tableId, "", 1);
+ const keys = sample.length ? Object.keys(sample[0]) : [];
+ const want = /name|email|phone|mobile|cell|address|street|city|state|zip|postal|province|country|bear/i;
+ const skip = /[(),]/; // field names with filter-grammar chars can't be queried
+ const cols = keys.filter((k) => want.test(k) && !skip.test(k));
+ this.colCache.set(tableId, cols);
+ return cols;
+ }
+
+ private async searchTable(tableId: string, q: string, limit: number): Promise {
+ const cols = await this.searchableColumns(tableId);
+ if (!cols.length) return [];
+ const esc = q.replace(/[(),]/g, " ");
+ const where = cols.map((c) => `(${c},like,%${esc}%)`).join("~or");
+ return this.list(tableId, where, limit);
+ }
+
+ /**
+ * Total Paid donations for an email on/after `cutoff`, summed from the
+ * transaction tables (the only dated source). Used for ticket-voucher
+ * entitlement. found=false if donor tables aren't configured or there are no
+ * transactions for the email at all.
+ */
+ async amountSince(rawEmail: string, cutoff: Date): Promise<{ found: boolean; amount: number }> {
+ const email = rawEmail.trim();
+ if (!email || !this.onlineId || !this.offlineId) return { found: false, amount: 0 };
+ const esc = email.replace(/[(),]/g, " ");
+ const [online, offline] = await Promise.all([
+ this.list(this.onlineId, `(Email,eq,${esc})`, 1000),
+ this.list(this.offlineId, `(Email,eq,${esc})`, 1000),
+ ]);
+ const amount =
+ sumSince(online, "Donation Amount", "Donation Date", cutoff) +
+ sumSince(offline, "Donation Amount", "Donation Date", cutoff);
+ return { found: online.length + offline.length > 0, amount };
+ }
}
function num(v: unknown): number {
@@ -163,6 +256,40 @@ function num(v: unknown): number {
return Number.isFinite(n) ? n : 0;
}
+/** First non-empty value whose column name matches `rx`. */
+function pick(row: any, rx: RegExp): string {
+ for (const k of Object.keys(row)) if (rx.test(k) && row[k] != null && row[k] !== "") return String(row[k]);
+ return "";
+}
+/** Join all non-empty values whose column name matches `rx` (e.g. address parts). */
+function pickAll(row: any, rx: RegExp): string {
+ const parts: string[] = [];
+ for (const k of Object.keys(row)) if (rx.test(k) && row[k] != null && row[k] !== "") parts.push(String(row[k]));
+ return [...new Set(parts)].join(", ");
+}
+
+function mapDonorRow(r: any, source: "master" | "transactions"): DonorSearchResult {
+ const name =
+ r["Display Name"] ||
+ r["Name"] ||
+ [r["First Name"], r["Last Name"]].filter(Boolean).join(" ") ||
+ r["Bear Name"] ||
+ pick(r, /name/i) ||
+ "";
+ const lifetimeRaw = r["Total Donations"];
+ return {
+ name: String(name),
+ bearName: String(r["Bear Name"] ?? ""),
+ email: String(r["Email"] ?? pick(r, /email/i)),
+ altEmail: String(r["Alternate Email"] ?? ""),
+ phone: pick(r, /phone|mobile|cell/i),
+ address: pickAll(r, /address|street|city|state|zip|postal|province|country/i),
+ lifetime: lifetimeRaw !== undefined && lifetimeRaw !== null && lifetimeRaw !== "" ? num(lifetimeRaw) : null,
+ tags: splitTags(r["Tags"]),
+ source,
+ };
+}
+
// Count a transaction unless it's explicitly not paid (refunded/failed/pending).
function isPaid(row: any): boolean {
const s = String(row["Payment Status"] ?? "").trim();
diff --git a/backend/src/services/mailer.ts b/backend/src/services/mailer.ts
index a27ce84..a97f10b 100644
--- a/backend/src/services/mailer.ts
+++ b/backend/src/services/mailer.ts
@@ -9,6 +9,43 @@ export interface TicketEmail {
code: string;
quantity: number;
qrPng: Buffer;
+ iceBags?: number; // for add-on-only (ticketless) orders
+}
+
+/** Describe what a purchase is good for — handles ticketless (ice/UTV) orders. */
+function purchaseSummary(mail: TicketEmail): { lead: string; footer: string } {
+ const qty = mail.quantity;
+ if (qty > 0) {
+ const w = qty === 1 ? "ticket" : "tickets";
+ return {
+ lead: `This email is your ticket for ${qty} ${w} to the 2026 Beartaria Campgrounds event. Show the QR code below at the gate.`,
+ footer: `Each ticket admits one entry. This code is good for all ${qty} ${w} on one purchase — gate staff will check people in against it. See you there!`,
+ };
+ }
+ const bags = mail.iceBags ?? 0;
+ const extra = bags > 0 ? ` It includes ${bags} bag${bags === 1 ? "" : "s"} of ice.` : "";
+ return {
+ lead: `This email is your gate pass for your 2026 Beartaria Campgrounds purchase (add-ons such as ice, parking, or an ATV/UTV).${extra} Show the QR code below at the gate.`,
+ footer: `Show this QR at the gate and staff will redeem your add-ons against it. See you there!`,
+ };
+}
+
+/** Plain-text version of purchaseSummary (no HTML tags). */
+function purchaseSummaryText(mail: TicketEmail): { lead: string; footer: string } {
+ const qty = mail.quantity;
+ if (qty > 0) {
+ const w = qty === 1 ? "ticket" : "tickets";
+ return {
+ lead: `This is your ticket for ${qty} ${w} to the 2026 Beartaria Campgrounds event.`,
+ footer: `It is good for all ${qty} ${w} on this purchase.`,
+ };
+ }
+ const bags = mail.iceBags ?? 0;
+ const extra = bags > 0 ? ` It includes ${bags} bag${bags === 1 ? "" : "s"} of ice.` : "";
+ return {
+ lead: `This is your gate pass for your purchase (add-ons such as ice, parking, or an ATV/UTV).${extra}`,
+ footer: `Show this code at the gate and staff will redeem your add-ons against it.`,
+ };
}
export class MailerSendError extends Error {
@@ -94,8 +131,7 @@ function esc(s: string): string {
function renderHtml(mail: TicketEmail): string {
const name = esc(mail.toName || "");
- const qty = mail.quantity;
- const ticketWord = qty === 1 ? "ticket" : "tickets";
+ const { lead, footer } = purchaseSummary(mail);
return `
@@ -108,9 +144,7 @@ function renderHtml(mail: TicketEmail): string {
Hi ${name || "there"},
- Thank you for your purchase! This email is your ticket for
- ${qty} ${ticketWord} to the 2026 Beartaria Campgrounds event.
- Show the QR code below at the gate.
+ Thank you for your purchase! ${lead}
- 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!
+ ${footer}
@@ -134,17 +167,16 @@ function renderHtml(mail: TicketEmail): string {
}
function renderText(mail: TicketEmail): string {
- const qty = mail.quantity;
- const ticketWord = qty === 1 ? "ticket" : "tickets";
+ const { lead, footer } = purchaseSummaryText(mail);
return [
`Hi ${mail.toName || "there"},`,
"",
- `Thank you for your purchase! This is your ticket for ${qty} ${ticketWord} to the 2026 Beartaria Campgrounds event.`,
+ `Thank you for your purchase! ${lead}`,
"",
`Your ticket code: ${mail.code}`,
"",
"Show this code (or the QR code in the HTML version of this email) at the gate.",
- `It is good for all ${qty} ${ticketWord} on this purchase.`,
+ footer,
"",
"See you there!",
"Beartaria Campgrounds · beartariacampgrounds.com",
diff --git a/backend/src/services/nocodb.ts b/backend/src/services/nocodb.ts
index 707a70a..9e3416e 100644
--- a/backend/src/services/nocodb.ts
+++ b/backend/src/services/nocodb.ts
@@ -8,16 +8,24 @@ import { COL, type NocoRecord } from "../fields.js";
export class NocoDBClient {
private readonly base: string;
private readonly token: string;
- private readonly tableId: string;
+ private _tableId: string;
constructor(cfg: Pick) {
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
this.token = cfg.NOCODB_API_TOKEN;
- this.tableId = cfg.NOCODB_TABLE_ID;
+ this._tableId = cfg.NOCODB_TABLE_ID;
+ }
+
+ /** The table this client currently reads/writes (switchable at runtime). */
+ get tableId(): string {
+ return this._tableId;
+ }
+ setTableId(id: string): void {
+ this._tableId = id;
}
private get recordsUrl(): string {
- 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 {
@@ -76,6 +84,22 @@ export class NocoDBClient {
return this.list(`(${COL.name},like,%${q}%)~or(${COL.email},like,%${q}%)`, limit);
}
+ /** Every order for an exact email (case-insensitive). */
+ async findByEmail(email: string, limit = 1000): Promise {
+ const rows = await this.list(`(${COL.email},eq,${escapeValue(email)})`, limit);
+ // Belt-and-suspenders: some NocoDB backends do a case-sensitive eq, so
+ // narrow/confirm against a lowercased compare in JS.
+ const target = email.trim().toLowerCase();
+ const exact = rows.filter((r) => String(r[COL.email] ?? "").trim().toLowerCase() === target);
+ return exact.length ? exact : rows;
+ }
+
+ /** Sum of ticket vouchers a donor has already consumed across their orders. */
+ async vouchersUsedByEmail(email: string): Promise {
+ const rows = await this.findByEmail(email);
+ return rows.reduce((sum, r) => sum + (Number(r[COL.vouchers]) || 0), 0);
+ }
+
async create(fields: Record): Promise {
const body = await this.request(this.recordsUrl, {
method: "POST",
@@ -102,6 +126,67 @@ export class NocoDBClient {
return (Array.isArray(body) ? body[0] : body) as NocoRecord;
}
+ /** Fetch every record in the table, paginating. */
+ async all(): Promise {
+ const out: NocoRecord[] = [];
+ const pageSize = 1000;
+ let offset = 0;
+ for (;;) {
+ const url = new URL(this.recordsUrl);
+ url.searchParams.set("limit", String(pageSize));
+ url.searchParams.set("offset", String(offset));
+ const body = await this.request(url.toString());
+ const list = (body?.list ?? []) as NocoRecord[];
+ out.push(...list);
+ const info = body?.pageInfo;
+ if (!list.length || info?.isLastPage || list.length < pageSize) break;
+ offset += pageSize;
+ if (offset > 200000) break; // safety
+ }
+ return out;
+ }
+
+ /** Total record count in the current table (cheap — reads pageInfo). */
+ async count(): Promise {
+ const url = new URL(this.recordsUrl);
+ url.searchParams.set("limit", "1");
+ const body = await this.request(url.toString());
+ return body?.pageInfo?.totalRows ?? (body?.list?.length ?? 0);
+ }
+
+ /** Delete every record in the current table (paginated bulk delete). Returns
+ * the number deleted. Used by the admin "wipe slate" action. */
+ async deleteAll(): Promise {
+ let total = 0;
+ for (;;) {
+ const rows = await this.list("", 1000);
+ if (!rows.length) break;
+ const ids = rows.map((r) => ({ Id: (r as any).Id }));
+ await this.request(this.recordsUrl, { method: "DELETE", body: JSON.stringify(ids) });
+ total += rows.length;
+ }
+ return total;
+ }
+
+ /** Reachability + primary-key probe for a candidate table id (admin switch).
+ * Returns { ok, hasIdPk }. hasIdPk is false only if rows exist without an Id. */
+ async probeTable(tableId: string): Promise<{ ok: boolean; hasIdPk: boolean; status: number }> {
+ const url = new URL(`${this.base}/api/v2/tables/${tableId}/records`);
+ url.searchParams.set("limit", "1");
+ try {
+ const res = await fetch(url.toString(), {
+ headers: { "xc-token": this.token, "Content-Type": "application/json" },
+ });
+ if (!res.ok) return { ok: false, hasIdPk: false, status: res.status };
+ const body: any = await res.json().catch(() => ({}));
+ const list = body?.list ?? [];
+ const hasIdPk = list.length === 0 || "Id" in list[0];
+ return { ok: true, hasIdPk, status: 200 };
+ } catch {
+ return { ok: false, hasIdPk: false, status: 0 };
+ }
+ }
+
/** Cheap connectivity probe for healthchecks. */
async ping(): Promise {
const url = new URL(this.recordsUrl);
diff --git a/backend/src/services/state.ts b/backend/src/services/state.ts
new file mode 100644
index 0000000..b2cde2a
--- /dev/null
+++ b/backend/src/services/state.ts
@@ -0,0 +1,32 @@
+import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
+import { join } from "node:path";
+
+/**
+ * Tiny persisted state, stored as JSON on a mounted volume (STATE_DIR). Used for
+ * the admin "switch event table" action so the choice survives a redeploy —
+ * otherwise the app would revert to the .env table IDs on every restart.
+ */
+export interface ActiveTables {
+ ticketsTableId: string;
+ auditTableId?: string;
+}
+
+const FILE = "active-tables.json";
+
+export function loadActiveTables(dir: string): ActiveTables | null {
+ try {
+ const raw = readFileSync(join(dir, FILE), "utf8");
+ const parsed = JSON.parse(raw);
+ if (parsed && typeof parsed.ticketsTableId === "string" && parsed.ticketsTableId) {
+ return { ticketsTableId: parsed.ticketsTableId, auditTableId: parsed.auditTableId || undefined };
+ }
+ } catch {
+ // No override or unreadable — fall back to .env config.
+ }
+ return null;
+}
+
+export function saveActiveTables(dir: string, tables: ActiveTables): void {
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(join(dir, FILE), JSON.stringify(tables, null, 2), "utf8");
+}
diff --git a/backend/src/services/stats.ts b/backend/src/services/stats.ts
new file mode 100644
index 0000000..3e9801f
--- /dev/null
+++ b/backend/src/services/stats.ts
@@ -0,0 +1,131 @@
+import type { AppContext } from "../context.js";
+import { COL, toView, toNumber, type NocoRecord } from "../fields.js";
+
+export interface Stats {
+ orders: number;
+ tickets: { total: number; redeemed: number; remaining: number; pct: number };
+ people: { adults: number; youth: number; kids12: number; kids9: number; kids4Free: number };
+ ice: { total: number; redeemed: number; remaining: number; pct: number; ticketsSold: number };
+ types: { type: string; count: number; total: number; redeemed: number }[];
+ donors: { orders: number; members: number; vouchers: number };
+ extras: { carParking: number; rvParking: number; utv: number };
+ comps: { total: number; byCreator: { name: string; count: number }[] };
+ operators: { name: string; checkins: number; ice: number; undos: number }[];
+ checkinsByHour: { hour: string; count: number }[];
+ generatedAt: string;
+}
+
+let cache: { at: number; data: Stats } | null = null;
+const TTL_MS = 20_000;
+
+export async function computeStats(ctx: AppContext, force = false): Promise {
+ const now = Date.now();
+ if (!force && cache && now - cache.at < TTL_MS) return cache.data;
+
+ const records = await ctx.nocodb.all();
+ const bagsPerTicket = ctx.config.ICE_BAGS_PER_TICKET || 3;
+
+ let total = 0,
+ redeemed = 0,
+ iceTotal = 0,
+ iceRedeemed = 0;
+ let adults = 0,
+ youth = 0,
+ kids12 = 0,
+ kids9 = 0,
+ kids4 = 0;
+ let carParking = 0,
+ rvParking = 0,
+ utv = 0,
+ donorOrders = 0,
+ members = 0,
+ vouchers = 0;
+ const typeMap = new Map();
+ const compByCreator = new Map();
+ let compTotal = 0;
+
+ for (const r of records as NocoRecord[]) {
+ const v = toView(r);
+ if (v.ticketType) {
+ compTotal += 1;
+ const who = v.createdBy || "(unknown)";
+ compByCreator.set(who, (compByCreator.get(who) ?? 0) + 1);
+ }
+ total += v.total;
+ redeemed += v.redeemed;
+ iceTotal += v.ice.total;
+ iceRedeemed += v.ice.redeemed;
+ adults += toNumber(r[COL.adults]);
+ youth += toNumber(r[COL.youth]);
+ kids12 += toNumber(r[COL.kids12]);
+ kids9 += toNumber(r[COL.kids9]);
+ kids4 += toNumber(r[COL.kids4]);
+
+ const t = v.ticketType || "Regular";
+ const e = typeMap.get(t) ?? { count: 0, total: 0, redeemed: 0 };
+ e.count += 1;
+ e.total += v.total;
+ e.redeemed += v.redeemed;
+ typeMap.set(t, e);
+
+ if (v.extras.carParking) carParking += 1;
+ if (v.extras.rvParking) rvParking += 1;
+ if (v.extras.utv) utv += 1;
+ if (v.extras.isDonor) donorOrders += 1;
+ if (v.extras.donorTier === "member") members += 1;
+ vouchers += v.extras.vouchers;
+ }
+
+ // Operator activity + check-in timeline from the audit log.
+ const audit = await ctx.audit.all().catch(() => []);
+ const opMap = new Map();
+ const hourMap = new Map();
+ for (const a of audit) {
+ if (a.operator) {
+ const o = opMap.get(a.operator) ?? { checkins: 0, ice: 0, undos: 0 };
+ if (a.action === "check-in") o.checkins += a.people;
+ else if (a.action === "undo") o.undos += -a.people;
+ else if (a.action === "ice") o.ice += a.people;
+ opMap.set(a.operator, o);
+ }
+ if (a.action === "check-in" && a.people > 0 && a.at) {
+ const hour = String(a.at).slice(0, 13); // YYYY-MM-DDTHH
+ hourMap.set(hour, (hourMap.get(hour) ?? 0) + a.people);
+ }
+ }
+
+ const data: Stats = {
+ orders: records.length,
+ tickets: { total, redeemed, remaining: Math.max(0, total - redeemed), pct: total ? Math.round((redeemed / total) * 100) : 0 },
+ people: { adults, youth, kids12, kids9, kids4Free: kids4 },
+ ice: {
+ total: iceTotal,
+ redeemed: iceRedeemed,
+ remaining: Math.max(0, iceTotal - iceRedeemed),
+ pct: iceTotal ? Math.round((iceRedeemed / iceTotal) * 100) : 0,
+ ticketsSold: Math.round(iceTotal / bagsPerTicket),
+ },
+ types: [...typeMap.entries()]
+ .map(([type, e]) => ({ type, ...e }))
+ .sort((a, b) => b.total - a.total),
+ donors: { orders: donorOrders, members, vouchers },
+ extras: { carParking, rvParking, utv },
+ comps: {
+ total: compTotal,
+ byCreator: [...compByCreator.entries()]
+ .map(([name, count]) => ({ name, count }))
+ .sort((a, b) => b.count - a.count),
+ },
+ operators: [...opMap.entries()]
+ .map(([name, o]) => ({ name, ...o }))
+ .sort((a, b) => b.checkins - a.checkins),
+ checkinsByHour: [...hourMap.entries()]
+ .sort((a, b) => (a[0] < b[0] ? -1 : 1))
+ .slice(-12)
+ .map(([hour, count]) => ({ hour, count })),
+ generatedAt: new Date().toISOString(),
+ };
+
+ cache = { at: now, data };
+ return data;
+}
diff --git a/backend/src/test/fakeNocodb.ts b/backend/src/test/fakeNocodb.ts
index 46cbd89..d8fe66b 100644
--- a/backend/src/test/fakeNocodb.ts
+++ b/backend/src/test/fakeNocodb.ts
@@ -41,6 +41,17 @@ export class FakeNocoDB {
);
}
+ async findByEmail(email: string): Promise {
+ await this.delay();
+ const target = email.trim().toLowerCase();
+ return this.rows.filter((r) => String(r[COL.email] ?? "").trim().toLowerCase() === target);
+ }
+
+ async vouchersUsedByEmail(email: string): Promise {
+ const rows = await this.findByEmail(email);
+ return rows.reduce((sum, r) => sum + (Number(r[COL.vouchers]) || 0), 0);
+ }
+
async create(fields: Record): Promise {
await this.delay();
const rec = { Id: this.nextId++, ...fields } as NocoRecord;
@@ -86,14 +97,13 @@ export function fakeContext(db: FakeNocoDB): AppContext {
export async function seedTicket(
db: FakeNocoDB,
- opts: { code: string; name?: string; email?: string; ages?: Record; redeemed?: number },
+ opts: { code: string; name?: string; email?: string; adults?: number; redeemed?: number },
): Promise {
- const ages = opts.ages ?? { "Ages 18-25": 2, "Ages 26-45": 3, "Ages 0-3": 1 };
return db.create({
[COL.code]: opts.code,
[COL.name]: opts.name ?? "Test Bear",
[COL.email]: opts.email ?? "test@example.com",
+ [COL.adults]: opts.adults ?? 5,
[COL.redeemed]: opts.redeemed ?? 0,
- ...ages,
});
}
diff --git a/backend/src/test/fields.test.ts b/backend/src/test/fields.test.ts
index 2287a11..be0a4f1 100644
--- a/backend/src/test/fields.test.ts
+++ b/backend/src/test/fields.test.ts
@@ -2,45 +2,52 @@ import { describe, it, expect } from "vitest";
import { computeTotal, toView, COL } from "../fields.js";
describe("computeTotal", () => {
- it("sums age brackets but excludes Ages 0-3 (free)", () => {
+ it("sums adults + youth 13-16 only; all kids 12 & under are free", () => {
const rec = {
Id: 1,
- "Ages 0-3": 2, // free, not counted
- "Ages 4-7": 1,
- "Ages 18-25": 2,
- "Ages 26-45": 1,
+ [COL.adults]: 2,
+ [COL.youth]: 1,
+ [COL.kids12]: 1, // free, not counted
+ [COL.kids9]: 1, // free, not counted
+ [COL.kids4]: 3, // free, not counted
};
- expect(computeTotal(rec)).toBe(4);
+ expect(computeTotal(rec)).toBe(3);
});
it("coerces string counts and treats blanks as 0", () => {
- const rec = { Id: 1, "Ages 18-25": "3", "Ages 26-45": "" } as any;
+ const rec = { Id: 1, [COL.adults]: "3", [COL.youth]: "" } as any;
expect(computeTotal(rec)).toBe(3);
});
});
describe("toView", () => {
- it("derives remaining and surfaces extras", () => {
+ it("derives remaining and surfaces adult names, donor tier, and extras", () => {
const rec = {
Id: 7,
[COL.code]: "BC26-ABCD-2345",
[COL.name]: "Jane Bear",
[COL.email]: "jane@example.com",
+ [COL.adultNames]: "Jane Bear\nJohn Bear",
[COL.redeemed]: 2,
+ [COL.adults]: 2,
+ [COL.youth]: 3,
+ [COL.kids4]: 1,
[COL.carParking]: true,
[COL.iceAccess]: "yes",
- "Ages 0-3": 1,
- "Ages 18-25": 2,
- "Ages 26-45": 3,
+ [COL.donorTier]: "member",
+ [COL.vouchers]: 2,
};
const v = toView(rec);
expect(v.total).toBe(5);
expect(v.redeemed).toBe(2);
expect(v.remaining).toBe(3);
+ expect(v.adultNames).toEqual(["Jane Bear", "John Bear"]);
expect(v.extras.carParking).toBe(true);
expect(v.extras.iceAccess).toBe(true);
expect(v.extras.rvParking).toBe(false);
- expect(v.extras.freeUnder4).toBe(1);
- expect(v.ages.find((a) => a.bracket === "0-3")?.free).toBe(true);
+ expect(v.extras.donorTier).toBe("member");
+ expect(v.extras.vouchers).toBe(2);
+ expect(v.extras.freeKids).toBe(1);
+ expect(v.ages.find((a) => a.bracket === "Kids 0-4")?.free).toBe(true);
});
});
diff --git a/backend/src/test/fluentforms.test.ts b/backend/src/test/fluentforms.test.ts
new file mode 100644
index 0000000..9023e24
--- /dev/null
+++ b/backend/src/test/fluentforms.test.ts
@@ -0,0 +1,98 @@
+import { describe, it, expect } from "vitest";
+import { nameGroup, qty, selected, addressLine, readDonor, iceBagsFromPayment } from "../fluentforms.js";
+
+const ICE = { bagsPerTicket: 3, ticketPrice: 20 };
+
+// Food vendors are the only vendor tickets; two pass-holder name slots.
+const FOOD_SLOTS = ["names", "names_1"];
+
+/** Mirror the food vendor handler's pass count: one per named person, min 1. */
+function passCount(body: Record, slots: string[]): number {
+ const holders = slots.map((b) => nameGroup(body, b)).filter(Boolean);
+ return Math.max(1, holders.length);
+}
+
+describe("nameGroup", () => {
+ it("reads flattened bracket keys", () => {
+ const body = { "names[first_name]": "Joe", "names[last_name]": "Taco" };
+ expect(nameGroup(body, "names")).toBe("Joe Taco");
+ });
+ it("reads a nested object and includes the middle name", () => {
+ const body = { names: { first_name: "Ann", middle_name: "B", last_name: "Cole" } };
+ expect(nameGroup(body, "names")).toBe("Ann B Cole");
+ });
+ it("returns empty string when the group is blank", () => {
+ expect(nameGroup({}, "names_1")).toBe("");
+ });
+});
+
+describe("food vendor pass counting", () => {
+ it("food booth with two named holders gets 2 passes", () => {
+ const body = {
+ input_text: "Joe's Tacos",
+ "names[first_name]": "Joe",
+ "names[last_name]": "Taco",
+ "names_1[first_name]": "Jane",
+ "names_1[last_name]": "Taco",
+ };
+ expect(passCount(body, FOOD_SLOTS)).toBe(2);
+ });
+ it("food booth with only the first name gets 1 pass", () => {
+ const body = { input_text: "Solo BBQ", "names[first_name]": "Sam", "names[last_name]": "Que" };
+ expect(passCount(body, FOOD_SLOTS)).toBe(1);
+ });
+ it("booth with no names still gets 1 pass", () => {
+ expect(passCount({ input_text: "Nameless Booth" }, FOOD_SLOTS)).toBe(1);
+ });
+});
+
+describe("iceBagsFromPayment", () => {
+ it("reads '(N total bags)' from the real form label", () => {
+ expect(iceBagsFromPayment("One Ice ticket good for one bag per day (3 total bags)", ICE)).toBe(3);
+ expect(iceBagsFromPayment("Two Ice tickets good for one bag per day (6 total bags)", ICE)).toBe(6);
+ });
+ it("falls back to a worded ice-ticket count", () => {
+ expect(iceBagsFromPayment("Two Ice tickets", ICE)).toBe(6); // 2 × 3
+ expect(iceBagsFromPayment("Four Ice tickets", ICE)).toBe(12);
+ });
+ it("falls back to a dollar total at the ticket price", () => {
+ expect(iceBagsFromPayment("$40.00", ICE)).toBe(6); // 2 tickets × 3
+ expect(iceBagsFromPayment(20, ICE)).toBe(3); // 1 ticket × 3
+ });
+ it("treats a small plain count as ticket count", () => {
+ expect(iceBagsFromPayment(2, ICE)).toBe(6); // 2 tickets × 3
+ });
+ it("is 0 for blank / no ice", () => {
+ expect(iceBagsFromPayment("", ICE)).toBe(0);
+ expect(iceBagsFromPayment(undefined, ICE)).toBe(0);
+ expect(iceBagsFromPayment(0, ICE)).toBe(0);
+ });
+});
+
+describe("readDonor", () => {
+ it("treats donor_tier=member as a donor", () => {
+ expect(readDonor({ donor_tier: "member" })).toEqual({ isDonor: true, donorTier: "member" });
+ });
+ it("honors the donor_eligible hidden flag", () => {
+ expect(readDonor({ donor_eligible: "true" }).isDonor).toBe(true);
+ });
+ it("is not a donor when nothing indicates it", () => {
+ expect(readDonor({ input_radio: "No" })).toEqual({ isDonor: false, donorTier: "" });
+ });
+});
+
+describe("qty / selected / addressLine", () => {
+ it("parses money strings and nested quantities", () => {
+ expect(qty("$40.00")).toBe(40);
+ expect(qty({ quantity: 2 })).toBe(2);
+ expect(qty("")).toBe(0);
+ });
+ it("selected() treats $0.00 / no / blank as unselected", () => {
+ expect(selected("$0.00")).toBe(false);
+ expect(selected("No")).toBe(false);
+ expect(selected("Yes")).toBe(true);
+ });
+ it("flattens a compound address", () => {
+ expect(addressLine({ address_line_1: "1 Main", city: "Boise", state: "ID" })).toBe("1 Main, Boise, ID");
+ });
+});
diff --git a/backend/src/test/redeem.test.ts b/backend/src/test/redeem.test.ts
index 72738a6..b2255e1 100644
--- a/backend/src/test/redeem.test.ts
+++ b/backend/src/test/redeem.test.ts
@@ -6,7 +6,7 @@ import { COL } from "../fields.js";
describe("redeem", () => {
it("checks in a single walk-up (default count 1)", async () => {
const db = new FakeNocoDB();
- await seedTicket(db, { code: "BC26-AAAA-1111", ages: { "Ages 26-45": 4 } });
+ await seedTicket(db, { code: "BC26-AAAA-1111", adults: 4 });
const ctx = fakeContext(db);
const r = await redeem(ctx, "BC26-AAAA-1111", 1);
expect(r.ok).toBe(true);
@@ -20,7 +20,7 @@ describe("redeem", () => {
it("supports group check-in and QR reuse across visits", async () => {
const db = new FakeNocoDB();
// Party of 7 (2 free under-4 not counted): total 5.
- await seedTicket(db, { code: "BC26-FAM-0001", ages: { "Ages 0-3": 2, "Ages 26-45": 2, "Ages 8-12": 3 } });
+ await seedTicket(db, { code: "BC26-FAM-0001", adults: 5 });
const ctx = fakeContext(db);
const first = await redeem(ctx, "BC26-FAM-0001", 2); // father + son
@@ -36,7 +36,7 @@ describe("redeem", () => {
it("rejects over-redemption without mutating", async () => {
const db = new FakeNocoDB();
- await seedTicket(db, { code: "BC26-BBBB-2222", ages: { "Ages 26-45": 2 } });
+ await seedTicket(db, { code: "BC26-BBBB-2222", adults: 2 });
const ctx = fakeContext(db);
const r = await redeem(ctx, "BC26-BBBB-2222", 5);
expect(r.ok).toBe(false);
@@ -46,7 +46,7 @@ describe("redeem", () => {
it("allows negative count to undo, clamped at zero", async () => {
const db = new FakeNocoDB();
- await seedTicket(db, { code: "BC26-CCCC-3333", ages: { "Ages 26-45": 3 }, redeemed: 2 });
+ await seedTicket(db, { code: "BC26-CCCC-3333", adults: 3, redeemed: 2 });
const ctx = fakeContext(db);
const r = await redeem(ctx, "BC26-CCCC-3333", -5);
expect(r.ok).toBe(true);
@@ -55,7 +55,7 @@ describe("redeem", () => {
it("writes an audit entry on each successful check-in and undo", async () => {
const db = new FakeNocoDB();
- await seedTicket(db, { code: "BC26-AUDT-0001", ages: { "Ages 26-45": 4 } });
+ await seedTicket(db, { code: "BC26-AUDT-0001", adults: 4 });
const ctx = fakeContext(db);
await redeem(ctx, "BC26-AUDT-0001", 2);
await redeem(ctx, "BC26-AUDT-0001", -1);
@@ -67,7 +67,7 @@ describe("redeem", () => {
it("does not audit a no-op (undo when nothing redeemed)", async () => {
const db = new FakeNocoDB();
- await seedTicket(db, { code: "BC26-AUDT-0002", ages: { "Ages 26-45": 3 }, redeemed: 0 });
+ await seedTicket(db, { code: "BC26-AUDT-0002", adults: 3, redeemed: 0 });
const ctx = fakeContext(db);
await redeem(ctx, "BC26-AUDT-0002", -2); // clamps to 0, delta 0
expect((ctx.audit as any).entries).toHaveLength(0);
@@ -77,7 +77,7 @@ describe("redeem", () => {
const db = new FakeNocoDB();
await seedTicket(db, {
code: "BC26-ICE-0003",
- ages: { "Ages 26-45": 2 },
+ adults: 2,
});
// Give the ticket 3 prepaid ice bags.
db.rows[0]["Ice Total"] = 3;
@@ -112,7 +112,7 @@ describe("redeem", () => {
it("surfaces db_error when the update fails", async () => {
const db = new FakeNocoDB();
- await seedTicket(db, { code: "BC26-DDDD-4444", ages: { "Ages 26-45": 3 } });
+ await seedTicket(db, { code: "BC26-DDDD-4444", adults: 3 });
db.failNext = true;
const ctx = fakeContext(db);
const r = await redeem(ctx, "BC26-DDDD-4444", 1);
@@ -122,7 +122,7 @@ describe("redeem", () => {
it("CONCURRENCY: 20 parallel single check-ins on a 5-ticket code yield exactly 5", async () => {
const db = new FakeNocoDB(8);
- await seedTicket(db, { code: "BC26-RACE-0005", ages: { "Ages 26-45": 5 } });
+ await seedTicket(db, { code: "BC26-RACE-0005", adults: 5 });
const ctx = fakeContext(db);
const results = await Promise.all(
@@ -137,7 +137,7 @@ describe("redeem", () => {
describe("lookupByCode", () => {
it("returns the ticket view without mutating", async () => {
const db = new FakeNocoDB();
- await seedTicket(db, { code: "BC26-LOOK-0001", ages: { "Ages 26-45": 3 } });
+ await seedTicket(db, { code: "BC26-LOOK-0001", adults: 3 });
const ctx = fakeContext(db);
const r = await lookupByCode(ctx, "BC26-LOOK-0001");
expect(r.ok && r.found && r.ticket.remaining).toBe(3);
@@ -159,7 +159,7 @@ describe("createTicket idempotency", () => {
const input = {
name: "Jane Bear",
email: "jane@example.com",
- ages: { "Ages 26-45": 2 },
+ counts: { adults: 2, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
submissionKey: "sub:412",
};
const a = await createTicket(ctx, input);
diff --git a/backend/src/test/vouchers.test.ts b/backend/src/test/vouchers.test.ts
new file mode 100644
index 0000000..497887a
--- /dev/null
+++ b/backend/src/test/vouchers.test.ts
@@ -0,0 +1,38 @@
+import { describe, it, expect } from "vitest";
+import { FakeNocoDB } from "./fakeNocodb.js";
+import { COL } from "../fields.js";
+
+/** Mirror the ticket-vouchers endpoint's remaining math. */
+function remaining(entitled: number, used: number): number {
+ return Math.max(0, entitled - used);
+}
+
+describe("voucher consumption", () => {
+ it("sums vouchers used across a donor's orders", async () => {
+ const db = new FakeNocoDB(0);
+ await db.create({ [COL.email]: "donor@example.com", [COL.vouchers]: 2 });
+ await db.create({ [COL.email]: "donor@example.com", [COL.vouchers]: 1 });
+ await db.create({ [COL.email]: "someone-else@example.com", [COL.vouchers]: 2 });
+ await db.create({ [COL.email]: "donor@example.com", [COL.vouchers]: 0 }); // non-voucher order
+ expect(await db.vouchersUsedByEmail("donor@example.com")).toBe(3);
+ });
+
+ it("matches email case-insensitively", async () => {
+ const db = new FakeNocoDB(0);
+ await db.create({ [COL.email]: "Donor@Example.com", [COL.vouchers]: 2 });
+ expect(await db.vouchersUsedByEmail("donor@example.com")).toBe(2);
+ });
+
+ it("returns 0 used for a donor with no orders", async () => {
+ const db = new FakeNocoDB(0);
+ expect(await db.vouchersUsedByEmail("nobody@example.com")).toBe(0);
+ });
+
+ it("remaining = entitled - used, floored at 0", () => {
+ expect(remaining(2, 0)).toBe(2); // fresh 2-voucher donor
+ expect(remaining(2, 1)).toBe(1); // used one
+ expect(remaining(2, 2)).toBe(0); // used both — no more free tickets
+ expect(remaining(1, 2)).toBe(0); // over-consumed (edge) never goes negative
+ expect(remaining(0, 0)).toBe(0); // non-donor
+ });
+});
diff --git a/backend/src/ticketService.ts b/backend/src/ticketService.ts
index 76fa4dc..040ae07 100644
--- a/backend/src/ticketService.ts
+++ b/backend/src/ticketService.ts
@@ -128,15 +128,21 @@ export async function search(ctx: AppContext, query: string): Promise; // NocoDB age-column title -> count
submissionKey: string;
}
@@ -158,20 +164,31 @@ export async function createTicket(
code = generateCode();
}
+ const c = input.counts;
const fields: Record = {
[COL.name]: input.name,
[COL.email]: input.email,
[COL.code]: code,
[COL.redeemed]: 0,
+ [COL.adults]: c.adults,
+ [COL.youth]: c.youth,
+ [COL.kids12]: c.kids12,
+ [COL.kids9]: c.kids9,
+ [COL.kids4]: c.kids4,
[COL.iceTotal]: input.iceBags ?? 0,
[COL.iceRedeemed]: 0,
[COL.submissionKey]: input.submissionKey,
- ...input.ages,
};
+ if (input.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n");
+ if (input.ticketType) fields[COL.ticketType] = input.ticketType;
+ if (input.createdBy) fields[COL.createdBy] = input.createdBy;
if (input.address !== undefined) fields[COL.address] = input.address;
if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor;
+ if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier;
+ if (input.vouchers !== undefined) fields[COL.vouchers] = input.vouchers;
if (input.carParking !== undefined) fields[COL.carParking] = input.carParking;
if (input.rvParking !== undefined) fields[COL.rvParking] = input.rvParking;
+ if (input.utv !== undefined) fields[COL.utv] = input.utv;
if (input.iceAccess !== undefined) fields[COL.iceAccess] = input.iceAccess;
if (input.paymentMethod !== undefined) fields[COL.paymentMethod] = input.paymentMethod;
diff --git a/docker-compose.yml b/docker-compose.yml
index 20e1aa0..dc62d8d 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,7 +3,9 @@ services:
build: .
image: camptickets:latest
container_name: camptickets
- env_file: .env
+ # Single source of truth for secrets — edit backend/.env, then
+ # `docker compose up -d`. (Was ./.env; consolidated to avoid a stale copy.)
+ env_file: backend/.env
environment:
# Container always listens on 8080 internally; the host mapping below is
# what nginx proxies to. Keep this fixed regardless of .env PORT.
@@ -17,4 +19,11 @@ services:
# host.docker.internal resolves to the host gateway.
extra_hosts:
- "host.docker.internal:host-gateway"
+ # Small writable volume for runtime state (the active event-table override
+ # set from the admin area), so it survives redeploys.
+ volumes:
+ - camptickets-data:/data
restart: unless-stopped
+
+volumes:
+ camptickets-data:
diff --git a/docs/fluentforms-donor-discount.md b/docs/fluentforms-donor-discount.md
index dcec3fe..6872a0f 100644
--- a/docs/fluentforms-donor-discount.md
+++ b/docs/fluentforms-donor-discount.md
@@ -1,8 +1,26 @@
-# FluentForms → donor discount lookup
+# FluentForms → donor lookup APIs
-FluentForms has no native way to query an external database from a field. This
-wires it up with a small Custom JS block that calls our secret-gated endpoint
-and unlocks a discount when the entered email belongs to a donor/member.
+FluentForms has no native way to query an external database from a field. These
+wire it up with a small Custom JS block that calls a secret-gated endpoint on
+the ticketing backend. Two endpoints are available (same key, CORS, and rate
+limit):
+
+| Endpoint | Purpose |
+|---|---|
+| `GET /api/public/donor-eligibility` | Is this email a donor/member? → unlock a discount |
+| `GET /api/public/ticket-vouchers` | How many free tickets has this donor earned? → 0 / 1 / 2 |
+
+Both require `?key=`, are rate-limited (30/min/IP), and
+CORS-restricted to `PUBLIC_LOOKUP_ORIGIN` (default
+`https://tickets.beartariacampgrounds.com`). Neither returns names or dollar
+amounts. The secret is visible in page source, so treat it as deterrence, not
+security; rotate it by changing `PUBLIC_LOOKUP_SECRET` and redeploying.
+
+---
+
+## Donor discount lookup
+
+Unlocks a discount when the entered email belongs to a donor/member.
## Endpoint
@@ -124,3 +142,11 @@ curl "https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=&email=
+```
+
+Returns the **remaining** free-ticket count — never names or dollar amounts:
+
+```json
+{ "vouchers": 1, "entitled": 2, "used": 1, "remaining": 1 }
+```
+
+- `vouchers` / `remaining` — how many free tickets are **still available** (this
+ is what the form should grant). Use `vouchers`; `remaining` is an alias.
+- `entitled` — the tier entitlement earned from giving (0/1/2).
+- `used` — vouchers already consumed by this donor's prior ticket orders.
+- `key` = the value of `PUBLIC_LOOKUP_SECRET` (set in the backend `.env`).
+- `email` = the donor's email (URL-encoded).
+- Rate-limited (30 requests / minute / IP) and CORS-restricted to
+ `PUBLIC_LOOKUP_ORIGIN` (`tickets.` + `vendors.beartariacampgrounds.com`).
+
+### Vouchers decrement as they're used
+
+`remaining = entitled − used`, where `used` is the sum of the **Vouchers**
+column across every ticket order placed with that email. Each checkout stores
+the vouchers it applied, so the next lookup returns fewer — a donor can't keep
+claiming free tickets by re-submitting the form. Once `used ≥ entitled`,
+`vouchers` is `0`.
+
+**To reset for testing:** in NocoDB, zero out (or delete) the **Vouchers**
+value on that donor's ticket order row(s). `used` drops and `remaining` rises on
+the next lookup — no redeploy needed.
+
+> The secret is visible in page source, so treat it as **deterrence, not
+> security** — it only gates a 0/1/2 count. Rotate it by changing
+> `PUBLIC_LOOKUP_SECRET` and redeploying.
+
+## Rules
+
+Donations are summed for the email across the online + offline transaction
+tables, counting only **Paid** rows dated **on/after `VOUCHER_SINCE`**:
+
+| Total since the cutoff | Vouchers |
+|---|---|
+| ≥ $1000 | 2 |
+| ≥ $400 | 1 |
+| otherwise | 0 |
+
+Configurable in the backend `.env`:
+
+| Var | Default | Meaning |
+|---|---|---|
+| `VOUCHER_SINCE` | `2025-09-04` | Only donations on/after this date count. Bump each year. |
+| `VOUCHER_TIER1_MIN` | `400` | Dollar total for 1 voucher |
+| `VOUCHER_TIER2_MIN` | `1000` | Dollar total for 2 vouchers |
+
+The count comes from the **dated transaction tables** (the donor master-list
+rollups have no dates), so donations must exist in those tables for the window.
+
+## Form snippet
+
+Add a **Custom HTML** element to the form and paste this, setting `KEY` to your
+`PUBLIC_LOOKUP_SECRET` (and `EMAIL_SELECTOR` if the email field isn't named
+`email`). It shows the earned count on email blur and writes it into a hidden
+field `free_tickets` you can use for conditional logic or to cap a quantity.
+
+```html
+
+
+```
+
+## Test
+
+```
+curl "https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?key=&email="
+# entitled 2, none used yet -> {"vouchers":2,"entitled":2,"used":0,"remaining":2}
+# after a checkout using 2 -> {"vouchers":0,"entitled":2,"used":2,"remaining":0}
+```
+
+Related: [`fluentforms-donor-discount.md`](./fluentforms-donor-discount.md) — the
+companion donor-discount eligibility lookup (same key / CORS / rate limit).
diff --git a/scripts/switch-event.sh b/scripts/switch-event.sh
new file mode 100755
index 0000000..862ad54
--- /dev/null
+++ b/scripts/switch-event.sh
@@ -0,0 +1,73 @@
+#!/usr/bin/env bash
+set -euo pipefail
+#
+# switch-event.sh — point the scanner app at a DIFFERENT NocoDB tickets (and
+# optionally audit) table, e.g. to start a NEW event on a fresh table while
+# keeping the old table intact for archive. Backs up backend/.env, updates it,
+# and restarts the app container. The old table is never touched.
+#
+# Usage:
+# scripts/switch-event.sh [AUDIT_TABLE_ID]
+#
+# FIRST create the new table(s): in the NocoDB UI, DUPLICATE the current table
+# with "structure only" (no records). That preserves every column AND the Id
+# primary key — critical, because updates against a table with no primary key
+# would hit every row. Then grab the new table id from its URL/API and pass it
+# here. (The app also fail-safes: it refuses to update a row that has no Id.)
+#
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+ENV_FILE="$ROOT/backend/.env"
+CONTAINER="${CONTAINER:-camptickets}"
+
+NEW_TICKETS="${1:-}"
+NEW_AUDIT="${2:-}"
+[ -n "$NEW_TICKETS" ] || { echo "Usage: $0 [AUDIT_TABLE_ID]" >&2; exit 1; }
+
+get() { grep -E "^$1=" "$ENV_FILE" | head -1 | cut -d= -f2-; }
+BASE_URL="$(get NOCODB_BASE_URL)"
+TOKEN="$(get NOCODB_API_TOKEN)"
+CUR_TICKETS="$(get NOCODB_TABLE_ID)"
+CUR_AUDIT="$(get NOCODB_AUDIT_TABLE_ID)"
+
+# Validate a table is reachable and (if it has rows) exposes an Id primary key.
+check() {
+ local table="$1" tmp http
+ tmp="$(mktemp)"
+ http="$(curl -s -o "$tmp" -w '%{http_code}' -H "xc-token: $TOKEN" \
+ "$BASE_URL/api/v2/tables/$table/records?limit=1")"
+ if [ "$http" != "200" ]; then
+ echo " ✗ $table not reachable (HTTP $http)"; rm -f "$tmp"; return 1
+ fi
+ if ! python3 -c 'import sys,json; l=json.load(open(sys.argv[1]))["list"]; sys.exit(0 if (not l or "Id" in l[0]) else 1)' "$tmp"; then
+ echo " ✗ $table has rows without an Id primary key — refusing"; rm -f "$tmp"; return 1
+ fi
+ rm -f "$tmp"; echo " ✓ $table reachable"
+}
+
+echo "Validating new table(s) on $BASE_URL ..."
+check "$NEW_TICKETS" || exit 1
+[ -n "$NEW_AUDIT" ] && { check "$NEW_AUDIT" || exit 1; }
+
+BK="$ENV_FILE.bak.$(date +%Y%m%d-%H%M%S)"
+cp "$ENV_FILE" "$BK"
+echo "Backed up env -> $BK"
+
+echo "Switching tables:"
+echo " tickets: $CUR_TICKETS -> $NEW_TICKETS"
+sed -i -E "s|^NOCODB_TABLE_ID=.*|NOCODB_TABLE_ID=$NEW_TICKETS|" "$ENV_FILE"
+if [ -n "$NEW_AUDIT" ]; then
+ echo " audit: $CUR_AUDIT -> $NEW_AUDIT"
+ sed -i -E "s|^NOCODB_AUDIT_TABLE_ID=.*|NOCODB_AUDIT_TABLE_ID=$NEW_AUDIT|" "$ENV_FILE"
+else
+ echo " audit: unchanged ($CUR_AUDIT) — pass a second arg to switch it too"
+fi
+
+echo "Restarting $CONTAINER ..."
+( cd "$ROOT" && docker compose up -d --force-recreate >/dev/null )
+sleep 3
+
+echo "Now active:"
+echo " NOCODB_TABLE_ID=$(get NOCODB_TABLE_ID)"
+echo " NOCODB_AUDIT_TABLE_ID=$(get NOCODB_AUDIT_TABLE_ID)"
+echo "Old tickets table $CUR_TICKETS kept intact. (env backup: $BK)"
diff --git a/scripts/wipe-slate.sh b/scripts/wipe-slate.sh
new file mode 100755
index 0000000..58fc3a8
--- /dev/null
+++ b/scripts/wipe-slate.sh
@@ -0,0 +1,57 @@
+#!/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."