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:
parent
43fddec286
commit
7296555964
15 changed files with 368 additions and 91 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
68
backend/src/fluentforms.ts
Normal file
68
backend/src/fluentforms.ts
Normal 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 };
|
||||
}
|
||||
|
|
@ -17,17 +17,21 @@ function safeEqual(a: string, b: string): boolean {
|
|||
*/
|
||||
export async function publicLookupRoutes(app: FastifyInstance): Promise<void> {
|
||||
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<void> {
|
|||
"/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<void> {
|
|||
"/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" });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
// Keep the "exhausted" persona fully redeemed on every load so its state
|
||||
// is deterministic (total = scannable count from the persona's counts).
|
||||
if (p.exhaust) {
|
||||
const { adults, youth, 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,
|
||||
|
|
|
|||
133
backend/src/routes/vendorWebhook.ts
Normal file
133
backend/src/routes/vendorWebhook.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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<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;
|
||||
}
|
||||
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<void> {
|
|||
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<void> {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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 = `<!doctype html>
|
|||
|
||||
<h2>What it does</h2>
|
||||
<p>On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject <b>"2026 Beartaria Campgrounds Tickets"</b>). FluentForms sends the payment receipt separately.</p>
|
||||
<p><b>Scannable ticket total</b> = adults + youth (13-16) + 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 & 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>
|
||||
<table>
|
||||
|
|
@ -132,7 +132,7 @@ const PAGE = `<!doctype html>
|
|||
|
||||
<h2>Example payload</h2>
|
||||
<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 & under are free), member donor with 2 vouchers, car parking, and 6 bags of ice (2 ice tickets).</p>
|
||||
|
||||
<h2>Test with curl</h2>
|
||||
<pre><code>${exampleCurl}</code></pre>
|
||||
|
|
@ -158,6 +158,21 @@ const PAGE = `<!doctype html>
|
|||
<li>Save, submit a test purchase, and confirm the QR email arrives.</li>
|
||||
</ol>
|
||||
|
||||
<h2>Vendor booth webhooks</h2>
|
||||
<p class="sub">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: <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"}'</code></pre>
|
||||
|
||||
<footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer>
|
||||
</div>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
84
backend/src/test/fluentforms.test.ts
Normal file
84
backend/src/test/fluentforms.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue