diff --git a/app/app/admin.tsx b/app/app/admin.tsx index 92b4052..4d1a277 100644 --- a/app/app/admin.tsx +++ b/app/app/admin.tsx @@ -209,7 +209,7 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti if (e.rvParking) tags.push("๐Ÿš RV"); if (e.utv) tags.push("๐Ÿ๏ธ UTV"); if (e.iceAccess || ticket.ice.total > 0) tags.push(`๐ŸงŠ ${ticket.ice.remaining}/${ticket.ice.total}`); - if (e.freeUnder5 > 0) tags.push(`๐Ÿ‘ถ ${e.freeUnder5} free`); + if (e.freeKids > 0) tags.push(`๐Ÿ‘ถ ${e.freeKids} free kids`); return ( diff --git a/app/app/index.tsx b/app/app/index.tsx index c3bcdbc..d3211da 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -346,7 +346,7 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) { if (e.rvParking) tags.push("๐Ÿš RV parking"); if (e.utv) tags.push("๐Ÿ๏ธ UTV/ATV"); if (e.iceAccess || ticket.ice.total > 0) tags.push(`๐ŸงŠ ${ticket.ice.remaining}/${ticket.ice.total} ice`); - if (e.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; return ( @@ -365,6 +365,8 @@ const TYPE_ICON: Record = { Performer: "๐ŸŽญ", Volunteer: "๐Ÿ™Œ", Speaker: "๐ŸŽค", + "Food Vendor": "๐Ÿ”", + Vendor: "๐Ÿ›’", }; function TypeBadge({ type }: { type: string }) { diff --git a/app/app/stats.tsx b/app/app/stats.tsx index 9c9428a..acd8c84 100644 --- a/app/app/stats.tsx +++ b/app/app/stats.tsx @@ -13,6 +13,8 @@ const TYPE_ICON: Record = { Performer: "๐ŸŽญ", Volunteer: "๐Ÿ™Œ", Speaker: "๐ŸŽค", + "Food Vendor": "๐Ÿ”", + Vendor: "๐Ÿ›’", }; const MEDAL = ["๐Ÿฅ‡", "๐Ÿฅˆ", "๐Ÿฅ‰"]; @@ -138,9 +140,9 @@ export default function StatsScreen() { Who's coming - - - + + + diff --git a/app/lib/api.ts b/app/lib/api.ts index ccc02e8..9f9d4f1 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -36,7 +36,7 @@ export interface TicketView { isDonor: boolean; donorTier: string; vouchers: number; - freeUnder5: number; + freeKids: number; }; ages: { bracket: string; count: number; free: boolean }[]; } diff --git a/backend/src/config.ts b/backend/src/config.ts index ef9f7f5..88954cf 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -27,7 +27,17 @@ const schema = z.object({ // Disabled unless a secret is set. Returns only eligibility + tier, never // names or dollar amounts. Rate-limited + CORS-restricted. PUBLIC_LOOKUP_SECRET: z.string().optional(), - 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 // >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the date each year. diff --git a/backend/src/fields.ts b/backend/src/fields.ts index 50c569d..ae4dbf9 100644 --- a/backend/src/fields.ts +++ b/backend/src/fields.ts @@ -51,11 +51,17 @@ function bool(v: unknown): boolean { } /** - * Total scannable tickets = everyone except kids 0-4 (who are free): - * adults + youth (13-16) + kids 10-12 + kids 5-9. + * Total scannable (paid) tickets = adults + youth 13-16. Children 12 and under + * (kids 10-12 / 5-9 / 0-4) are admitted free and not counted; charging starts + * at age 13. */ 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 { @@ -67,8 +73,8 @@ export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number; return [ { bracket: "Adults", count: num(rec[COL.adults]), free: false }, { bracket: "Youth 13-16", count: num(rec[COL.youth]), free: false }, - { bracket: "Kids 10-12", count: num(rec[COL.kids12]), free: false }, - { bracket: "Kids 5-9", count: num(rec[COL.kids9]), free: false }, + { bracket: "Kids 10-12", count: num(rec[COL.kids12]), free: true }, + { bracket: "Kids 5-9", count: num(rec[COL.kids9]), free: true }, { bracket: "Kids 0-4", count: num(rec[COL.kids4]), free: true }, ].filter((b) => b.count > 0); } @@ -111,7 +117,7 @@ export interface TicketView { isDonor: boolean; donorTier: string; vouchers: number; - freeUnder5: number; + freeKids: number; // children 12 & under (free admission) }; ages: { bracket: string; count: number; free: boolean }[]; } @@ -144,7 +150,7 @@ export function toView(rec: NocoRecord): TicketView { isDonor: bool(rec[COL.isDonor]), donorTier: String(rec[COL.donorTier] ?? ""), vouchers: num(rec[COL.vouchers]), - freeUnder5: num(rec[COL.kids4]), + freeKids: freeKidsCount(rec), }, ages: ageBreakdown(rec), }; diff --git a/backend/src/fluentforms.ts b/backend/src/fluentforms.ts new file mode 100644 index 0000000..de628a7 --- /dev/null +++ b/backend/src/fluentforms.ts @@ -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, 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): { 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 }; +} diff --git a/backend/src/routes/publicLookup.ts b/backend/src/routes/publicLookup.ts index c804b30..d1437f9 100644 --- a/backend/src/routes/publicLookup.ts +++ b/backend/src/routes/publicLookup.ts @@ -17,17 +17,21 @@ function safeEqual(a: string, b: string): boolean { */ export async function publicLookupRoutes(app: FastifyInstance): Promise { 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("Vary", "Origin"); reply.header("Access-Control-Allow-Methods", "GET, OPTIONS"); }; // Preflight (in case the form sends one). - const preflight = async (_req: any, reply: any) => { - cors(reply); + const preflight = async (req: any, reply: any) => { + cors(req, reply); return reply.code(204).send(); }; app.options("/api/public/donor-eligibility", preflight); @@ -42,7 +46,7 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise { "/api/public/donor-eligibility", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { - cors(reply); + cors(req, reply); // Disabled unless configured. if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) { return reply.code(404).send({ error: "not_available" }); @@ -72,7 +76,7 @@ export async function publicLookupRoutes(app: FastifyInstance): Promise { "/api/public/ticket-vouchers", { config: { rateLimit: { max: 30, timeWindow: "1 minute" } } }, async (req, reply) => { - cors(reply); + cors(req, reply); if (!cfg.PUBLIC_LOOKUP_SECRET || !app.ctx.donors.enabled) { return reply.code(404).send({ error: "not_available" }); } diff --git a/backend/src/routes/test.ts b/backend/src/routes/test.ts index 1096416..a82c0f3 100644 --- a/backend/src/routes/test.ts +++ b/backend/src/routes/test.ts @@ -37,11 +37,11 @@ const PERSONAS: Persona[] = [ name: "Family Fay", email: "family@test.beartaria", 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, carParking: true, 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", @@ -119,8 +119,8 @@ export async function testRoutes(app: FastifyInstance): Promise { // Keep the "exhausted" persona fully redeemed on every load so its state // is deterministic (total = scannable count from the persona's counts). if (p.exhaust) { - const { adults, youth, kids12, kids9 } = p.counts; - await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth + kids12 + kids9 }); + const { adults, youth } = p.counts; + await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth }); } cards.push({ code: result.code, diff --git a/backend/src/routes/vendorWebhook.ts b/backend/src/routes/vendorWebhook.ts new file mode 100644 index 0000000..95eb076 --- /dev/null +++ b/backend/src/routes/vendorWebhook.ts @@ -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; + + 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>; + 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 { + // 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)); +} diff --git a/backend/src/routes/webhook.ts b/backend/src/routes/webhook.ts index 9d96961..77d7522 100644 --- a/backend/src/routes/webhook.ts +++ b/backend/src/routes/webhook.ts @@ -1,55 +1,9 @@ -import { createHash, timingSafeEqual } from "node:crypto"; +import { createHash } from "node:crypto"; import type { FastifyInstance } from "fastify"; -import { toBool, toNumber } from "../fields.js"; +import { toBool } from "../fields.js"; import { createTicket } from "../ticketService.js"; import { renderQrPng } from "../services/qrcode.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, 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; -} +import { safeEqual, nameGroup, qty, selected, addressLine } from "../fluentforms.js"; // 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"]; @@ -81,11 +35,13 @@ export async function webhookRoutes(app: FastifyInstance): Promise { kids9: qty(body.item_quantity_kids_9), 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) { // Nothing to check in at the gate. Log the payload so we can calibrate. req.log.warn({ body }, "webhook: no scannable tickets in submission"); - return reply.code(400).send({ error: "no_tickets", detail: "no scannable tickets (adults/youth/kids 5+)" }); + 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. @@ -108,12 +64,7 @@ export async function webhookRoutes(app: FastifyInstance): Promise { const iceBags = Math.max(0, iceTickets) * app.ctx.config.ICE_BAGS_PER_TICKET; const iceAccess = iceBags > 0 || selected(body.input_radio_7); - const address = - body.address_1 && typeof body.address_1 === "object" - ? Object.values(body.address_1).filter(Boolean).join(", ") - : body.address_1 !== undefined - ? String(body.address_1) - : undefined; + const address = addressLine(body.address_1); // Idempotency: prefer a stable submission id, else hash the content. const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id; diff --git a/backend/src/routes/webhookDoc.ts b/backend/src/routes/webhookDoc.ts index 8f7bd8a..276062b 100644 --- a/backend/src/routes/webhookDoc.ts +++ b/backend/src/routes/webhookDoc.ts @@ -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_donor", req: "required", type: "quantity", desc: "Adult tickets (donor). Added to the regular adults." }, { key: "item_quantity_youth_ticket_reg / _donor", req: "optional", type: "quantity", desc: "Youth 13-16 tickets (regular + donor)." }, - { key: "item_quantity_kids_12", req: "optional", type: "quantity", desc: "Kids 10-12. Counts toward the scannable total." }, - { key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. Counts toward the scannable total." }, - { key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE โ€” NOT counted toward the scannable ticket total." }, + { key: "item_quantity_kids_12", req: "optional", type: "quantity", desc: "Kids 10-12. FREE โ€” stored but NOT counted toward the scannable ticket total." }, + { key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. FREE โ€” stored but NOT counted toward the scannable ticket total." }, + { key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE โ€” stored but NOT counted toward the scannable ticket total." }, { key: "donor_tier", req: "optional", type: "hidden", desc: "member / donor / empty (from the donor-eligibility lookup)." }, { key: "donor_eligible", req: "optional", type: "hidden", desc: "true / false (from the donor-eligibility lookup)." }, { key: "vouchers", req: "optional", type: "hidden", desc: "Integer voucher count (from the ticket-voucher lookup)." }, @@ -118,7 +118,7 @@ const PAGE = `

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) + kids 10-12 + kids 5-9. Kids 0-4 are free and not counted. Each adult name provided is stored and shown to gate staff on a successful scan.

+

Scannable ticket total = adults + youth (13-16). Children 12 & under are free (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.

Fields

@@ -132,7 +132,7 @@ const PAGE = `

Example payload

${exampleJson}
-

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).

+

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).

Test with curl

${exampleCurl}
@@ -158,6 +158,21 @@ const PAGE = `
  • Save, submit a test purchase, and confirm the QR email arrives.
  • +

    Vendor booth webhooks

    +

    The two vendor forms on vendors.beartariacampgrounds.com post to their own endpoints (same X-Webhook-Secret). Each named booth person gets one entry pass; the booth name (input_text) becomes the ticket title, and the ticket is tagged with a 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.

    +
    + + + + + +
    FormEndpointPassesTicket Type
    Vendor Fee Food 2026POST /vendor-webhook/foodup to 2 (names + names_1)๐Ÿ” Food Vendor
    Vendor Fee Non-Food 2026POST /vendor-webhook/non-food1 (names)๐Ÿ›’ Vendor
    +

    Relevant 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.

    +
    curl -X POST https://scan.beartariacampgrounds.com/vendor-webhook/food \\
    +  -H "Content-Type: application/json" \\
    +  -H "X-Webhook-Secret: <your WEBHOOK_SECRET>" \\
    +  -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"}'
    +
    Beartaria Campgrounds ยท scan.beartariacampgrounds.com
    diff --git a/backend/src/server.ts b/backend/src/server.ts index b10433e..7895fe1 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -9,6 +9,7 @@ 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"; @@ -32,6 +33,7 @@ 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); diff --git a/backend/src/test/fields.test.ts b/backend/src/test/fields.test.ts index 834de59..be0a4f1 100644 --- a/backend/src/test/fields.test.ts +++ b/backend/src/test/fields.test.ts @@ -2,16 +2,16 @@ import { describe, it, expect } from "vitest"; import { computeTotal, toView, COL } from "../fields.js"; 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 = { Id: 1, [COL.adults]: 2, [COL.youth]: 1, - [COL.kids12]: 1, - [COL.kids9]: 1, + [COL.kids12]: 1, // free, not counted + [COL.kids9]: 1, // 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", () => { @@ -47,7 +47,7 @@ describe("toView", () => { expect(v.extras.rvParking).toBe(false); expect(v.extras.donorTier).toBe("member"); expect(v.extras.vouchers).toBe(2); - expect(v.extras.freeUnder5).toBe(1); + expect(v.extras.freeKids).toBe(1); expect(v.ages.find((a) => a.bracket === "Kids 0-4")?.free).toBe(true); }); }); diff --git a/backend/src/test/fluentforms.test.ts b/backend/src/test/fluentforms.test.ts new file mode 100644 index 0000000..4f21e4b --- /dev/null +++ b/backend/src/test/fluentforms.test.ts @@ -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, 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"); + }); +});