Vendor webhooks, free kids through 12, multi-origin lookup CORS

Children now free through age 12:
- Scannable/paid ticket total = adults + youth 13-16 only; kids 12 &
  under (0-4, 5-9, 10-12) are stored but not counted (charging starts
  at 13). computeTotal + freeKidsCount in fields.ts, webhook guard,
  scan/admin badges, event-report labels, docs, and personas updated.

Vendor booth webhooks (vendors.beartariacampgrounds.com):
- New /vendor-webhook/food (2 named pass-holders) and
  /vendor-webhook/non-food (1 pass-holder), reusing WEBHOOK_SECRET.
  Booth name -> ticket title; each named person = one entry pass;
  tagged with a "Food Vendor"/"Vendor" Ticket Type (badge on scan +
  event-report rollup). Idempotent + QR email like the attendee hook.
- Extracted shared FluentForms parsing (nameGroup/qty/selected/
  addressLine/readDonor) into fluentforms.ts; attendee webhook now
  imports it. 13 new unit tests.

Public lookup CORS is now a comma-separated allowlist; the caller's
Origin is echoed only if it matches. tickets + vendors both allowed
on donor-eligibility and ticket-vouchers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-16 05:13:07 +00:00
parent 43fddec286
commit 7296555964
15 changed files with 368 additions and 91 deletions

View file

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

View file

@ -346,7 +346,7 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) {
if (e.rvParking) tags.push("🚐 RV parking"); if (e.rvParking) tags.push("🚐 RV parking");
if (e.utv) tags.push("🏍️ UTV/ATV"); if (e.utv) tags.push("🏍️ UTV/ATV");
if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`); if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`);
if (e.freeUnder5 > 0) tags.push(`👶 ${e.freeUnder5} under 5 (free)`); if (e.freeKids > 0) tags.push(`👶 ${e.freeKids} ${e.freeKids === 1 ? "kid" : "kids"} 12 & under (free)`);
if (!tags.length) return null; if (!tags.length) return null;
return ( return (
<View style={styles.tags}> <View style={styles.tags}>
@ -365,6 +365,8 @@ const TYPE_ICON: Record<string, string> = {
Performer: "🎭", Performer: "🎭",
Volunteer: "🙌", Volunteer: "🙌",
Speaker: "🎤", Speaker: "🎤",
"Food Vendor": "🍔",
Vendor: "🛒",
}; };
function TypeBadge({ type }: { type: string }) { function TypeBadge({ type }: { type: string }) {

View file

@ -13,6 +13,8 @@ const TYPE_ICON: Record<string, string> = {
Performer: "🎭", Performer: "🎭",
Volunteer: "🙌", Volunteer: "🙌",
Speaker: "🎤", Speaker: "🎤",
"Food Vendor": "🍔",
Vendor: "🛒",
}; };
const MEDAL = ["🥇", "🥈", "🥉"]; const MEDAL = ["🥇", "🥈", "🥉"];
@ -138,9 +140,9 @@ export default function StatsScreen() {
<View style={styles.card}> <View style={styles.card}>
<Text style={styles.cardTitle}>Who's coming</Text> <Text style={styles.cardTitle}>Who's coming</Text>
<View style={styles.tileRow}> <View style={styles.tileRow}>
<Tile value={stats.people.adults} label="adults" /> <Tile value={stats.people.adults} label="adults (paid)" />
<Tile value={stats.people.youth} label="youth 13-16" /> <Tile value={stats.people.youth} label="youth 13-16 (paid)" />
<Tile value={stats.people.kids12 + stats.people.kids9} label="kids 5-12" /> <Tile value={stats.people.kids12 + stats.people.kids9} label="kids 5-12 (free)" />
<Tile value={stats.people.kids4Free} label="under 5 (free)" /> <Tile value={stats.people.kids4Free} label="under 5 (free)" />
</View> </View>
</View> </View>

View file

@ -36,7 +36,7 @@ export interface TicketView {
isDonor: boolean; isDonor: boolean;
donorTier: string; donorTier: string;
vouchers: number; vouchers: number;
freeUnder5: number; freeKids: number;
}; };
ages: { bracket: string; count: number; free: boolean }[]; ages: { bracket: string; count: number; free: boolean }[];
} }

View file

@ -27,7 +27,17 @@ const schema = z.object({
// Disabled unless a secret is set. Returns only eligibility + tier, never // Disabled unless a secret is set. Returns only eligibility + tier, never
// names or dollar amounts. Rate-limited + CORS-restricted. // names or dollar amounts. Rate-limited + CORS-restricted.
PUBLIC_LOOKUP_SECRET: z.string().optional(), PUBLIC_LOOKUP_SECRET: z.string().optional(),
PUBLIC_LOOKUP_ORIGIN: z.string().default("https://tickets.beartariacampgrounds.com"), // Comma-separated allowlist of browser origins permitted to call the public
// lookups (the request's Origin is echoed back only if it matches one).
PUBLIC_LOOKUP_ORIGIN: z
.string()
.default("https://tickets.beartariacampgrounds.com,https://vendors.beartariacampgrounds.com")
.transform((s) =>
s
.split(",")
.map((o) => o.trim().replace(/\/+$/, ""))
.filter(Boolean),
),
// Ticket-voucher entitlement: donations on/after VOUCHER_SINCE totalling // Ticket-voucher entitlement: donations on/after VOUCHER_SINCE totalling
// >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the date each year. // >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the date each year.

View file

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

View file

@ -0,0 +1,68 @@
import { timingSafeEqual } from "node:crypto";
import { toBool, toNumber } from "./fields.js";
/** Constant-time string compare for shared webhook secrets. */
export function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a || "");
const bb = Buffer.from(b || "");
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
/** Read a FluentForms compound name field, given as a nested object
* (`names: {first_name,...}`) or flattened bracket keys (`names[first_name]`). */
export function nameGroup(body: Record<string, any>, base: string): string {
const obj = body[base];
let first: any, middle: any, last: any;
if (obj && typeof obj === "object") {
({ first_name: first, middle_name: middle, last_name: last } = obj);
} else {
first = body[`${base}[first_name]`];
middle = body[`${base}[middle_name]`];
last = body[`${base}[last_name]`];
}
return [first, middle, last]
.map((x) => (x == null ? "" : String(x).trim()))
.filter(Boolean)
.join(" ");
}
/** Read an item_quantity / payment field's numeric value (handles nested
* objects like {quantity} / {value} and money strings like "$40.00"). */
export function qty(v: any): number {
if (v == null || v === "") return 0;
if (typeof v === "object") return toNumber(v.quantity ?? v.value ?? v.item_quantity ?? v.amount ?? 0);
if (typeof v === "string") return toNumber(v.replace(/[^0-9.\-]/g, ""));
return toNumber(v);
}
/** A payment/extra field counts as "selected" if it has a meaningful value.
* Donor (free) items can be $0, so a non-empty, non-"no"/"0" value also counts. */
export function selected(v: any): boolean {
if (v == null || v === "") return false;
if (typeof v === "object") {
if ("selected" in v) return toBool((v as any).selected);
return qty(v) > 0 || Object.keys(v).length > 0;
}
const s = String(v).trim().toLowerCase();
if (!s || s === "no" || s === "0" || s === "$0" || s === "$0.00" || s === "false" || s === "none") return false;
return true;
}
/** Flatten a FluentForms compound address (`address_1`) to a single line. */
export function addressLine(v: any): string | undefined {
if (v && typeof v === "object") return Object.values(v).filter(Boolean).join(", ");
if (v !== undefined) return String(v);
return undefined;
}
/** Donor status from the hidden lookup fields + the "are you a donor?" radio. */
export function readDonor(body: Record<string, any>): { isDonor: boolean; donorTier: string } {
const donorTier = String(body.donor_tier ?? "").trim();
const isDonor =
donorTier === "member" ||
donorTier === "donor" ||
toBool(body.donor_eligible) ||
selected(body.input_radio); // "Are you a campground donor?"
return { isDonor, donorTier };
}

View file

@ -17,17 +17,21 @@ function safeEqual(a: string, b: string): boolean {
*/ */
export async function publicLookupRoutes(app: FastifyInstance): Promise<void> { export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
const cfg = app.ctx.config; const cfg = app.ctx.config;
const origin = cfg.PUBLIC_LOOKUP_ORIGIN; const allowed = cfg.PUBLIC_LOOKUP_ORIGIN; // string[] allowlist
const cors = (reply: any) => { const cors = (req: any, reply: any) => {
const reqOrigin = String(req.headers?.origin ?? "").replace(/\/+$/, "");
// Echo the caller's origin only if it's on the allowlist; otherwise fall
// back to the first configured origin (keeps non-browser callers working).
const origin = allowed.includes(reqOrigin) ? reqOrigin : allowed[0];
reply.header("Access-Control-Allow-Origin", origin); reply.header("Access-Control-Allow-Origin", origin);
reply.header("Vary", "Origin"); reply.header("Vary", "Origin");
reply.header("Access-Control-Allow-Methods", "GET, OPTIONS"); reply.header("Access-Control-Allow-Methods", "GET, OPTIONS");
}; };
// Preflight (in case the form sends one). // Preflight (in case the form sends one).
const preflight = async (_req: any, reply: any) => { const preflight = async (req: any, reply: any) => {
cors(reply); cors(req, reply);
return reply.code(204).send(); return reply.code(204).send();
}; };
app.options("/api/public/donor-eligibility", preflight); app.options("/api/public/donor-eligibility", preflight);
@ -42,7 +46,7 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
"/api/public/donor-eligibility", "/api/public/donor-eligibility",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
cors(reply); cors(req, reply);
// Disabled unless configured. // Disabled unless configured.
if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) { if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) {
return reply.code(404).send({ error: "not_available" }); return reply.code(404).send({ error: "not_available" });
@ -72,7 +76,7 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
"/api/public/ticket-vouchers", "/api/public/ticket-vouchers",
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } },
async (req, reply) => { async (req, reply) => {
cors(reply); cors(req, reply);
if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) { if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) {
return reply.code(404).send({ error: "not_available" }); return reply.code(404).send({ error: "not_available" });
} }

View file

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

View file

@ -0,0 +1,133 @@
import { createHash } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { createTicket } from "../ticketService.js";
import { renderQrPng } from "../services/qrcode.js";
import { safeEqual, nameGroup, addressLine, readDonor } from "../fluentforms.js";
/**
* Vendor booth webhooks (Vendor Fee Food / Non-Food 2026, on
* vendors.beartariacampgrounds.com). Structurally these are the same form; the
* only difference is how many entry passes a booth includes:
*
* - Food: two named pass-holders (`names` = "Name Ticket 1",
* `names_1` = "Name Ticket #2") up to 2 passes.
* - Non-Food: one named pass-holder (`names`) 1 pass.
*
* Each named person gets one gate ticket. The booth name becomes the ticket
* title (so gate staff see the booth) and the pass-holders are stored as the
* attendee names. The ticket is tagged with a vendor `Ticket Type` so it shows
* a badge on scan and rolls up in the event report. Booth size / additional
* space are logistics, not admissions, so they don't affect the pass count.
*
* Shares WEBHOOK_SECRET with the attendee webhook (same X-Webhook-Secret header).
*/
interface VendorKind {
ticketType: string; // badge label shown on scan
nameSlots: string[]; // pass-holder name field bases, in order
}
const KINDS: Record<"food" | "nonfood", VendorKind> = {
food: { ticketType: "Food Vendor", nameSlots: ["names", "names_1"] },
nonfood: { ticketType: "Vendor", nameSlots: ["names"] },
};
function makeHandler(app: FastifyInstance, kind: VendorKind) {
return async (req: any, reply: any) => {
const secret = req.headers["x-webhook-secret"];
if (typeof secret !== "string" || !safeEqual(secret, app.ctx.config.WEBHOOK_SECRET)) {
return reply.code(401).send({ error: "unauthorized" });
}
const body = (req.body ?? {}) as Record<string, any>;
const boothName = String(body.input_text ?? "").trim();
// Pass-holder names (non-empty slots, in order).
const passHolders = kind.nameSlots.map((b) => nameGroup(body, b)).filter(Boolean);
const primary = passHolders[0] ?? "";
// Ticket title = booth name (most useful at the gate), else the first person.
const title = boothName || primary;
if (!title) {
return reply.code(400).send({ error: "missing_fields", detail: "booth name or vendor name is required" });
}
const email = String(body.email ?? "").trim();
// One entry pass per named person; a booth with no names still gets 1.
const passes = Math.max(1, passHolders.length);
const { isDonor, donorTier } = readDonor(body);
const address = addressLine(body.address_1);
// Idempotency: prefer a stable submission id, else hash the content.
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id;
const submissionKey = submissionId
? `sub:${String(submissionId)}`
: "hash:" +
createHash("sha256")
.update(`vendor|${kind.ticketType}|${email}|${title}|${passes}`)
.digest("hex")
.slice(0, 32);
// Vendor passes are adult admissions; no youth/kids/ice/parking.
const counts = { adults: passes, youth: 0, kids12: 0, kids9: 0, kids4: 0 };
let result: Awaited<ReturnType<typeof createTicket>>;
try {
result = await createTicket(app.ctx, {
name: title,
adultNames: passHolders,
email,
address,
isDonor,
donorTier,
ticketType: kind.ticketType,
counts,
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
submissionKey,
});
} catch (e: any) {
req.log.error({ err: e }, "vendor webhook: failed to create ticket");
return reply.code(502).send({ error: "db_error", detail: e?.message });
}
if (result.status === "duplicate") {
return { status: "duplicate", code: result.code };
}
// Email the ticket QR (FluentForms sends the receipt separately).
if (!email) {
req.log.warn({ code: result.code }, "vendor webhook: ticket created but no email");
return { status: "created", code: result.code, passes, emailSent: false, emailSkipped: "no_email" };
}
if (app.ctx.mailer.isBlockedRecipient(email)) {
req.log.warn({ email }, "vendor webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send");
return { status: "created", code: result.code, passes, emailSent: false, emailSkipped: "trial_restriction" };
}
try {
const qr = await renderQrPng(result.code);
await app.ctx.mailer.sendTicket({
toEmail: email,
toName: primary || title,
code: result.code,
quantity: passes,
qrPng: qr,
});
} catch (e: any) {
req.log.error({ err: e, code: result.code }, "vendor webhook: created but email failed");
return reply.code(502).send({ status: "created", code: result.code, passes, emailSent: false, error: e?.message });
}
return { status: "created", code: result.code, passes, emailSent: true };
};
}
export async function vendorWebhookRoutes(app: FastifyInstance): Promise<void> {
// Configure these URLs in the two FluentForms vendor forms:
// Food: https://scan.beartariacampgrounds.com/vendor-webhook/food
// Non-Food: https://scan.beartariacampgrounds.com/vendor-webhook/non-food
app.post("/vendor-webhook/food", makeHandler(app, KINDS.food));
app.post("/vendor-webhook/non-food", makeHandler(app, KINDS.nonfood));
// Explicit API aliases.
app.post("/api/webhook/vendor-food", makeHandler(app, KINDS.food));
app.post("/api/webhook/vendor-non-food", makeHandler(app, KINDS.nonfood));
}

View file

@ -1,55 +1,9 @@
import { createHash, timingSafeEqual } from "node:crypto"; import { createHash } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { toBool, toNumber } from "../fields.js"; import { toBool } from "../fields.js";
import { createTicket } from "../ticketService.js"; import { createTicket } from "../ticketService.js";
import { renderQrPng } from "../services/qrcode.js"; import { renderQrPng } from "../services/qrcode.js";
import { safeEqual, nameGroup, qty, selected, addressLine } from "../fluentforms.js";
function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a || "");
const bb = Buffer.from(b || "");
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
/** Read a FluentForms compound name field, given as a nested object
* (`names: {first_name,...}`) or flattened bracket keys (`names[first_name]`). */
function nameGroup(body: Record<string, any>, base: string): string {
const obj = body[base];
let first: any, middle: any, last: any;
if (obj && typeof obj === "object") {
({ first_name: first, middle_name: middle, last_name: last } = obj);
} else {
first = body[`${base}[first_name]`];
middle = body[`${base}[middle_name]`];
last = body[`${base}[last_name]`];
}
return [first, middle, last]
.map((x) => (x == null ? "" : String(x).trim()))
.filter(Boolean)
.join(" ");
}
/** Read an item_quantity / payment field's numeric value (handles nested
* objects like {quantity} / {value} and money strings like "$40.00"). */
function qty(v: any): number {
if (v == null || v === "") return 0;
if (typeof v === "object") return toNumber(v.quantity ?? v.value ?? v.item_quantity ?? v.amount ?? 0);
if (typeof v === "string") return toNumber(v.replace(/[^0-9.\-]/g, ""));
return toNumber(v);
}
/** A payment/extra field counts as "selected" if it has a meaningful value.
* Donor (free) items can be $0, so a non-empty, non-"no"/"0" value also counts. */
function selected(v: any): boolean {
if (v == null || v === "") return false;
if (typeof v === "object") {
if ("selected" in v) return toBool((v as any).selected);
return qty(v) > 0 || Object.keys(v).length > 0;
}
const s = String(v).trim().toLowerCase();
if (!s || s === "no" || s === "0" || s === "$0" || s === "$0.00" || s === "false" || s === "none") return false;
return true;
}
// Adult name field bases, in order (purchaser first). // Adult name field bases, in order (purchaser first).
const ADULT_NAME_BASES = ["names", "names_1", "names_2", "names_3", "names_4", "names_5", "names_6", "names_7", "names_8", "names_9"]; const ADULT_NAME_BASES = ["names", "names_1", "names_2", "names_3", "names_4", "names_5", "names_6", "names_7", "names_8", "names_9"];
@ -81,11 +35,13 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
kids9: qty(body.item_quantity_kids_9), kids9: qty(body.item_quantity_kids_9),
kids4: qty(body.item_quantity_kids_4), kids4: qty(body.item_quantity_kids_4),
}; };
const scannable = counts.adults + counts.youth + counts.kids12 + counts.kids9; // Paid/scannable admissions = adults + youth 13-16. Children 12 & under are
// free (charging starts at 13) and are stored but not counted at the gate.
const scannable = counts.adults + counts.youth;
if (scannable <= 0) { if (scannable <= 0) {
// Nothing to check in at the gate. Log the payload so we can calibrate. // Nothing to check in at the gate. Log the payload so we can calibrate.
req.log.warn({ body }, "webhook: no scannable tickets in submission"); req.log.warn({ body }, "webhook: no scannable tickets in submission");
return reply.code(400).send({ error: "no_tickets", detail: "no scannable tickets (adults/youth/kids 5+)" }); return reply.code(400).send({ error: "no_tickets", detail: "no paid tickets (adults / youth 13-16)" });
} }
// Donor info (hidden fields from the eligibility/voucher lookups) + radio. // Donor info (hidden fields from the eligibility/voucher lookups) + radio.
@ -108,12 +64,7 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
const iceBags = Math.max(0, iceTickets) * app.ctx.config.ICE_BAGS_PER_TICKET; const iceBags = Math.max(0, iceTickets) * app.ctx.config.ICE_BAGS_PER_TICKET;
const iceAccess = iceBags > 0 || selected(body.input_radio_7); const iceAccess = iceBags > 0 || selected(body.input_radio_7);
const address = const address = addressLine(body.address_1);
body.address_1 && typeof body.address_1 === "object"
? Object.values(body.address_1).filter(Boolean).join(", ")
: body.address_1 !== undefined
? String(body.address_1)
: undefined;
// Idempotency: prefer a stable submission id, else hash the content. // Idempotency: prefer a stable submission id, else hash the content.
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id; const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id;

