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
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 };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue