Rework webhook + model for the 2026 Tickets form

New form schema: up to 10 named adults, youth 13-16, kids 10-12 / 5-9 / 0-4,
donor tier/vouchers (from the lookups), parking/RV/UTV/ice payment fields.
- Scannable total = adults + youth + kids 10-12 + kids 5-9 (kids 0-4 free).
- Store + display adult names on a good scan; show donor tier, UTV, vouchers.
- Ice: payment_ice = 1-4 tickets ($20 each), 1 ticket = 3 bags.
- New NocoDB 2026 schema (with Id PK); webhook parses compound names,
  quantity/payment fields (nested objects or money strings).
- Our webhook keeps sending the QR ticket email (FluentForms sends the receipt);
  from address is now info@beartariacampgrounds.com.
Updated /test personas, /webhook-doc, tests, and the app display.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-13 03:23:06 +00:00
parent 718d1515b0
commit b7c77afe02
14 changed files with 360 additions and 210 deletions

View file

@ -17,8 +17,10 @@ NOCODB_DONORS_TABLE_ID=
NOCODB_DONOR_ONLINE_TABLE_ID= NOCODB_DONOR_ONLINE_TABLE_ID=
NOCODB_DONOR_OFFLINE_TABLE_ID= NOCODB_DONOR_OFFLINE_TABLE_ID=
# Ice bags granted when a purchase includes ice but the webhook sends only a boolean # Ice: form sells 1-4 ice tickets at $20 each; one ticket = 3 bags. The webhook
ICE_BAGS_DEFAULT=3 # reads payment_ice as a ticket count (1-4) or a dollar total ($20-$80).
ICE_TICKET_PRICE=20
ICE_BAGS_PER_TICKET=3
# Ticket-voucher entitlement (donor free tickets). Donations on/after # Ticket-voucher entitlement (donor free tickets). Donations on/after
# VOUCHER_SINCE totalling >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the # VOUCHER_SINCE totalling >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the
@ -33,7 +35,7 @@ ENABLE_TEST_PAGE=false
# MailerSend # MailerSend
MAILERSEND_API_TOKEN= MAILERSEND_API_TOKEN=
MAIL_FROM_EMAIL=tickets@beartariacampgrounds.com MAIL_FROM_EMAIL=info@beartariacampgrounds.com
MAIL_FROM_NAME=Beartaria Campgrounds MAIL_FROM_NAME=Beartaria Campgrounds
# Shared secret FluentForms sends in the X-Webhook-Secret header (long random string) # Shared secret FluentForms sends in the X-Webhook-Secret header (long random string)

View file