View file

@ -18,9 +18,9 @@ const FIELDS: Field[] = [
{ key: "item_quantity_adult_ticket_reg", req: "required", type: "quantity", desc: "Adult tickets (regular)." }, { key: "item_quantity_adult_ticket_reg", req: "required", type: "quantity", desc: "Adult tickets (regular)." },
{ key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "Adult tickets (donor). Added to the regular adults." }, { key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "Adult tickets (donor). Added to the regular adults." },
{ key: "item_quantity_youth_ticket_reg / _donor", req: "optional", type: "quantity", desc: "Youth 13-16 tickets (regular + donor)." }, { key: "item_quantity_youth_ticket_reg / _donor", req: "optional", type: "quantity", desc: "Youth 13-16 tickets (regular + donor)." },
{ key: "item_quantity_kids_12", req: "optional", type: "quantity", desc: "Kids 10-12. Counts toward the scannable total." }, { key: "item_quantity_kids_12", req: "optional", type: "quantity", desc: "Kids 10-12. FREE — stored but NOT counted toward the scannable ticket total." },
{ key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. Counts toward the scannable total." }, { key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. FREE — stored but NOT counted toward the scannable ticket total." },
{ key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE — NOT counted toward the scannable ticket total." }, { key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE — stored but NOT counted toward the scannable ticket total." },
{ key: "donor_tier", req: "optional", type: "hidden", desc: "member / donor / empty (from the donor-eligibility lookup)." }, { key: "donor_tier", req: "optional", type: "hidden", desc: "member / donor / empty (from the donor-eligibility lookup)." },
{ key: "donor_eligible", req: "optional", type: "hidden", desc: "true / false (from the donor-eligibility lookup)." }, { key: "donor_eligible", req: "optional", type: "hidden", desc: "true / false (from the donor-eligibility lookup)." },
{ key: "vouchers", req: "optional", type: "hidden", desc: "Integer voucher count (from the ticket-voucher lookup)." }, { key: "vouchers", req: "optional", type: "hidden", desc: "Integer voucher count (from the ticket-voucher lookup)." },
@ -118,7 +118,7 @@ const PAGE = `<!doctype html>
<h2>What it does</h2> <h2>What it does</h2>
<p>On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject <b>"2026 Beartaria Campgrounds Tickets"</b>). FluentForms sends the payment receipt separately.</p> <p>On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject <b>"2026 Beartaria Campgrounds Tickets"</b>). FluentForms sends the payment receipt separately.</p>
<p><b>Scannable ticket total</b> = adults + youth (13-16) + kids 10-12 + kids 5-9. <b>Kids 0-4 are free</b> and not counted. Each adult name provided is stored and shown to gate staff on a successful scan.</p> <p><b>Scannable ticket total</b> = adults + youth (13-16). <b>Children 12 &amp; under are free</b> (charging starts at 13) their counts are stored and shown to gate staff, but not counted toward the ticket total. Each adult name provided is stored and shown on a successful scan.</p>
<h2>Fields</h2> <h2>Fields</h2>
<table> <table>
@ -132,7 +132,7 @@ const PAGE = `<!doctype html>
<h2>Example payload</h2> <h2>Example payload</h2>
<pre><code>${exampleJson}</code></pre> <pre><code>${exampleJson}</code></pre>
<p class="sub">This issues 5 scannable tickets (2 adults + 1 youth + 2 kids 5-9; the two kids 0-4 are free), member donor with 2 vouchers, car parking, and 6 bags of ice (2 ice tickets).</p> <p class="sub">This issues 3 scannable tickets (2 adults + 1 youth 13-16; all four kids 12 &amp; under are free), member donor with 2 vouchers, car parking, and 6 bags of ice (2 ice tickets).</p>
<h2>Test with curl</h2> <h2>Test with curl</h2>
<pre><code>${exampleCurl}</code></pre> <pre><code>${exampleCurl}</code></pre>
@ -158,6 +158,21 @@ const PAGE = `<!doctype html>
<li>Save, submit a test purchase, and confirm the QR email arrives.</li> <li>Save, submit a test purchase, and confirm the QR email arrives.</li>
</ol> </ol>
<h2>Vendor booth webhooks</h2>
<p class="sub">The two vendor forms on <b>vendors.beartariacampgrounds.com</b> post to their own endpoints (same <code>X-Webhook-Secret</code>). Each <b>named</b> booth person gets one entry pass; the booth name (<code>input_text</code>) becomes the ticket title, and the ticket is tagged with a vendor <b>Ticket Type</b> that shows a badge on scan and rolls up in the event report. Booth size / additional space are logistics and don't affect passes.</p>
<table>
<thead><tr><th>Form</th><th>Endpoint</th><th>Passes</th><th>Ticket Type</th></tr></thead>
<tbody>
<tr><td>Vendor Fee Food 2026</td><td><code>POST /vendor-webhook/food</code></td><td>up to 2 (<code>names</code> + <code>names_1</code>)</td><td>🍔 Food Vendor</td></tr>
<tr><td>Vendor Fee Non-Food 2026</td><td><code>POST /vendor-webhook/non-food</code></td><td>1 (<code>names</code>)</td><td>🛒 Vendor</td></tr>
</tbody>
</table>
<p class="sub">Relevant keys: <code>input_text</code> (Booth Name), <code>names</code> / <code>names_1</code> (pass-holders), <code>email</code>, <code>address_1</code>, <code>donor_tier</code> / <code>donor_eligible</code> / <code>input_radio</code> (donor), <code>payment_method</code>. Same idempotency (<code>id</code>/<code>submission_id</code>) and response shapes as above, plus a <code>passes</code> count.</p>
<pre><code>curl -X POST https://scan.beartariacampgrounds.com/vendor-webhook/food \\
-H "Content-Type: application/json" \\
-H "X-Webhook-Secret: &lt;your WEBHOOK_SECRET&gt;" \\
-d '{"id":"v-101","input_text":"Joe'\\''s Tacos","names":{"first_name":"Joe","last_name":"Taco"},"names_1":{"first_name":"Jane","last_name":"Taco"},"email":"joe@example.com","donor_tier":"member","payment_method":"stripe"}'</code></pre>
<footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer> <footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer>
</div> </div>
</body> </body>

View file

@ -9,6 +9,7 @@ import { loadConfig } from "./config.js";
import { buildContext } from "./context.js"; import { buildContext } from "./context.js";
import { authRoutes } from "./routes/auth.js"; import { authRoutes } from "./routes/auth.js";
import { webhookRoutes } from "./routes/webhook.js"; import { webhookRoutes } from "./routes/webhook.js";
import { vendorWebhookRoutes } from "./routes/vendorWebhook.js";
import { ticketRoutes } from "./routes/tickets.js"; import { ticketRoutes } from "./routes/tickets.js";
import { testRoutes } from "./routes/test.js"; import { testRoutes } from "./routes/test.js";
import { installRoutes } from "./routes/install.js"; import { installRoutes } from "./routes/install.js";
@ -32,6 +33,7 @@ export async function build() {
await app.register(authRoutes); await app.register(authRoutes);
await app.register(webhookRoutes); await app.register(webhookRoutes);
await app.register(vendorWebhookRoutes);
await app.register(ticketRoutes); await app.register(ticketRoutes);
await app.register(testRoutes); await app.register(testRoutes);
await app.register(installRoutes); await app.register(installRoutes);

View file

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

View file

@ -0,0 +1,84 @@
import { describe, it, expect } from "vitest";
import { nameGroup, qty, selected, addressLine, readDonor } from "../fluentforms.js";
// Pass-holder name slots per the two vendor forms.
const FOOD_SLOTS = ["names", "names_1"];
const NONFOOD_SLOTS = ["names"];
/** Mirror the vendor handler's pass count: one per named person, min 1. */
function passCount(body: Record<string, any>, slots: string[]): number {
const holders = slots.map((b) => nameGroup(body, b)).filter(Boolean);
return Math.max(1, holders.length);
}
describe("nameGroup", () => {
it("reads flattened bracket keys", () => {
const body = { "names[first_name]": "Joe", "names[last_name]": "Taco" };
expect(nameGroup(body, "names")).toBe("Joe Taco");
});
it("reads a nested object and includes the middle name", () => {
const body = { names: { first_name: "Ann", middle_name: "B", last_name: "Cole" } };
expect(nameGroup(body, "names")).toBe("Ann B Cole");
});
it("returns empty string when the group is blank", () => {
expect(nameGroup({}, "names_1")).toBe("");
});
});
describe("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("non-food booth gets 1 pass (only one name slot)", () => {
const body = {
input_text: "Craft Corner",
"names[first_name]": "Pat",
"names[last_name]": "Maker",
// a stray names_1 must NOT count for non-food
"names_1[first_name]": "Ignore",
};
expect(passCount(body, NONFOOD_SLOTS)).toBe(1);
});
it("booth with no names still gets 1 pass", () => {
expect(passCount({ input_text: "Nameless Booth" }, FOOD_SLOTS)).toBe(1);
});
});
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");
});
});