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 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}
-
-
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 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 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: 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 685e1a7..cd5f362 100644
--- a/backend/src/server.ts
+++ b/backend/src/server.ts
@@ -9,14 +9,8 @@ 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 +28,8 @@ 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 c6b3a29..05e3b50 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 tableId: string | null;
+ private readonly tableId: string | null;
constructor(cfg: Pick) {
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 {
- 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 ? "+" : "";
@@ -125,43 +79,6 @@ export class AuditLogger {
}
}
- private mapRow(r: any): AuditRow {
- return {
- id: r.Id,
- code: r[AUDIT_COL.code] ?? "",
- people: Number(r[AUDIT_COL.people]) || 0,
- name: r[AUDIT_COL.name] ?? "",
- operator: r[AUDIT_COL.operator] ?? "",
- remainingAfter: Number(r[AUDIT_COL.remainingAfter]) || 0,
- at: r[AUDIT_COL.at] ?? r.CreatedAt ?? "",
- action: (r[AUDIT_COL.action] ?? "check-in") as AuditEntry["action"],
- };
- }
-
- /** Every audit row, paginated (for reporting/aggregation). */
- async all(): Promise {
- 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 46e5e53..bd13de8 100644
--- a/backend/src/services/donors.ts
+++ b/backend/src/services/donors.ts
@@ -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;
@@ -168,87 +156,6 @@ 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 {
@@ -256,40 +163,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();
diff --git a/backend/src/services/mailer.ts b/backend/src/services/mailer.ts
index a97f10b..a27ce84 100644
--- a/backend/src/services/mailer.ts
+++ b/backend/src/services/mailer.ts
@@ -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 ${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 {
@@ -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 `
@@ -144,7 +108,9 @@ function renderHtml(mail: TicketEmail): string {
Hi ${name || "there"},
- Thank you for your purchase! ${lead}
+ 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.
- ${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!
@@ -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",
diff --git a/backend/src/services/nocodb.ts b/backend/src/services/nocodb.ts
index 9e3416e..2378b24 100644
--- a/backend/src/services/nocodb.ts
+++ b/backend/src/services/nocodb.ts
@@ -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) {
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 {
@@ -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 {
- 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",
@@ -110,15 +86,6 @@ export class NocoDBClient {
/** Patch fields on a record identified by its NocoDB Id. */
async update(id: number, fields: Record): Promise {
- // Fail safe: without a valid primary key, a v2 PATCH /records applies to
- // EVERY row in the table. Refuse rather than mass-corrupt ticket counts.
- // (A table missing its Id column will trip this — use a table with a PK.)
- if (id === undefined || id === null || (typeof id === "number" && !Number.isFinite(id))) {
- throw new NocoDBError(
- "record has no Id — refusing to update (the table is missing its primary key)",
- 500,
- );
- }
const body = await this.request(this.recordsUrl, {
method: "PATCH",
body: JSON.stringify({ Id: id, ...fields }),
@@ -126,67 +93,6 @@ 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
deleted file mode 100644
index b2cde2a..0000000
--- a/backend/src/services/state.ts
+++ /dev/null
@@ -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");
-}
diff --git a/backend/src/services/stats.ts b/backend/src/services/stats.ts
deleted file mode 100644
index 3e9801f..0000000
--- a/backend/src/services/stats.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import type { AppContext } from "../context.js";
-import { COL, toView, toNumber, type NocoRecord } from "../fields.js";
-
-export interface Stats {
- orders: number;
- tickets: { total: number; redeemed: number; remaining: number; pct: number };
- people: { adults: number; youth: number; kids12: number; kids9: number; kids4Free: number };
- ice: { total: number; redeemed: number; remaining: number; pct: number; ticketsSold: number };
- types: { type: string; count: number; total: number; redeemed: number }[];
- donors: { orders: number; members: number; vouchers: number };
- extras: { carParking: number; rvParking: number; utv: number };
- comps: { total: number; byCreator: { name: string; count: number }[] };
- operators: { name: string; checkins: number; ice: number; undos: number }[];
- checkinsByHour: { hour: string; count: number }[];
- generatedAt: string;
-}
-
-let cache: { at: number; data: Stats } | null = null;
-const TTL_MS = 20_000;
-
-export async function computeStats(ctx: AppContext, force = false): Promise {
- 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 d8fe66b..46cbd89 100644
--- a/backend/src/test/fakeNocodb.ts
+++ b/backend/src/test/fakeNocodb.ts
@@ -41,17 +41,6 @@ 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;
@@ -97,13 +86,14 @@ export function fakeContext(db: FakeNocoDB): AppContext {
export async function seedTicket(
db: FakeNocoDB,
- opts: { code: string; name?: string; email?: string; adults?: number; redeemed?: number },
+ opts: { code: string; name?: string; email?: string; ages?: Record; 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 be0a4f1..2287a11 100644
--- a/backend/src/test/fields.test.ts
+++ b/backend/src/test/fields.test.ts
@@ -2,52 +2,45 @@ 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 age brackets but excludes Ages 0-3 (free)", () => {
const rec = {
Id: 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
+ "Ages 0-3": 2, // free, not counted
+ "Ages 4-7": 1,
+ "Ages 18-25": 2,
+ "Ages 26-45": 1,
};
- expect(computeTotal(rec)).toBe(3);
+ expect(computeTotal(rec)).toBe(4);
});
it("coerces string counts and treats blanks as 0", () => {
- const rec = { Id: 1, [COL.adults]: "3", [COL.youth]: "" } as any;
+ const rec = { Id: 1, "Ages 18-25": "3", "Ages 26-45": "" } as any;
expect(computeTotal(rec)).toBe(3);
});
});
describe("toView", () => {
- it("derives remaining and surfaces adult names, donor tier, and extras", () => {
+ it("derives remaining and surfaces 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",
- [COL.donorTier]: "member",
- [COL.vouchers]: 2,
+ "Ages 0-3": 1,
+ "Ages 18-25": 2,
+ "Ages 26-45": 3,
};
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.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);
+ expect(v.extras.freeUnder4).toBe(1);
+ expect(v.ages.find((a) => a.bracket === "0-3")?.free).toBe(true);
});
});
diff --git a/backend/src/test/fluentforms.test.ts b/backend/src/test/fluentforms.test.ts
deleted file mode 100644
index 9023e24..0000000
--- a/backend/src/test/fluentforms.test.ts
+++ /dev/null
@@ -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, 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 b2255e1..72738a6 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", adults: 4 });
+ await seedTicket(db, { code: "BC26-AAAA-1111", ages: { "Ages 26-45": 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", adults: 5 });
+ await seedTicket(db, { code: "BC26-FAM-0001", ages: { "Ages 0-3": 2, "Ages 26-45": 2, "Ages 8-12": 3 } });
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", adults: 2 });
+ await seedTicket(db, { code: "BC26-BBBB-2222", ages: { "Ages 26-45": 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", adults: 3, redeemed: 2 });
+ await seedTicket(db, { code: "BC26-CCCC-3333", ages: { "Ages 26-45": 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", adults: 4 });
+ await seedTicket(db, { code: "BC26-AUDT-0001", ages: { "Ages 26-45": 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", adults: 3, redeemed: 0 });
+ await seedTicket(db, { code: "BC26-AUDT-0002", ages: { "Ages 26-45": 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",
- adults: 2,
+ ages: { "Ages 26-45": 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", adults: 3 });
+ await seedTicket(db, { code: "BC26-DDDD-4444", ages: { "Ages 26-45": 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", adults: 5 });
+ await seedTicket(db, { code: "BC26-RACE-0005", ages: { "Ages 26-45": 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", adults: 3 });
+ await seedTicket(db, { code: "BC26-LOOK-0001", ages: { "Ages 26-45": 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",
- counts: { adults: 2, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
+ ages: { "Ages 26-45": 2 },
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
deleted file mode 100644
index 497887a..0000000
--- a/backend/src/test/vouchers.test.ts
+++ /dev/null
@@ -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
- });
-});
diff --git a/backend/src/ticketService.ts b/backend/src/ticketService.ts
index 040ae07..76fa4dc 100644
--- a/backend/src/ticketService.ts
+++ b/backend/src/ticketService.ts
@@ -128,21 +128,15 @@ export async function search(ctx: AppContext, query: string): Promise; // NocoDB age-column title -> count
submissionKey: string;
}
@@ -164,31 +158,20 @@ 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 dc62d8d..20e1aa0 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -3,9 +3,7 @@ services:
build: .
image: camptickets:latest
container_name: camptickets
- # 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
+ env_file: .env
environment:
# Container always listens on 8080 internally; the host mapping below is
# what nginx proxies to. Keep this fixed regardless of .env PORT.
@@ -19,11 +17,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:
diff --git a/docs/fluentforms-donor-discount.md b/docs/fluentforms-donor-discount.md
deleted file mode 100644
index 6872a0f..0000000
--- a/docs/fluentforms-donor-discount.md
+++ /dev/null
@@ -1,152 +0,0 @@
-# FluentForms → donor lookup APIs
-
-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
-
-```
-GET https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=&email=
-```
-
-- `key` = the value of `PUBLIC_LOOKUP_SECRET` (set in the backend `.env`).
-- Returns minimal JSON — never names or dollar amounts:
- - `{"eligible": true, "tier": "member"}`
- - `{"eligible": true, "tier": "donor"}`
- - `{"eligible": false, "tier": null}`
-- Rate-limited (30/min/IP) and CORS-restricted to `PUBLIC_LOOKUP_ORIGIN`
- (default `https://tickets.beartariacampgrounds.com`).
-
-> The secret is visible in page source, so treat this as *deterrence, not
-> security*. It only gates a discount and reveals a yes/no + tier, so the blast
-> radius is small. Rotate the secret by changing `PUBLIC_LOOKUP_SECRET` and
-> redeploying.
-
-## Form setup (conditional pricing)
-
-The idea: a **hidden field** `donor_tier` holds `regular` / `donor` / `member`.
-The JS sets it from the email lookup, and your payment options are shown/hidden
-by FluentForms conditional logic based on its value.
-
-1. **Hidden field.** Add a *Hidden Field*, name it exactly `donor_tier`, default
- value `regular`.
-2. **Email field.** Note its name (default `email`).
-3. **Payment options.** Set up two payment items (or two options of a
- multiple-choice payment field) — a regular price and a discounted price — and
- give each **conditional logic**:
- - **Regular price:** show when `donor_tier` **is** `regular`
- - **Donor price:** show when `donor_tier` **is** `donor` **OR** `donor_tier`
- **is** `member` (add both rules with "match any").
-4. **Custom HTML.** Add a *Custom HTML* element and paste the snippet below,
- setting `KEY` to your `PUBLIC_LOOKUP_SECRET` (and `EMAIL_SELECTOR` if your
- email field isn't named `email`).
-
-> FluentForms is Vue-driven, so a plain `input.value = …` won't update its
-> model and conditional logic won't fire. The snippet uses the native value
-> setter + dispatches `input`/`change`, which is the reliable way to make FF
-> notice a programmatic change. Test on your form; if conditional logic still
-> doesn't react, tell me your FF version and I'll adapt.
-
-## Snippet
-
-```html
-
-
-```
-
-## Test
-
-```
-curl "https://scan.beartariacampgrounds.com/api/public/donor-eligibility?key=&email="
-# donor/member -> {"eligible":true,"tier":"member"}
-# anyone else -> {"eligible":false,"tier":null}
-```
-
-If member and donor get the **same** discounted price, simplify: set the donor
-price to show when `donor_tier` **is not** `regular`, and you can ignore the
-member/donor distinction.
-
----
-
-## Ticket-voucher entitlement
-
-The donor **ticket-voucher lookup** (how many free tickets a donor earned) is a
-separate endpoint documented on its own page:
-[`fluentforms-ticket-vouchers.md`](./fluentforms-ticket-vouchers.md).
diff --git a/docs/fluentforms-ticket-vouchers.md b/docs/fluentforms-ticket-vouchers.md
deleted file mode 100644
index af66c45..0000000
--- a/docs/fluentforms-ticket-vouchers.md
+++ /dev/null
@@ -1,131 +0,0 @@
-# FluentForms → ticket-voucher lookup
-
-Look up, by email, how many **free tickets** a donor has earned from their
-giving. Intended for the ticket-rewards / checkout form: enter an email, call
-this endpoint, and show / apply the earned vouchers.
-
-FluentForms can't query an external database from a field natively, so this is
-done with a small Custom JS block that calls a secret-gated endpoint on the
-ticketing backend.
-
-## Endpoint
-
-```
-GET https://scan.beartariacampgrounds.com/api/public/ticket-vouchers?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
deleted file mode 100755
index 862ad54..0000000
--- a/scripts/switch-event.sh
+++ /dev/null
@@ -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 [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
deleted file mode 100755
index 58fc3a8..0000000
--- a/scripts/wipe-slate.sh
+++ /dev/null
@@ -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."