@ -201,11 +201,14 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti
}, [ticket.redeemed, showHistory]); }, [ticket.redeemed, showHistory]);
const tags: string[] = []; const tags: string[] = [];
if (ticket.extras.carParking) tags.push("🚗 Car"); const e = ticket.extras;
if (ticket.extras.rvParking) tags.push("🚐 RV"); if (e.donorTier === "member") tags.push("🐻 Member");
if (ticket.extras.iceAccess) tags.push("🧊 Ice"); else if (e.isDonor) tags.push("⭐ Donor");
if (ticket.extras.isDonor) tags.push("⭐ Donor"); if (e.carParking) tags.push("🚗 Car");
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} free`); 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`);
return ( return (
<View style={styles.card}> <View style={styles.card}>

View file

@ -259,6 +259,7 @@ export default function ScannerScreen() {
<Text style={styles.counts}> <Text style={styles.counts}>
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining {ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
</Text> </Text>
<AdultNames names={ticket.adultNames} />
<ExtrasRow ticket={ticket} /> <ExtrasRow ticket={ticket} />
</> </>
)} )}
@ -345,11 +346,14 @@ function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticke
function ExtrasRow({ ticket }: { ticket: TicketView }) { function ExtrasRow({ ticket }: { ticket: TicketView }) {
const tags: string[] = []; const tags: string[] = [];
if (ticket.extras.carParking) tags.push("🚗 Car parking"); const e = ticket.extras;
if (ticket.extras.rvParking) tags.push("🚐 RV parking"); if (e.donorTier === "member") tags.push("🐻 Member");
if (ticket.extras.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`); else if (e.isDonor) tags.push("⭐ Donor");
if (ticket.extras.isDonor) tags.push("⭐ Donor"); if (e.carParking) tags.push("🚗 Car parking");
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} under 4 (free)`); 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 (!tags.length) return null; if (!tags.length) return null;
return ( return (
<View style={styles.tags}> <View style={styles.tags}>
@ -362,6 +366,19 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) {
); );
} }
function AdultNames({ names }: { names: string[] }) {
if (!names.length) return null;
return (
<View style={styles.namesBox}>
{names.map((n, i) => (
<Text key={i} style={styles.nameLine}>
{n}
</Text>
))}
</View>
);
}
function ConfirmCard({ function ConfirmCard({
ticket, ticket,
isIce, isIce,
@ -393,6 +410,7 @@ function ConfirmCard({
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining <Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining
</Text> </Text>
<Text style={styles.cardSub}>{redeemed} already redeemed</Text> <Text style={styles.cardSub}>{redeemed} already redeemed</Text>
{!isIce && <AdultNames names={ticket.adultNames} />}
{!isIce && <ExtrasRow ticket={ticket} />} {!isIce && <ExtrasRow ticket={ticket} />}
{isIce && total === 0 && <Text style={styles.exhausted}>This ticket did not prepay for ice.</Text>} {isIce && total === 0 && <Text style={styles.exhausted}>This ticket did not prepay for ice.</Text>}
@ -489,6 +507,8 @@ const styles = StyleSheet.create({
donorFigureDivider: { width: 1, alignSelf: "stretch", backgroundColor: "rgba(255,255,255,0.35)", marginVertical: 8 }, donorFigureDivider: { width: 1, alignSelf: "stretch", backgroundColor: "rgba(255,255,255,0.35)", marginVertical: 8 },
donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 18 }, donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 18 },
namesBox: { marginTop: 12, alignItems: "center", gap: 3 },
nameLine: { color: "#fff", fontSize: 18, fontWeight: "600", textAlign: "center" },
tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 }, tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 },
tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" }, tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" },

View file

@ -25,12 +25,16 @@ export interface TicketView {
redeemed: number; redeemed: number;
remaining: number; remaining: number;
ice: ResourceCount; ice: ResourceCount;
adultNames: string[];
extras: { extras: {
carParking: boolean; carParking: boolean;
rvParking: boolean; rvParking: boolean;
utv: boolean;
iceAccess: boolean; iceAccess: boolean;
isDonor: boolean; isDonor: boolean;
freeUnder4: number; donorTier: string;
vouchers: number;
freeUnder5: number;
}; };
ages: { bracket: string; count: number; free: boolean }[]; ages: { bracket: string; count: number; free: boolean }[];
} }

View file

@ -17,9 +17,11 @@ const schema = z.object({
NOCODB_DONOR_ONLINE_TABLE_ID: z.string().optional(), NOCODB_DONOR_ONLINE_TABLE_ID: z.string().optional(),
NOCODB_DONOR_OFFLINE_TABLE_ID: z.string().optional(), NOCODB_DONOR_OFFLINE_TABLE_ID: z.string().optional(),
// Prepaid ice bags granted when a purchase includes ice but the webhook only // Ice: the form sells 1-4 ice "tickets" at $20 each; one ticket = 3 bags.
// sends a boolean (not an explicit bag count). // The webhook reads payment_ice as either a ticket count (1-4) or a dollar
ICE_BAGS_DEFAULT: z.coerce.number().default(3), // total ($20-$80) and stores bags = tickets * ICE_BAGS_PER_TICKET.
ICE_TICKET_PRICE: z.coerce.number().default(20),
ICE_BAGS_PER_TICKET: z.coerce.number().default(3),
// Public donor-eligibility lookup (for the FluentForms checkout discount). // Public donor-eligibility lookup (for the FluentForms checkout discount).
// Disabled unless a secret is set. Returns only eligibility + tier, never // Disabled unless a secret is set. Returns only eligibility + tier, never

View file

@ -1,48 +1,39 @@
/** /**
* Mapping between the NocoDB "2026 Campground Tickets" table columns, the * Mapping for the "2026 Campground Tickets" NocoDB table, matching the 2026
* webhook payload keys, and the view we return to the app. * FluentForms "Tickets 2026" schema. Change titles here if the columns differ.
*
* The 2026 table is a clone of the 2025 submission table (per-purchase record
* with age-bracket headcounts, parking/ice flags, donor flag) PLUS four columns
* this system adds: Ticket Code, Redeemed, SubmissionKey, LastScanAt.
*
* If the real column titles differ, change them here in one place.
*/ */
export const COL = { export const COL = {
id: "Id", id: "Id",
name: "Title", // first column in the 2025 table holds the purchaser name name: "Title", // purchaser full name
adultNames: "Adult Names", // newline-separated list of adult attendee names
email: "Email Address", email: "Email Address",
address: "Address", address: "Address",
isDonor: "Is Donor", 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", carParking: "Car Parking",
rvParking: "RV Parking", rvParking: "RV Parking",
utv: "UTV",
iceAccess: "Ice Access", iceAccess: "Ice Access",
paymentMethod: "Payment Method", paymentMethod: "Payment Method",
// Columns this system adds to the table: // Columns this system manages:
code: "Ticket Code", code: "Ticket Code",
redeemed: "Redeemed", redeemed: "Redeemed",
submissionKey: "SubmissionKey", submissionKey: "SubmissionKey",
lastScanAt: "LastScanAt", lastScanAt: "LastScanAt",
iceTotal: "Ice Total", // prepaid ice bags iceTotal: "Ice Total",
iceRedeemed: "Ice Redeemed", // bags picked up iceRedeemed: "Ice Redeemed",
} as const; } as const;
/** Age-bracket columns, in order. */
export const AGE_COLUMNS = [
"Ages 0-3",
"Ages 4-7",
"Ages 8-12",
"Ages 13-17",
"Ages 18-25",
"Ages 26-45",
"Ages 46-64",
"Ages 65+",
] as const;
/** Age brackets admitted free and NOT counted as redeemable tickets. */
export const FREE_AGE_COLUMNS: readonly string[] = ["Ages 0-3"];
export type NocoRecord = Record<string, unknown> & { Id: number }; export type NocoRecord = Record<string, unknown> & { Id: number };
function num(v: unknown): number { function num(v: unknown): number {
@ -57,23 +48,40 @@ function bool(v: unknown): boolean {
return false; return false;
} }
/** Total redeemable tickets = sum of age brackets minus the free ones. */ /**
* Total scannable tickets = everyone except kids 0-4 (who are free):
* adults + youth (13-16) + kids 10-12 + kids 5-9.
*/
export function computeTotal(rec: NocoRecord): number { export function computeTotal(rec: NocoRecord): number {
let total = 0; return num(rec[COL.adults]) + num(rec[COL.youth]) + num(rec[COL.kids12]) + num(rec[COL.kids9]);
for (const col of AGE_COLUMNS) {
if (FREE_AGE_COLUMNS.includes(col)) continue;
total += num(rec[col]);
}
return total;
} }
/** Per-bracket breakdown for display. */ 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 }[] { export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number; free: boolean }[] {
return AGE_COLUMNS.map((col) => ({ return [
bracket: col.replace(/^Ages /, ""), { bracket: "Adults", count: num(rec[COL.adults]), free: false },
count: num(rec[col]), { bracket: "Youth 13-16", count: num(rec[COL.youth]), free: false },
free: FREE_AGE_COLUMNS.includes(col), { bracket: "Kids 10-12", count: num(rec[COL.kids12]), free: false },
})).filter((b) => b.count > 0); { bracket: "Kids 5-9", count: num(rec[COL.kids9]), free: false },
{ 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 { export interface ResourceCount {
@ -90,20 +98,20 @@ export interface TicketView {
redeemed: number; redeemed: number;
remaining: number; remaining: number;
ice: ResourceCount; ice: ResourceCount;
adultNames: string[];
extras: { extras: {
carParking: boolean; carParking: boolean;
rvParking: boolean; rvParking: boolean;
utv: boolean;
iceAccess: boolean; iceAccess: boolean;
isDonor: boolean; isDonor: boolean;
freeUnder4: number; donorTier: string;
vouchers: number;
freeUnder5: number;
}; };
ages: { bracket: string; count: number; free: boolean }[]; ages: { bracket: string; count: number; free: boolean }[];
} }
export function computeIceTotal(rec: NocoRecord): number {
return num(rec[COL.iceTotal]);
}
export function toView(rec: NocoRecord): TicketView { export function toView(rec: NocoRecord): TicketView {
const total = computeTotal(rec); const total = computeTotal(rec);
const redeemed = num(rec[COL.redeemed]); const redeemed = num(rec[COL.redeemed]);
@ -121,12 +129,16 @@ export function toView(rec: NocoRecord): TicketView {
redeemed: iceRedeemed, redeemed: iceRedeemed,
remaining: Math.max(0, iceTotal - iceRedeemed), remaining: Math.max(0, iceTotal - iceRedeemed),
}, },
adultNames: parseAdultNames(rec),
extras: { extras: {
carParking: bool(rec[COL.carParking]), carParking: bool(rec[COL.carParking]),
rvParking: bool(rec[COL.rvParking]), rvParking: bool(rec[COL.rvParking]),
utv: bool(rec[COL.utv]),
iceAccess: bool(rec[COL.iceAccess]), iceAccess: bool(rec[COL.iceAccess]),
isDonor: bool(rec[COL.isDonor]), isDonor: bool(rec[COL.isDonor]),
freeUnder4: num(rec["Ages 0-3"]), donorTier: String(rec[COL.donorTier] ?? ""),
vouchers: num(rec[COL.vouchers]),
freeUnder5: num(rec[COL.kids4]),
}, },
ages: ageBreakdown(rec), ages: ageBreakdown(rec),
}; };

View file

@ -7,57 +7,68 @@ interface Persona {
key: string; key: string;
name: string; name: string;
email: string; email: string;
ages: Record<string, number>; adultNames?: string[];
counts: { adults: number; youth: number; kids12: number; kids9: number; kids4: number };
iceBags?: number; iceBags?: number;
carParking?: boolean; carParking?: boolean;
rvParking?: boolean; rvParking?: boolean;
utv?: boolean;
isDonor?: boolean; isDonor?: boolean;
donorTier?: string;
exhaust?: boolean; // pre-redeem all tickets so it scans as "exhausted" exhaust?: boolean; // pre-redeem all tickets so it scans as "exhausted"
blurb: string; blurb: string;
} }
const C = (adults = 0, youth = 0, kids12 = 0, kids9 = 0, kids4 = 0) => ({ adults, youth, kids12, kids9, kids4 });
// A curated set covering the different attribute combinations to test. // A curated set covering the different attribute combinations to test.
const PERSONAS: Persona[] = [ const PERSONAS: Persona[] = [
{ {
key: "solo", key: "solo",
name: "Solo Sam", name: "Solo Sam",
email: "solo@test.beartaria", email: "solo@test.beartaria",
ages: { "Ages 18-25": 1 }, adultNames: ["Solo Sam"],
counts: C(1),
blurb: "1 ticket, no extras. Check-in mode → green, 1/1.", blurb: "1 ticket, no extras. Check-in mode → green, 1/1.",
}, },
{ {
key: "family", key: "family",
name: "Family Fay", name: "Family Fay",
email: "family@test.beartaria", email: "family@test.beartaria",
ages: { "Ages 0-3": 2, "Ages 8-12": 3, "Ages 26-45": 2 }, 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
iceBags: 3, iceBags: 3,
carParking: true, carParking: true,
blurb: blurb:
"5 tickets (2 under-4 free), car parking, 3 ice bags. Check-in a few at a time to test QR reuse; then Ice mode.", "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.",
}, },
{ {
key: "donor2", key: "donor2",
name: "Donor Dan", name: "Donor Dan",
email: "donor@example.test", email: "donor@example.test",
ages: { "Ages 26-45": 2 }, adultNames: ["Donor Dan", "Donna Dan"],
counts: C(2),
rvParking: true, rvParking: true,
utv: true,
isDonor: true, isDonor: true,
donorTier: "member",
blurb: blurb:
"2 tickets, RV parking, donor-flagged. Banquet mode: this test email has no real donations, so use Banquet's manual email lookup with a real donor's address to see totals.", "2 tickets, RV + UTV, donor/member. Banquet mode: this test email has no real donations — use Banquet's manual email lookup with a real donor's address.",
}, },
{ {
key: "ice", key: "ice",
name: "Ice Ike", name: "Ice Ike",
email: "ice@test.beartaria", email: "ice@test.beartaria",
ages: { "Ages 18-25": 1 }, adultNames: ["Ice Ike"],
iceBags: 3, counts: C(1),
blurb: "1 ticket + 3 ice bags. Ice mode → grab all 3 at once, then scan again → exhausted.", iceBags: 6, // 2 ice tickets
blurb: "1 ticket + 6 ice bags (2 ice tickets). Ice mode → grab bags, then scan again → exhausted.",
}, },
{ {
key: "exhausted", key: "exhausted",
name: "Done Dora", name: "Done Dora",
email: "done@test.beartaria", email: "done@test.beartaria",
ages: { "Ages 26-45": 2 }, counts: C(2),
exhaust: true, exhaust: true,
blurb: "2 tickets, already fully redeemed. Check-in mode → red 'exhausted'.", blurb: "2 tickets, already fully redeemed. Check-in mode → red 'exhausted'.",
}, },
@ -74,22 +85,22 @@ export async function testRoutes(app: FastifyInstance): Promise<void> {
for (const p of PERSONAS) { for (const p of PERSONAS) {
const result = await createTicket(app.ctx, { const result = await createTicket(app.ctx, {
name: p.name, name: p.name,
adultNames: p.adultNames,
email: p.email, email: p.email,
ages: p.ages, counts: p.counts,
iceBags: p.iceBags, iceBags: p.iceBags,
carParking: p.carParking, carParking: p.carParking,
rvParking: p.rvParking, rvParking: p.rvParking,
utv: p.utv,
isDonor: p.isDonor, isDonor: p.isDonor,
donorTier: p.donorTier,
submissionKey: `test:${p.key}`, submissionKey: `test:${p.key}`,
}); });
// 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 (compute the total from the persona's own age counts, // is deterministic (total = scannable count from the persona's counts).
// since NocoDB's create response may not echo them back).
if (p.exhaust) { if (p.exhaust) {
const total = Object.entries(p.ages) const { adults, youth, kids12, kids9 } = p.counts;
.filter(([col]) => col !== "Ages 0-3") await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth + kids12 + kids9 });
.reduce((s, [, n]) => s + n, 0);
await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: total });
} }
cards.push({ cards.push({
code: result.code, code: result.code,

View file

@ -167,11 +167,16 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
schema: { schema: {
body: { body: {
type: "object", type: "object",
required: ["name", "ages"], required: ["name"],
properties: { properties: {
name: { type: "string", minLength: 1 }, name: { type: "string", minLength: 1 },
email: { type: "string" }, email: { type: "string" },
ages: { type: "object" }, adults: { type: "integer", minimum: 0 },
youth: { type: "integer", minimum: 0 },
kids12: { type: "integer", minimum: 0 },
kids9: { type: "integer", minimum: 0 },
kids4: { type: "integer", minimum: 0 },
adultNames: { type: "array", items: { type: "string" } },
sendEmail: { type: "boolean" }, sendEmail: { type: "boolean" },
}, },
}, },
@ -182,8 +187,15 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
const submissionKey = `manual:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`; const submissionKey = `manual:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`;
const result = await createTicket(app.ctx, { const result = await createTicket(app.ctx, {
name: b.name, name: b.name,
adultNames: b.adultNames,
email: b.email ?? "", email: b.email ?? "",
ages: b.ages, counts: {
adults: b.adults ?? 1,
youth: b.youth ?? 0,
kids12: b.kids12 ?? 0,
kids9: b.kids9 ?? 0,
kids4: b.kids4 ?? 0,
},
submissionKey, submissionKey,
}); });
if (b.sendEmail && b.email && !app.ctx.mailer.isBlockedRecipient(b.email)) { if (b.sendEmail && b.email && !app.ctx.mailer.isBlockedRecipient(b.email)) {

View file

@ -1,6 +1,6 @@
import { createHash, timingSafeEqual } from "node:crypto"; import { createHash, timingSafeEqual } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { AGE_COLUMNS, toBool, toNumber } from "../fields.js"; import { toBool, toNumber } 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";
@ -11,18 +11,48 @@ function safeEqual(a: string, b: string): boolean {
return timingSafeEqual(ba, bb); return timingSafeEqual(ba, bb);
} }
// Map webhook payload keys -> NocoDB age-column titles. Keys are what you map /** Read a FluentForms compound name field, given as a nested object
// the FluentForms fields to in the webhook feed. * (`names: {first_name,...}`) or flattened bracket keys (`names[first_name]`). */
const AGE_KEY_TO_COL: Record<string, string> = { function nameGroup(body: Record<string, any>, base: string): string {
ages_0_3: "Ages 0-3", const obj = body[base];
ages_4_7: "Ages 4-7", let first: any, middle: any, last: any;
ages_8_12: "Ages 8-12", if (obj && typeof obj === "object") {
ages_13_17: "Ages 13-17", ({ first_name: first, middle_name: middle, last_name: last } = obj);
ages_18_25: "Ages 18-25", } else {
ages_26_45: "Ages 26-45", first = body[`${base}[first_name]`];
ages_46_64: "Ages 46-64", middle = body[`${base}[middle_name]`];
ages_65: "Ages 65+", 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).
const ADULT_NAME_BASES = ["names", "names_1", "names_2", "names_3", "names_4", "names_5", "names_6", "names_7", "names_8", "names_9"];
export async function webhookRoutes(app: FastifyInstance): Promise<void> { export async function webhookRoutes(app: FastifyInstance): Promise<void> {
const handler = async (req: any, reply: any) => { const handler = async (req: any, reply: any) => {
@ -31,57 +61,87 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
return reply.code(401).send({ error: "unauthorized" }); return reply.code(401).send({ error: "unauthorized" });
} }
const body = (req.body ?? {}) as Record<string, unknown>; const body = (req.body ?? {}) as Record<string, any>;
const name = String(body.name ?? "").trim();
// Purchaser = the first adult name group; fall back to a plain `name` field.
const name = nameGroup(body, "names") || String(body.name ?? "").trim();
const email = String(body.email ?? "").trim(); const email = String(body.email ?? "").trim();
if (!name || !email) { if (!name) {
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" }); return reply.code(400).send({ error: "missing_fields", detail: "purchaser name is required" });
} }
// Build age-bracket counts from whichever keys were provided. // Adult attendee names (non-empty groups, in order).
const ages: Record<string, number> = {}; const adultNames = ADULT_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean);
for (const [key, col] of Object.entries(AGE_KEY_TO_COL)) {
if (body[key] !== undefined && body[key] !== null && body[key] !== "") { // Attendee counts.
ages[col] = toNumber(body[key]); const counts = {
} adults: qty(body.item_quantity_adult_ticket_reg) + qty(body.item_quantity_adult_ticket_donor),
} youth: qty(body.item_quantity_youth_ticket_reg) + qty(body.item_quantity_youth_ticket_donor),
const anyAge = AGE_COLUMNS.some((c) => (ages[c] ?? 0) > 0); kids12: qty(body.item_quantity_kids_12),
if (!anyAge) { kids9: qty(body.item_quantity_kids_9),
return reply.code(400).send({ error: "no_tickets", detail: "no age-bracket counts provided" }); kids4: qty(body.item_quantity_kids_4),
};
const scannable = counts.adults + counts.youth + counts.kids12 + counts.kids9;
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+)" });
} }
// Idempotency key: prefer a stable submission id, else hash the content. // Donor info (hidden fields from the eligibility/voucher lookups) + radio.
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id; 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?"
const vouchers = qty(body.vouchers);
// Extras (best-effort from payment fields — donor variants may be free/$0).
const carParking = selected(body.payment_parking_reg) || selected(body.payment_parking_donor);
const rvParking = selected(body.payment_rv_reg) || selected(body.payment_rv_donor);
const utv = selected(body.payment_utv_reg) || selected(body.payment_utv_donor);
// Ice: payment_ice is either a ticket count (1-4) or a dollar total
// ($20-$80). One ice ticket = ICE_BAGS_PER_TICKET bags.
const iceRaw = qty(body.payment_ice);
const iceTickets = iceRaw >= app.ctx.config.ICE_TICKET_PRICE ? Math.round(iceRaw / app.ctx.config.ICE_TICKET_PRICE) : Math.round(iceRaw);
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;
// 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 const submissionKey = submissionId
? `sub:${String(submissionId)}` ? `sub:${String(submissionId)}`
: "hash:" + : "hash:" +
createHash("sha256") createHash("sha256")
.update(`${email}|${name}|${JSON.stringify(ages)}`) .update(`${email}|${name}|${JSON.stringify(counts)}`)
.digest("hex") .digest("hex")
.slice(0, 32); .slice(0, 32);
// Ice: prefer an explicit bag count; else grant the default when a boolean
// ice option is truthy; else 0.
let iceBags = 0;
if (body.ice_bags !== undefined && body.ice_bags !== null && body.ice_bags !== "") {
iceBags = toNumber(body.ice_bags);
} else if (body.ice_access !== undefined && toBool(body.ice_access)) {
iceBags = app.ctx.config.ICE_BAGS_DEFAULT;
}
let result: Awaited<ReturnType<typeof createTicket>>; let result: Awaited<ReturnType<typeof createTicket>>;
try { try {
result = await createTicket(app.ctx, { result = await createTicket(app.ctx, {
name, name,
adultNames,
email, email,
address: body.address !== undefined ? String(body.address) : undefined, address,
isDonor: body.is_donor !== undefined ? toBool(body.is_donor) : undefined, isDonor,
carParking: body.car_parking !== undefined ? toBool(body.car_parking) : undefined, donorTier,
rvParking: body.rv_parking !== undefined ? toBool(body.rv_parking) : undefined, vouchers,
iceAccess: body.ice_access !== undefined ? toBool(body.ice_access) : undefined, counts,
carParking,
rvParking,
utv,
iceAccess,
iceBags, iceBags,
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined, paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
ages,
submissionKey, submissionKey,
}); });
} catch (e: any) { } catch (e: any) {
@ -93,9 +153,13 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
return { status: "duplicate", code: result.code }; return { status: "duplicate", code: result.code };
} }
// Send the ticket email. If it fails, the row already exists — report 502 // Send the ticket QR email (FluentForms sends the receipt separately). If it
// so the failure is visible in FluentForms' delivery log; the ticket can be // fails, the row already exists — report 502 so it's visible in the feed
// re-sent later via POST /api/tickets/:code/resend-email. // log; re-send later via POST /api/tickets/:code/resend-email.
if (!email) {
req.log.warn({ code: result.code }, "webhook: ticket created but no email to send to");
return { status: "created", code: result.code, emailSent: false, emailSkipped: "no_email" };
}
if (app.ctx.mailer.isBlockedRecipient(email)) { if (app.ctx.mailer.isBlockedRecipient(email)) {
req.log.warn({ email }, "webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send"); req.log.warn({ email }, "webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send");
return { status: "created", code: result.code, emailSent: false, emailSkipped: "trial_restriction" }; return { status: "created", code: result.code, emailSent: false, emailSkipped: "trial_restriction" };
@ -103,13 +167,11 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
try { try {
const qr = await renderQrPng(result.code); const qr = await renderQrPng(result.code);
const quantity = // redeemable total for the email copy
AGE_COLUMNS.filter((c) => c !== "Ages 0-3").reduce((s, c) => s + (ages[c] ?? 0), 0);
await app.ctx.mailer.sendTicket({ await app.ctx.mailer.sendTicket({
toEmail: email, toEmail: email,
toName: name, toName: name,
code: result.code, code: result.code,
quantity, quantity: scannable,
qrPng: qr, qrPng: qr,
}); });
} catch (e: any) { } catch (e: any) {

View file

@ -11,29 +11,26 @@ interface Field {
} }
const FIELDS: Field[] = [ const FIELDS: Field[] = [
{ key: "name", req: "required", type: "text", desc: "Purchaser's full name." }, { key: "names", req: "required", type: "name (compound)", desc: "Purchaser / Adult #1 — object {first_name, middle_name, last_name}. Also accepts flat names[first_name] keys." },
{ key: "email", req: "required", type: "email", desc: "Purchaser's email — the QR ticket is sent here." }, { key: "names_1 … names_9", req: "optional", type: "name (compound)", desc: "Additional adult attendee names (Adults #2#10). Empty groups are ignored. Stored as the adult-name list shown at the gate." },
{ { key: "email", req: "optional", type: "email", desc: "Purchaser email — the QR ticket is sent here (FluentForms sends the receipt separately)." },
key: "submission_id", { key: "address_1", req: "optional", type: "address (compound)", desc: "Mailing address object; joined into one line." },
req: "optional", { key: "item_quantity_adult_ticket_reg", req: "required", type: "quantity", desc: "Adult tickets (regular)." },
type: "text/number", { key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "Adult tickets (donor). Added to the regular adults." },
desc: "Form entry/submission ID. Used for idempotency so retries or double-submits don't create duplicate tickets. If omitted, a hash of name+email+counts is used instead. (Aliases: submissionId, entry_id.)", { 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: "ages_0_3", req: "optional", type: "number", desc: "Headcount ages 03. Admitted free — NOT counted toward redeemable tickets." }, { key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. Counts toward the scannable total." },
{ key: "ages_4_7", req: "optional", type: "number", desc: "Headcount ages 47." }, { key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE — NOT counted toward the scannable ticket total." },
{ key: "ages_8_12", req: "optional", type: "number", desc: "Headcount ages 812." }, { key: "donor_tier", req: "optional", type: "hidden", desc: "member / donor / empty (from the donor-eligibility lookup)." },
{ key: "ages_13_17", req: "optional", type: "number", desc: "Headcount ages 1317." }, { key: "donor_eligible", req: "optional", type: "hidden", desc: "true / false (from the donor-eligibility lookup)." },
{ key: "ages_18_25", req: "optional", type: "number", desc: "Headcount ages 1825." }, { key: "vouchers", req: "optional", type: "hidden", desc: "Integer voucher count (from the ticket-voucher lookup)." },
{ key: "ages_26_45", req: "optional", type: "number", desc: "Headcount ages 2645." }, { key: "input_radio", req: "optional", type: "choice", desc: "'Are you a campground donor?' — also used as a donor signal." },
{ key: "ages_46_64", req: "optional", type: "number", desc: "Headcount ages 4664." }, { key: "payment_parking_reg / _donor", req: "optional", type: "payment", desc: "Car parking. Flagged if either variant is selected." },
{ key: "ages_65", req: "optional", type: "number", desc: "Headcount ages 65+." }, { key: "payment_rv_reg / _donor", req: "optional", type: "payment", desc: "RV. Flagged if either variant is selected." },
{ key: "ice_bags", req: "optional", type: "number", desc: "Prepaid ice bags. If omitted and ice_access is truthy, defaults to the configured amount (3)." }, { key: "payment_utv_reg / _donor", req: "optional", type: "payment", desc: "ATV/UTV. Flagged if either variant is selected." },
{ key: "ice_access", req: "optional", type: "yes/no", desc: "Whether they bought ice access. Accepts 1/0, true/false, yes/no." }, { key: "payment_ice", req: "optional", type: "payment", desc: "Ice tickets (1-4 at $20 each). One ice ticket = 3 bags; stored as bags = tickets × 3. Accepts a ticket count (1-4) or a dollar total ($20-$80)." },
{ key: "car_parking", req: "optional", type: "yes/no", desc: "Car parking pass." }, { key: "payment_method", req: "optional", type: "payment", desc: "Payment method label." },
{ key: "rv_parking", req: "optional", type: "yes/no", desc: "RV parking pass." }, { key: "id / submission_id", req: "optional", type: "text", desc: "Entry/submission id for idempotency (retries won't duplicate). Falls back to a content hash." },
{ key: "is_donor", req: "optional", type: "yes/no", desc: "Donor flag." },
{ key: "address", req: "optional", type: "text", desc: "Mailing address." },
{ key: "payment_method", req: "optional", type: "text", desc: "Payment method label." },
]; ];
function esc(s: string): string { function esc(s: string): string {
@ -48,28 +45,34 @@ export async function webhookDocRoutes(app: FastifyInstance): Promise<void> {
const rows = FIELDS.map( const rows = FIELDS.map(
(f) => `<tr> (f) => `<tr>
<td><code>${f.key}</code></td> <td><code>${esc(f.key)}</code></td>
<td class="${f.req === "required" ? "req" : "opt"}">${f.req}</td> <td class="${f.req === "required" ? "req" : "opt"}">${f.req}</td>
<td>${f.type}</td> <td>${esc(f.type)}</td>
<td>${esc(f.desc)}</td> <td>${esc(f.desc)}</td>
</tr>`, </tr>`,
).join(""); ).join("");
const exampleJson = esc(`{ const exampleJson = esc(`{
"name": "Jane Bear", "id": "412",
"names": { "first_name": "Jane", "last_name": "Bear" },
"names_1": { "first_name": "John", "last_name": "Bear" },
"email": "jane@example.com", "email": "jane@example.com",
"submission_id": "12345", "item_quantity_adult_ticket_reg": 2,
"ages_0_3": 2, "item_quantity_adult_ticket_donor": 0,
"ages_8_12": 3, "item_quantity_youth_ticket_reg": 1,
"ages_26_45": 2, "item_quantity_kids_9": 2,
"car_parking": "yes", "item_quantity_kids_4": 2,
"ice_access": "yes" "donor_tier": "member",
"vouchers": 2,
"payment_parking_reg": "$40.00",
"payment_ice": 2,
"payment_method": "stripe"
}`); }`);
const exampleCurl = esc(`curl -X POST ${WEBHOOK_URL} \\ const exampleCurl = esc(`curl -X POST ${WEBHOOK_URL} \\
-H "Content-Type: application/json" \\ -H "Content-Type: application/json" \\
-H "X-Webhook-Secret: <your WEBHOOK_SECRET>" \\ -H "X-Webhook-Secret: <your WEBHOOK_SECRET>" \\
-d '{"name":"Jane Bear","email":"jane@example.com","submission_id":"12345","ages_26_45":2,"ice_access":"yes"}'`); -d @submission.json`);
const PAGE = `<!doctype html> const PAGE = `<!doctype html>
<html lang="en"> <html lang="en">
@ -82,7 +85,7 @@ const PAGE = `<!doctype html>
:root { color-scheme: dark; } :root { color-scheme: dark; }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.55; } body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.55; }
.wrap { max-width: 820px; margin: 0 auto; padding: 28px 20px 64px; } .wrap { max-width: 900px; margin: 0 auto; padding: 28px 20px 64px; }
h1 { font-size: 26px; margin: 0 0 4px; } h1 { font-size: 26px; margin: 0 0 4px; }
h2 { font-size: 20px; margin: 32px 0 10px; border-bottom: 1px solid #24382a; padding-bottom: 6px; } h2 { font-size: 20px; margin: 32px 0 10px; border-bottom: 1px solid #24382a; padding-bottom: 6px; }
.sub { color: #9db3a4; margin: 0 0 8px; } .sub { color: #9db3a4; margin: 0 0 8px; }
@ -104,31 +107,32 @@ const PAGE = `<!doctype html>
</head> </head>
<body> <body>
<div class="wrap"> <div class="wrap">
<h1>🐻 Camp Scan Purchase Webhook</h1> <h1>🐻 Camp Scan Purchase Webhook (Tickets 2026)</h1>
<p class="sub">How the FluentForms ticket checkout notifies the ticketing backend to create a ticket and email the QR code.</p> <p class="sub">How the FluentForms "Tickets 2026" checkout notifies the ticketing backend to create a ticket and email the QR code.</p>
<div class="kv"> <div class="kv">
<div><b>Endpoint</b>&nbsp; <span class="pill">POST</span> <code>${WEBHOOK_URL}</code></div> <div><b>Endpoint</b>&nbsp; <span class="pill">POST</span> <code>${WEBHOOK_URL}</code></div>
<div><b>Auth header</b>&nbsp; <code>X-Webhook-Secret: &lt;the shared WEBHOOK_SECRET&gt;</code></div> <div><b>Auth header</b>&nbsp; <code>X-Webhook-Secret: &lt;the shared WEBHOOK_SECRET&gt;</code></div>
<div><b>Body format</b>&nbsp; JSON (<code>application/json</code>) or form-encoded both accepted.</div> <div><b>Body format</b>&nbsp; JSON (<code>application/json</code>) or form-encoded both accepted. Send all form fields.</div>
</div> </div>
<h2>What it does</h2> <h2>What it does</h2>
<p>On a valid request the backend generates a unique ticket code, creates a row in the "2026 Campground Tickets" NocoDB table, renders a QR code, and emails it to the purchaser (subject: <b>"2026 Beartaria Campgrounds Tickets"</b>). The total number of redeemable tickets is the <b>sum of the age-bracket counts, excluding ages 03</b> (who are free).</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>
<h2>Fields</h2> <h2>Fields</h2>
<table> <table>
<thead><tr><th>Key</th><th>Required</th><th>Type</th><th>Description</th></tr></thead> <thead><tr><th>Key</th><th>Required</th><th>Type</th><th>Description</th></tr></thead>
<tbody>${rows}</tbody> <tbody>${rows}</tbody>
</table> </table>
<p class="sub">At least one non-zero age-bracket count is required (otherwise there are no tickets to issue). Booleans accept <code>1/0</code>, <code>true/false</code>, or <code>yes/no</code>.</p> <p class="sub">Compound name fields arrive as objects (<code>names: {first_name,}</code>) or flattened <code>names[first_name]</code> keys both handled. Quantity/payment fields accept numbers, money strings ("$40.00"), or <code>{quantity}</code> objects.</p>
<h2>Idempotency</h2> <h2>Idempotency</h2>
<p>Send a stable <code>submission_id</code>. If the backend sees the same one again it returns <code>{"status":"duplicate"}</code> without creating a second ticket or re-sending email so FluentForms retries and accidental double-submits are safe.</p> <p>Send a stable <code>id</code> / <code>submission_id</code>. A repeat returns <code>{"status":"duplicate"}</code> without creating a second ticket or re-emailing safe for retries and double-submits.</p>
<h2>Example payload</h2> <h2>Example payload</h2>
<pre><code>${exampleJson}</code></pre> <pre><code>${exampleJson}</code></pre>
<p class="sub">This issues 5 redeemable tickets (3×812 + 2×2645; the two 03 are free), with car parking and 3 ice bags.</p> <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>
<h2>Test with curl</h2> <h2>Test with curl</h2>
<pre><code>${exampleCurl}</code></pre> <pre><code>${exampleCurl}</code></pre>
@ -139,7 +143,7 @@ const PAGE = `<!doctype html>
<tbody> <tbody>
<tr><td>200</td><td><code>{"status":"created","code":"BC26-…","emailSent":true}</code></td><td>Ticket created and emailed.</td></tr> <tr><td>200</td><td><code>{"status":"created","code":"BC26-…","emailSent":true}</code></td><td>Ticket created and emailed.</td></tr>
<tr><td>200</td><td><code>{"status":"duplicate","code":"BC26-…"}</code></td><td>Same submission already processed no-op.</td></tr> <tr><td>200</td><td><code>{"status":"duplicate","code":"BC26-…"}</code></td><td>Same submission already processed no-op.</td></tr>
<tr><td>400</td><td><code>{"error":"missing_fields"}</code> / <code>"no_tickets"</code></td><td>Missing name/email, or no age counts.</td></tr> <tr><td>400</td><td><code>{"error":"missing_fields"}</code> / <code>"no_tickets"</code></td><td>Missing purchaser name, or zero scannable tickets.</td></tr>
<tr><td>401</td><td><code>{"error":"unauthorized"}</code></td><td>Missing or wrong <code>X-Webhook-Secret</code>.</td></tr> <tr><td>401</td><td><code>{"error":"unauthorized"}</code></td><td>Missing or wrong <code>X-Webhook-Secret</code>.</td></tr>
<tr><td>502</td><td><code>{"status":"created","emailSent":false,}</code></td><td>Ticket row created but the email failed re-send from the admin app.</td></tr> <tr><td>502</td><td><code>{"status":"created","emailSent":false,}</code></td><td>Ticket row created but the email failed re-send from the admin app.</td></tr>
</tbody> </tbody>
@ -148,11 +152,10 @@ const PAGE = `<!doctype html>
<h2>FluentForms setup</h2> <h2>FluentForms setup</h2>
<ol> <ol>
<li>On the ticket form: <b>Settings &amp; Integrations Webhook Add Webhook</b>.</li> <li>On the ticket form: <b>Settings &amp; Integrations Webhook Add Webhook</b>.</li>
<li><b>Request URL:</b> <code>${WEBHOOK_URL}</code></li> <li><b>Request URL:</b> <code>${WEBHOOK_URL}</code>&nbsp; · &nbsp;<b>Method:</b> <code>POST</code>&nbsp; · &nbsp;<b>Format:</b> <code>JSON</code></li>
<li><b>Request Method:</b> <code>POST</code>&nbsp; · &nbsp;<b>Format:</b> <code>JSON</code></li>
<li><b>Request Headers:</b> add <code>X-Webhook-Secret</code> = the shared secret.</li> <li><b>Request Headers:</b> add <code>X-Webhook-Secret</code> = the shared secret.</li>
<li><b>Request Body:</b> map each form field to the keys in the table above.</li> <li><b>Request Body:</b> send <b>all fields</b> (the field names above are the FluentForms field keys).</li>
<li>Save, then 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>
<footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer> <footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer>

View file

@ -86,14 +86,13 @@ export function fakeContext(db: FakeNocoDB): AppContext {
export async function seedTicket( export async function seedTicket(
db: FakeNocoDB, db: FakeNocoDB,
opts: { code: string; name?: string; email?: string; ages?: Record<string, number>; redeemed?: number }, opts: { code: string; name?: string; email?: string; adults?: number; redeemed?: number },
): Promise<NocoRecord> { ): Promise<NocoRecord> {
const ages = opts.ages ?? { "Ages 18-25": 2, "Ages 26-45": 3, "Ages 0-3": 1 };
return db.create({ return db.create({
[COL.code]: opts.code, [COL.code]: opts.code,
[COL.name]: opts.name ?? "Test Bear", [COL.name]: opts.name ?? "Test Bear",
[COL.email]: opts.email ?? "test@example.com", [COL.email]: opts.email ?? "test@example.com",
[COL.adults]: opts.adults ?? 5,
[COL.redeemed]: opts.redeemed ?? 0, [COL.redeemed]: opts.redeemed ?? 0,
...ages,
}); });
} }

View file

@ -2,45 +2,52 @@ 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 age brackets but excludes Ages 0-3 (free)", () => { it("sums adults + youth + kids 10-12 + kids 5-9, excluding kids 0-4 (free)", () => {
const rec = { const rec = {
Id: 1, Id: 1,
"Ages 0-3": 2, // free, not counted [COL.adults]: 2,
"Ages 4-7": 1, [COL.youth]: 1,
"Ages 18-25": 2, [COL.kids12]: 1,
"Ages 26-45": 1, [COL.kids9]: 1,
[COL.kids4]: 3, // free, not counted
}; };
expect(computeTotal(rec)).toBe(4); expect(computeTotal(rec)).toBe(5);
}); });
it("coerces string counts and treats blanks as 0", () => { it("coerces string counts and treats blanks as 0", () => {
const rec = { Id: 1, "Ages 18-25": "3", "Ages 26-45": "" } as any; const rec = { Id: 1, [COL.adults]: "3", [COL.youth]: "" } as any;
expect(computeTotal(rec)).toBe(3); expect(computeTotal(rec)).toBe(3);
}); });
}); });
describe("toView", () => { describe("toView", () => {
it("derives remaining and surfaces extras", () => { it("derives remaining and surfaces adult names, donor tier, and extras", () => {
const rec = { const rec = {
Id: 7, Id: 7,
[COL.code]: "BC26-ABCD-2345", [COL.code]: "BC26-ABCD-2345",
[COL.name]: "Jane Bear", [COL.name]: "Jane Bear",
[COL.email]: "jane@example.com", [COL.email]: "jane@example.com",
[COL.adultNames]: "Jane Bear\nJohn Bear",
[COL.redeemed]: 2, [COL.redeemed]: 2,
[COL.adults]: 2,
[COL.youth]: 3,
[COL.kids4]: 1,
[COL.carParking]: true, [COL.carParking]: true,
[COL.iceAccess]: "yes", [COL.iceAccess]: "yes",
"Ages 0-3": 1, [COL.donorTier]: "member",
"Ages 18-25": 2, [COL.vouchers]: 2,
"Ages 26-45": 3,
}; };
const v = toView(rec); const v = toView(rec);
expect(v.total).toBe(5); expect(v.total).toBe(5);
expect(v.redeemed).toBe(2); expect(v.redeemed).toBe(2);
expect(v.remaining).toBe(3); expect(v.remaining).toBe(3);
expect(v.adultNames).toEqual(["Jane Bear", "John Bear"]);
expect(v.extras.carParking).toBe(true); expect(v.extras.carParking).toBe(true);
expect(v.extras.iceAccess).toBe(true); expect(v.extras.iceAccess).toBe(true);
expect(v.extras.rvParking).toBe(false); expect(v.extras.rvParking).toBe(false);
expect(v.extras.freeUnder4).toBe(1); expect(v.extras.donorTier).toBe("member");
expect(v.ages.find((a) => a.bracket === "0-3")?.free).toBe(true); expect(v.extras.vouchers).toBe(2);
expect(v.extras.freeUnder5).toBe(1);
expect(v.ages.find((a) => a.bracket === "Kids 0-4")?.free).toBe(true);
}); });
}); });

View file

@ -6,7 +6,7 @@ import { COL } from "../fields.js";
describe("redeem", () => { describe("redeem", () => {
it("checks in a single walk-up (default count 1)", async () => { it("checks in a single walk-up (default count 1)", async () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-AAAA-1111", ages: { "Ages 26-45": 4 } }); await seedTicket(db, { code: "BC26-AAAA-1111", adults: 4 });
const ctx = fakeContext(db); const ctx = fakeContext(db);
const r = await redeem(ctx, "BC26-AAAA-1111", 1); const r = await redeem(ctx, "BC26-AAAA-1111", 1);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
@ -20,7 +20,7 @@ describe("redeem", () => {
it("supports group check-in and QR reuse across visits", async () => { it("supports group check-in and QR reuse across visits", async () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
// Party of 7 (2 free under-4 not counted): total 5. // Party of 7 (2 free under-4 not counted): total 5.
await seedTicket(db, { code: "BC26-FAM-0001", ages: { "Ages 0-3": 2, "Ages 26-45": 2, "Ages 8-12": 3 } }); await seedTicket(db, { code: "BC26-FAM-0001", adults: 5 });
const ctx = fakeContext(db); const ctx = fakeContext(db);
const first = await redeem(ctx, "BC26-FAM-0001", 2); // father + son const first = await redeem(ctx, "BC26-FAM-0001", 2); // father + son
@ -36,7 +36,7 @@ describe("redeem", () => {
it("rejects over-redemption without mutating", async () => { it("rejects over-redemption without mutating", async () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-BBBB-2222", ages: { "Ages 26-45": 2 } }); await seedTicket(db, { code: "BC26-BBBB-2222", adults: 2 });
const ctx = fakeContext(db); const ctx = fakeContext(db);
const r = await redeem(ctx, "BC26-BBBB-2222", 5); const r = await redeem(ctx, "BC26-BBBB-2222", 5);
expect(r.ok).toBe(false); expect(r.ok).toBe(false);
@ -46,7 +46,7 @@ describe("redeem", () => {
it("allows negative count to undo, clamped at zero", async () => { it("allows negative count to undo, clamped at zero", async () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-CCCC-3333", ages: { "Ages 26-45": 3 }, redeemed: 2 }); await seedTicket(db, { code: "BC26-CCCC-3333", adults: 3, redeemed: 2 });
const ctx = fakeContext(db); const ctx = fakeContext(db);
const r = await redeem(ctx, "BC26-CCCC-3333", -5); const r = await redeem(ctx, "BC26-CCCC-3333", -5);
expect(r.ok).toBe(true); expect(r.ok).toBe(true);
@ -55,7 +55,7 @@ describe("redeem", () => {
it("writes an audit entry on each successful check-in and undo", async () => { it("writes an audit entry on each successful check-in and undo", async () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-AUDT-0001", ages: { "Ages 26-45": 4 } }); await seedTicket(db, { code: "BC26-AUDT-0001", adults: 4 });
const ctx = fakeContext(db); const ctx = fakeContext(db);
await redeem(ctx, "BC26-AUDT-0001", 2); await redeem(ctx, "BC26-AUDT-0001", 2);
await redeem(ctx, "BC26-AUDT-0001", -1); await redeem(ctx, "BC26-AUDT-0001", -1);
@ -67,7 +67,7 @@ describe("redeem", () => {
it("does not audit a no-op (undo when nothing redeemed)", async () => { it("does not audit a no-op (undo when nothing redeemed)", async () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-AUDT-0002", ages: { "Ages 26-45": 3 }, redeemed: 0 }); await seedTicket(db, { code: "BC26-AUDT-0002", adults: 3, redeemed: 0 });
const ctx = fakeContext(db); const ctx = fakeContext(db);
await redeem(ctx, "BC26-AUDT-0002", -2); // clamps to 0, delta 0 await redeem(ctx, "BC26-AUDT-0002", -2); // clamps to 0, delta 0
expect((ctx.audit as any).entries).toHaveLength(0); expect((ctx.audit as any).entries).toHaveLength(0);
@ -77,7 +77,7 @@ describe("redeem", () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
await seedTicket(db, { await seedTicket(db, {
code: "BC26-ICE-0003", code: "BC26-ICE-0003",
ages: { "Ages 26-45": 2 }, adults: 2,
}); });
// Give the ticket 3 prepaid ice bags. // Give the ticket 3 prepaid ice bags.
db.rows[0]["Ice Total"] = 3; db.rows[0]["Ice Total"] = 3;
@ -112,7 +112,7 @@ describe("redeem", () => {
it("surfaces db_error when the update fails", async () => { it("surfaces db_error when the update fails", async () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-DDDD-4444", ages: { "Ages 26-45": 3 } }); await seedTicket(db, { code: "BC26-DDDD-4444", adults: 3 });
db.failNext = true; db.failNext = true;
const ctx = fakeContext(db); const ctx = fakeContext(db);
const r = await redeem(ctx, "BC26-DDDD-4444", 1); const r = await redeem(ctx, "BC26-DDDD-4444", 1);
@ -122,7 +122,7 @@ describe("redeem", () => {
it("CONCURRENCY: 20 parallel single check-ins on a 5-ticket code yield exactly 5", async () => { it("CONCURRENCY: 20 parallel single check-ins on a 5-ticket code yield exactly 5", async () => {
const db = new FakeNocoDB(8); const db = new FakeNocoDB(8);
await seedTicket(db, { code: "BC26-RACE-0005", ages: { "Ages 26-45": 5 } }); await seedTicket(db, { code: "BC26-RACE-0005", adults: 5 });
const ctx = fakeContext(db); const ctx = fakeContext(db);
const results = await Promise.all( const results = await Promise.all(
@ -137,7 +137,7 @@ describe("redeem", () => {
describe("lookupByCode", () => { describe("lookupByCode", () => {
it("returns the ticket view without mutating", async () => { it("returns the ticket view without mutating", async () => {
const db = new FakeNocoDB(); const db = new FakeNocoDB();
await seedTicket(db, { code: "BC26-LOOK-0001", ages: { "Ages 26-45": 3 } }); await seedTicket(db, { code: "BC26-LOOK-0001", adults: 3 });
const ctx = fakeContext(db); const ctx = fakeContext(db);
const r = await lookupByCode(ctx, "BC26-LOOK-0001"); const r = await lookupByCode(ctx, "BC26-LOOK-0001");
expect(r.ok && r.found && r.ticket.remaining).toBe(3); expect(r.ok && r.found && r.ticket.remaining).toBe(3);
@ -159,7 +159,7 @@ describe("createTicket idempotency", () => {
const input = { const input = {
name: "Jane Bear", name: "Jane Bear",
email: "jane@example.com", email: "jane@example.com",
ages: { "Ages 26-45": 2 }, counts: { adults: 2, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
submissionKey: "sub:412", submissionKey: "sub:412",
}; };
const a = await createTicket(ctx, input); const a = await createTicket(ctx, input);

View file

@ -128,15 +128,19 @@ export async function search(ctx: AppContext, query: string): Promise<TicketView
export interface WebhookInput { export interface WebhookInput {
name: string; name: string;
adultNames?: string[];
email: string; email: string;
address?: string; address?: string;
isDonor?: boolean; isDonor?: boolean;
donorTier?: string;
vouchers?: number;
counts: { adults: number; youth: number; kids12: number; kids9: number; kids4: number };
carParking?: boolean; carParking?: boolean;
rvParking?: boolean; rvParking?: boolean;
utv?: boolean;
iceAccess?: boolean; iceAccess?: boolean;
iceBags?: number; // prepaid ice bags iceBags?: number; // prepaid ice bags/tickets
paymentMethod?: string; paymentMethod?: string;
ages: Record<string, number>; // NocoDB age-column title -> count
submissionKey: string; submissionKey: string;
} }
@ -158,20 +162,29 @@ export async function createTicket(
code = generateCode(); code = generateCode();
} }
const c = input.counts;
const fields: Record<string, unknown> = { const fields: Record<string, unknown> = {
[COL.name]: input.name, [COL.name]: input.name,
[COL.email]: input.email, [COL.email]: input.email,
[COL.code]: code, [COL.code]: code,
[COL.redeemed]: 0, [COL.redeemed]: 0,
[COL.adults]: c.adults,
[COL.youth]: c.youth,
[COL.kids12]: c.kids12,
[COL.kids9]: c.kids9,
[COL.kids4]: c.kids4,
[COL.iceTotal]: input.iceBags ?? 0, [COL.iceTotal]: input.iceBags ?? 0,
[COL.iceRedeemed]: 0, [COL.iceRedeemed]: 0,
[COL.submissionKey]: input.submissionKey, [COL.submissionKey]: input.submissionKey,
...input.ages,
}; };
if (input.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n");
if (input.address !== undefined) fields[COL.address] = input.address; if (input.address !== undefined) fields[COL.address] = input.address;
if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor; if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor;
if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier;
if (input.vouchers !== undefined) fields[COL.vouchers] = input.vouchers;
if (input.carParking !== undefined) fields[COL.carParking] = input.carParking; if (input.carParking !== undefined) fields[COL.carParking] = input.carParking;
if (input.rvParking !== undefined) fields[COL.rvParking] = input.rvParking; if (input.rvParking !== undefined) fields[COL.rvParking] = input.rvParking;
if (input.utv !== undefined) fields[COL.utv] = input.utv;
if (input.iceAccess !== undefined) fields[COL.iceAccess] = input.iceAccess; if (input.iceAccess !== undefined) fields[COL.iceAccess] = input.iceAccess;
if (input.paymentMethod !== undefined) fields[COL.paymentMethod] = input.paymentMethod; if (input.paymentMethod !== undefined) fields[COL.paymentMethod] = input.paymentMethod;