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>
159 lines
4.7 KiB
TypeScript
159 lines
4.7 KiB
TypeScript
/**
|
|
* Mapping for the "2026 Campground Tickets" NocoDB table, matching the 2026
|
|
* FluentForms "Tickets 2026" schema. Change titles here if the columns differ.
|
|
*/
|
|
export const COL = {
|
|
id: "Id",
|
|
name: "Title", // purchaser full name
|
|
adultNames: "Adult Names", // newline-separated list of adult attendee names
|
|
email: "Email Address",
|
|
address: "Address",
|
|
isDonor: "Is Donor",
|
|
donorTier: "Donor Tier", // member / donor / ""
|
|
vouchers: "Vouchers",
|
|
|
|
// Attendee counts by group:
|
|
adults: "Adults",
|
|
youth: "Youth 13-16",
|
|
kids12: "Kids 10-12",
|
|
kids9: "Kids 5-9",
|
|
kids4: "Kids 0-4", // free — NOT counted toward the scannable total
|
|
|
|
carParking: "Car Parking",
|
|
rvParking: "RV Parking",
|
|
utv: "UTV",
|
|
iceAccess: "Ice Access",
|
|
paymentMethod: "Payment Method",
|
|
ticketType: "Ticket Type", // "" for regular; Guest/Worker/Performer/Volunteer/Speaker for portal comps
|
|
createdBy: "Created By", // gate-staff name who issued a comp ticket (portal)
|
|
|
|
// Columns this system manages:
|
|
code: "Ticket Code",
|
|
redeemed: "Redeemed",
|
|
submissionKey: "SubmissionKey",
|
|
lastScanAt: "LastScanAt",
|
|
iceTotal: "Ice Total",
|
|
iceRedeemed: "Ice Redeemed",
|
|
} as const;
|
|
|
|
export type NocoRecord = Record<string, unknown> & { Id: number };
|
|
|
|
function num(v: unknown): number {
|
|
const n = Number(v);
|
|
return Number.isFinite(n) ? n : 0;
|
|
}
|
|
|
|
function bool(v: unknown): boolean {
|
|
if (typeof v === "boolean") return v;
|
|
if (typeof v === "number") return v !== 0;
|
|
if (typeof v === "string") return /^(1|true|yes|y|on)$/i.test(v.trim());
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* 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]);
|
|
}
|
|
|
|
/** 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 {
|
|
return num(rec[COL.iceTotal]);
|
|
}
|
|
|
|
/** Per-group breakdown for display. */
|
|
export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number; free: boolean }[] {
|
|
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: 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);
|
|
}
|
|
|
|
/** Adult names stored as a newline-separated list. */
|
|
export function parseAdultNames(rec: NocoRecord): string[] {
|
|
const raw = rec[COL.adultNames];
|
|
if (Array.isArray(raw)) return raw.map((x) => String(x)).filter(Boolean);
|
|
if (typeof raw === "string") {
|
|
return raw
|
|
.split(/\r?\n/)
|
|
.map((s) => s.trim())
|
|
.filter(Boolean);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
export interface ResourceCount {
|
|
total: number;
|
|
redeemed: number;
|
|
remaining: number;
|
|
}
|
|
|
|
export interface TicketView {
|
|
code: string;
|
|
name: string;
|
|
email: string;
|
|
ticketType: string; // "" for regular; Guest/Worker/... for special tickets
|
|
createdBy: string; // who issued a comp ticket
|
|
total: number;
|
|
redeemed: number;
|
|
remaining: number;
|
|
ice: ResourceCount;
|
|
adultNames: string[];
|
|
extras: {
|
|
carParking: boolean;
|
|
rvParking: boolean;
|
|
utv: boolean;
|
|
iceAccess: boolean;
|
|
isDonor: boolean;
|
|
donorTier: string;
|
|
vouchers: number;
|
|
freeKids: number; // children 12 & under (free admission)
|
|
};
|
|
ages: { bracket: string; count: number; free: boolean }[];
|
|
}
|
|
|
|
export function toView(rec: NocoRecord): TicketView {
|
|
const total = computeTotal(rec);
|
|
const redeemed = num(rec[COL.redeemed]);
|
|
const iceTotal = computeIceTotal(rec);
|
|
const iceRedeemed = num(rec[COL.iceRedeemed]);
|
|
return {
|
|
code: String(rec[COL.code] ?? ""),
|
|
name: String(rec[COL.name] ?? ""),
|
|
email: String(rec[COL.email] ?? ""),
|
|
ticketType: String(rec[COL.ticketType] ?? ""),
|
|
createdBy: String(rec[COL.createdBy] ?? ""),
|
|
total,
|
|
redeemed,
|
|
remaining: Math.max(0, total - redeemed),
|
|
ice: {
|
|
total: iceTotal,
|
|
redeemed: iceRedeemed,
|
|
remaining: Math.max(0, iceTotal - iceRedeemed),
|
|
},
|
|
adultNames: parseAdultNames(rec),
|
|
extras: {
|
|
carParking: bool(rec[COL.carParking]),
|
|
rvParking: bool(rec[COL.rvParking]),
|
|
utv: bool(rec[COL.utv]),
|
|
iceAccess: bool(rec[COL.iceAccess]),
|
|
isDonor: bool(rec[COL.isDonor]),
|
|
donorTier: String(rec[COL.donorTier] ?? ""),
|
|
vouchers: num(rec[COL.vouchers]),
|
|
freeKids: freeKidsCount(rec),
|
|
},
|
|
ages: ageBreakdown(rec),
|
|
};
|
|
}
|
|
|
|
export { num as toNumber, bool as toBool };
|