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_OFFLINE_TABLE_ID=
# Ice bags granted when a purchase includes ice but the webhook sends only a boolean
ICE_BAGS_DEFAULT=3
# Ice: form sells 1-4 ice tickets at $20 each; one ticket = 3 bags. The webhook
# 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
# VOUCHER_SINCE totalling >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the
@ -33,7 +35,7 @@ ENABLE_TEST_PAGE=false
# MailerSend
MAILERSEND_API_TOKEN=
MAIL_FROM_EMAIL=tickets@beartariacampgrounds.com
MAIL_FROM_EMAIL=info@beartariacampgrounds.com
MAIL_FROM_NAME=Beartaria Campgrounds
# 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]);
const tags: string[] = [];
if (ticket.extras.carParking) tags.push("🚗 Car");
if (ticket.extras.rvParking) tags.push("🚐 RV");
if (ticket.extras.iceAccess) tags.push("🧊 Ice");
if (ticket.extras.isDonor) tags.push("⭐ Donor");
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} free`);
const e = ticket.extras;
if (e.donorTier === "member") tags.push("🐻 Member");
else if (e.isDonor) tags.push("⭐ Donor");
if (e.carParking) tags.push("🚗 Car");
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 (
<View style={styles.card}>

View file

@ -259,6 +259,7 @@ export default function ScannerScreen() {
<Text style={styles.counts}>
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
</Text>
<AdultNames names={ticket.adultNames} />
<ExtrasRow ticket={ticket} />
</>
)}
@ -345,11 +346,14 @@ function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticke
function ExtrasRow({ ticket }: { ticket: TicketView }) {
const tags: string[] = [];
if (ticket.extras.carParking) tags.push("🚗 Car parking");
if (ticket.extras.rvParking) tags.push("🚐 RV parking");
if (ticket.extras.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`);
if (ticket.extras.isDonor) tags.push("⭐ Donor");
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} under 4 (free)`);
const e = ticket.extras;
if (e.donorTier === "member") tags.push("🐻 Member");
else if (e.isDonor) tags.push("⭐ Donor");
if (e.carParking) tags.push("🚗 Car parking");
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;
return (
<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({
ticket,
isIce,
@ -393,6 +410,7 @@ function ConfirmCard({
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining
</Text>
<Text style={styles.cardSub}>{redeemed} already redeemed</Text>
{!isIce && <AdultNames names={ticket.adultNames} />}
{!isIce && <ExtrasRow ticket={ticket} />}
{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 },
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 },
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;
remaining: number;
ice: ResourceCount;
adultNames: string[];
extras: {
carParking: boolean;
rvParking: boolean;
utv: boolean;
iceAccess: boolean;
isDonor: boolean;
freeUnder4: number;
donorTier: string;
vouchers: number;
freeUnder5: number;
};
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_OFFLINE_TABLE_ID: z.string().optional(),
// Prepaid ice bags granted when a purchase includes ice but the webhook only
// sends a boolean (not an explicit bag count).
ICE_BAGS_DEFAULT: z.coerce.number().default(3),
// Ice: the form sells 1-4 ice "tickets" at $20 each; one ticket = 3 bags.
// The webhook reads payment_ice as either a ticket count (1-4) or a dollar
// 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).
// 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
* webhook payload keys, and the view we return to the app.
*
* 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.
* 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", // 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",
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",
// Columns this system adds to the table:
// Columns this system manages:
code: "Ticket Code",
redeemed: "Redeemed",
submissionKey: "SubmissionKey",
lastScanAt: "LastScanAt",
iceTotal: "Ice Total", // prepaid ice bags
iceRedeemed: "Ice Redeemed", // bags picked up
iceTotal: "Ice Total",
iceRedeemed: "Ice Redeemed",
} 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 };
function num(v: unknown): number {
@ -57,23 +48,40 @@ function bool(v: unknown): boolean {
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 {
let total = 0;
for (const col of AGE_COLUMNS) {
if (FREE_AGE_COLUMNS.includes(col)) continue;
total += num(rec[col]);
}
return total;
return num(rec[COL.adults]) + num(rec[COL.youth]) + num(rec[COL.kids12]) + num(rec[COL.kids9]);
}
/** 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 }[] {
return AGE_COLUMNS.map((col) => ({
bracket: col.replace(/^Ages /, ""),
count: num(rec[col]),
free: FREE_AGE_COLUMNS.includes(col),
})).filter((b) => b.count > 0);
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 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 {
@ -90,20 +98,20 @@ export interface TicketView {
redeemed: number;
remaining: number;
ice: ResourceCount;
adultNames: string[];
extras: {
carParking: boolean;
rvParking: boolean;
utv: boolean;
iceAccess: boolean;
isDonor: boolean;
freeUnder4: number;
donorTier: string;
vouchers: number;
freeUnder5: number;
};
ages: { bracket: string; count: number; free: boolean }[];
}
export function computeIceTotal(rec: NocoRecord): number {
return num(rec[COL.iceTotal]);
}
export function toView(rec: NocoRecord): TicketView {
const total = computeTotal(rec);
const redeemed = num(rec[COL.redeemed]);
@ -121,12 +129,16 @@ export function toView(rec: NocoRecord): TicketView {
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]),
freeUnder4: num(rec["Ages 0-3"]),
donorTier: String(rec[COL.donorTier] ?? ""),
vouchers: num(rec[COL.vouchers]),
freeUnder5: num(rec[COL.kids4]),
},
ages: ageBreakdown(rec),
};

View file

@ -7,57 +7,68 @@ interface Persona {
key: string;
name: string;
email: string;
ages: Record<string, number>;
adultNames?: string[];
counts: { adults: number; youth: number; kids12: number; kids9: number; kids4: number };
iceBags?: number;
carParking?: boolean;
rvParking?: boolean;
utv?: boolean;
isDonor?: boolean;
donorTier?: string;
exhaust?: boolean; // pre-redeem all tickets so it scans as "exhausted"
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.
const PERSONAS: Persona[] = [
{
key: "solo",
name: "Solo Sam",
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.",
},
{
key: "family",
name: "Family Fay",
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,
carParking: true,
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",
name: "Donor Dan",
email: "donor@example.test",
ages: { "Ages 26-45": 2 },
adultNames: ["Donor Dan", "Donna Dan"],
counts: C(2),
rvParking: true,
utv: true,
isDonor: true,
donorTier: "member",
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",
name: "Ice Ike",
email: "ice@test.beartaria",
ages: { "Ages 18-25": 1 },
iceBags: 3,
blurb: "1 ticket + 3 ice bags. Ice mode → grab all 3 at once, then scan again → exhausted.",
adultNames: ["Ice Ike"],
counts: C(1),
iceBags: 6, // 2 ice tickets
blurb: "1 ticket + 6 ice bags (2 ice tickets). Ice mode → grab bags, then scan again → exhausted.",
},
{
key: "exhausted",
name: "Done Dora",
email: "done@test.beartaria",
ages: { "Ages 26-45": 2 },
counts: C(2),
exhaust: true,
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) {
const result = await createTicket(app.ctx, {
name: p.name,
adultNames: p.adultNames,
email: p.email,
ages: p.ages,
counts: p.counts,
iceBags: p.iceBags,
carParking: p.carParking,
rvParking: p.rvParking,
utv: p.utv,
isDonor: p.isDonor,
donorTier: p.donorTier,
submissionKey: `test:${p.key}`,
});
// Keep the "exhausted" persona fully redeemed on every load so its state
// is deterministic (compute the total from the persona's own age counts,
// since NocoDB's create response may not echo them back).
// is deterministic (total = scannable count from the persona's counts).
if (p.exhaust) {
const total = Object.entries(p.ages)
.filter(([col]) => col !== "Ages 0-3")
.reduce((s, [, n]) => s + n, 0);
await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: total });
const { adults, youth, kids12, kids9 } = p.counts;
await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth + kids12 + kids9 });
}
cards.push({
code: result.code,

View file

@ -167,11 +167,16 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
schema: {
body: {
type: "object",
required: ["name", "ages"],
required: ["name"],
properties: {
name: { type: "string", minLength: 1 },
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" },
},
},
@ -182,8 +187,15 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
const submissionKey = `manual:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`;
const result = await createTicket(app.ctx, {
name: b.name,
adultNames: b.adultNames,
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,
});
if (b.sendEmail && b.email && !app.ctx.mailer.isBlockedRecipient(b.email)) {

View file

@ -1,6 +1,6 @@
import { createHash, timingSafeEqual } from "node:crypto";
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 { renderQrPng } from "../services/qrcode.js";
@ -11,18 +11,48 @@ function safeEqual(a: string, b: string): boolean {
return timingSafeEqual(ba, bb);
}
// Map webhook payload keys -> NocoDB age-column titles. Keys are what you map
// the FluentForms fields to in the webhook feed.
const AGE_KEY_TO_COL: Record<string, string> = {
ages_0_3: "Ages 0-3",
ages_4_7: "Ages 4-7",
ages_8_12: "Ages 8-12",
ages_13_17: "Ages 13-17",
ages_18_25: "Ages 18-25",
ages_26_45: "Ages 26-45",
ages_46_64: "Ages 46-64",
ages_65: "Ages 65+",
};
/** 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;
}
// 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> {
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" });
}
const body = (req.body ?? {}) as Record<string, unknown>;
const name = String(body.name ?? "").trim();
const body = (req.body ?? {}) as Record<string, any>;
// 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();
if (!name || !email) {
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" });
if (!name) {
return reply.code(400).send({ error: "missing_fields", detail: "purchaser name is required" });
}
// Build age-bracket counts from whichever keys were provided.
const ages: Record<string, number> = {};
for (const [key, col] of Object.entries(AGE_KEY_TO_COL)) {
if (body[key] !== undefined && body[key] !== null && body[key] !== "") {
ages[col] = toNumber(body[key]);
}
}
const anyAge = AGE_COLUMNS.some((c) => (ages[c] ?? 0) > 0);
if (!anyAge) {
return reply.code(400).send({ error: "no_tickets", detail: "no age-bracket counts provided" });
// Adult attendee names (non-empty groups, in order).
const adultNames = ADULT_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean);
// Attendee counts.
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),
kids12: qty(body.item_quantity_kids_12),
kids9: qty(body.item_quantity_kids_9),
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.
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id;
// Donor info (hidden fields from the eligibility/voucher lookups) + radio.
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
? `sub:${String(submissionId)}`
: "hash:" +
createHash("sha256")
.update(`${email}|${name}|${JSON.stringify(ages)}`)
.update(`${email}|${name}|${JSON.stringify(counts)}`)
.digest("hex")
.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>>;
try {
result = await createTicket(app.ctx, {
name,
adultNames,
email,
address: body.address !== undefined ? String(body.address) : undefined,
isDonor: body.is_donor !== undefined ? toBool(body.is_donor) : undefined,
carParking: body.car_parking !== undefined ? toBool(body.car_parking) : undefined,
rvParking: body.rv_parking !== undefined ? toBool(body.rv_parking) : undefined,
iceAccess: body.ice_access !== undefined ? toBool(body.ice_access) : undefined,
address,
isDonor,
donorTier,
vouchers,
counts,
carParking,
rvParking,
utv,
iceAccess,
iceBags,
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
ages,
submissionKey,
});
} catch (e: any) {
@ -93,9 +153,13 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
return { status: "duplicate", code: result.code };
}
// Send the ticket email. If it fails, the row already exists — report 502
// so the failure is visible in FluentForms' delivery log; the ticket can be
// re-sent later via POST /api/tickets/:code/resend-email.
// Send the ticket QR email (FluentForms sends the receipt separately). If it
// fails, the row already exists — report 502 so it's visible in the feed
// 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)) {
req.log.warn({ email }, "webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send");
return { status: "created", code: result.code, emailSent: false, emailSkipped: "trial_restriction" };
@ -103,13 +167,11 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
try {
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({
toEmail: email,
toName: name,
code: result.code,
quantity,
quantity: scannable,
qrPng: qr,
});
} catch (e: any) {

View file

@ -11,29 +11,26 @@ interface Field {
}
const FIELDS: Field[] = [
{ key: "name", req: "required", type: "text", desc: "Purchaser's full name." },
{ key: "email", req: "required", type: "email", desc: "Purchaser's email — the QR ticket is sent here." },
{
key: "submission_id",
req: "optional",
type: "text/number",
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: "ages_0_3", req: "optional", type: "number", desc: "Headcount ages 03. Admitted free — NOT counted toward redeemable tickets." },
{ key: "ages_4_7", req: "optional", type: "number", desc: "Headcount ages 47." },
{ key: "ages_8_12", req: "optional", type: "number", desc: "Headcount ages 812." },
{ key: "ages_13_17", req: "optional", type: "number", desc: "Headcount ages 1317." },
{ key: "ages_18_25", req: "optional", type: "number", desc: "Headcount ages 1825." },
{ key: "ages_26_45", req: "optional", type: "number", desc: "Headcount ages 2645." },
{ key: "ages_46_64", req: "optional", type: "number", desc: "Headcount ages 4664." },
{ key: "ages_65", req: "optional", type: "number", desc: "Headcount ages 65+." },
{ 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: "ice_access", req: "optional", type: "yes/no", desc: "Whether they bought ice access. Accepts 1/0, true/false, yes/no." },
{ key: "car_parking", req: "optional", type: "yes/no", desc: "Car parking pass." },
{ key: "rv_parking", req: "optional", type: "yes/no", desc: "RV parking pass." },
{ 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." },
{ 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: "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: "address_1", req: "optional", type: "address (compound)", desc: "Mailing address object; joined into one line." },
{ 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: "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)." },
{ key: "input_radio", req: "optional", type: "choice", desc: "'Are you a campground donor?' — also used as a donor signal." },
{ key: "payment_parking_reg / _donor", req: "optional", type: "payment", desc: "Car parking. Flagged if either variant is selected." },
{ key: "payment_rv_reg / _donor", req: "optional", type: "payment", desc: "RV. Flagged if either variant is selected." },
{ key: "payment_utv_reg / _donor", req: "optional", type: "payment", desc: "ATV/UTV. Flagged if either variant is selected." },
{ 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: "payment_method", req: "optional", type: "payment", desc: "Payment method label." },
{ key: "id / submission_id", req: "optional", type: "text", desc: "Entry/submission id for idempotency (retries won't duplicate). Falls back to a content hash." },
];
function esc(s: string): string {
@ -48,28 +45,34 @@ export async function webhookDocRoutes(app: FastifyInstance): Promise<void> {
const rows = FIELDS.map(
(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>${f.type}</td>
<td>${esc(f.type)}</td>
<td>${esc(f.desc)}</td>
</tr>`,
).join("");
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",
"submission_id": "12345",
"ages_0_3": 2,
"ages_8_12": 3,
"ages_26_45": 2,
"car_parking": "yes",
"ice_access": "yes"
"item_quantity_adult_ticket_reg": 2,
"item_quantity_adult_ticket_donor": 0,
"item_quantity_youth_ticket_reg": 1,
"item_quantity_kids_9": 2,
"item_quantity_kids_4": 2,
"donor_tier": "member",
"vouchers": 2,
"payment_parking_reg": "$40.00",
"payment_ice": 2,
"payment_method": "stripe"
}`);
const exampleCurl = esc(`curl -X POST ${WEBHOOK_URL} \\
-H "Content-Type: application/json" \\
-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>
<html lang="en">
@ -82,7 +85,7 @@ const PAGE = `<!doctype html>
:root { color-scheme: dark; }
* { 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; }
.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; }
h2 { font-size: 20px; margin: 32px 0 10px; border-bottom: 1px solid #24382a; padding-bottom: 6px; }
.sub { color: #9db3a4; margin: 0 0 8px; }
@ -104,31 +107,32 @@ const PAGE = `<!doctype html>
</head>
<body>
<div class="wrap">
<h1>🐻 Camp Scan Purchase Webhook</h1>
<p class="sub">How the FluentForms ticket checkout notifies the ticketing backend to create a ticket and email the QR code.</p>
<h1>🐻 Camp Scan Purchase Webhook (Tickets 2026)</h1>
<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><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>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>
<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>
<table>
<thead><tr><th>Key</th><th>Required</th><th>Type</th><th>Description</th></tr></thead>
<tbody>${rows}</tbody>
</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>
<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>
<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>
<pre><code>${exampleCurl}</code></pre>
@ -139,7 +143,7 @@ const PAGE = `<!doctype html>
<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":"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>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>
@ -148,11 +152,10 @@ const PAGE = `<!doctype html>
<h2>FluentForms setup</h2>
<ol>
<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 Method:</b> <code>POST</code>&nbsp; · &nbsp;<b>Format:</b> <code>JSON</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 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>Save, then submit a test purchase and confirm the QR email arrives.</li>
<li><b>Request Body:</b> send <b>all fields</b> (the field names above are the FluentForms field keys).</li>
<li>Save, submit a test purchase, and confirm the QR email arrives.</li>
</ol>
<footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer>

View file

@ -86,14 +86,13 @@ export function fakeContext(db: FakeNocoDB): AppContext {
export async function seedTicket(
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> {
const ages = opts.ages ?? { "Ages 18-25": 2, "Ages 26-45": 3, "Ages 0-3": 1 };
return db.create({
[COL.code]: opts.code,
[COL.name]: opts.name ?? "Test Bear",
[COL.email]: opts.email ?? "test@example.com",
[COL.adults]: opts.adults ?? 5,
[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";
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 = {
Id: 1,
"Ages 0-3": 2, // free, not counted
"Ages 4-7": 1,
"Ages 18-25": 2,
"Ages 26-45": 1,
[COL.adults]: 2,
[COL.youth]: 1,
[COL.kids12]: 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", () => {
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);
});
});
describe("toView", () => {
it("derives remaining and surfaces extras", () => {
it("derives remaining and surfaces adult names, donor tier, and extras", () => {
const rec = {
Id: 7,
[COL.code]: "BC26-ABCD-2345",
[COL.name]: "Jane Bear",
[COL.email]: "jane@example.com",
[COL.adultNames]: "Jane Bear\nJohn Bear",
[COL.redeemed]: 2,
[COL.adults]: 2,
[COL.youth]: 3,
[COL.kids4]: 1,
[COL.carParking]: true,
[COL.iceAccess]: "yes",
"Ages 0-3": 1,
"Ages 18-25": 2,
"Ages 26-45": 3,
[COL.donorTier]: "member",
[COL.vouchers]: 2,
};
const v = toView(rec);
expect(v.total).toBe(5);
expect(v.redeemed).toBe(2);
expect(v.remaining).toBe(3);
expect(v.adultNames).toEqual(["Jane Bear", "John Bear"]);
expect(v.extras.carParking).toBe(true);
expect(v.extras.iceAccess).toBe(true);
expect(v.extras.rvParking).toBe(false);
expect(v.extras.freeUnder4).toBe(1);
expect(v.ages.find((a) => a.bracket === "0-3")?.free).toBe(true);
expect(v.extras.donorTier).toBe("member");
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", () => {
it("checks in a single walk-up (default count 1)", async () => {
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 r = await redeem(ctx, "BC26-AAAA-1111", 1);
expect(r.ok).toBe(true);
@ -20,7 +20,7 @@ describe("redeem", () => {
it("supports group check-in and QR reuse across visits", async () => {
const db = new FakeNocoDB();
// 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 first = await redeem(ctx, "BC26-FAM-0001", 2); // father + son
@ -36,7 +36,7 @@ describe("redeem", () => {
it("rejects over-redemption without mutating", async () => {
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 r = await redeem(ctx, "BC26-BBBB-2222", 5);
expect(r.ok).toBe(false);
@ -46,7 +46,7 @@ describe("redeem", () => {
it("allows negative count to undo, clamped at zero", async () => {
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 r = await redeem(ctx, "BC26-CCCC-3333", -5);
expect(r.ok).toBe(true);
@ -55,7 +55,7 @@ describe("redeem", () => {
it("writes an audit entry on each successful check-in and undo", async () => {
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);
await redeem(ctx, "BC26-AUDT-0001", 2);
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 () => {
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);
await redeem(ctx, "BC26-AUDT-0002", -2); // clamps to 0, delta 0
expect((ctx.audit as any).entries).toHaveLength(0);
@ -77,7 +77,7 @@ describe("redeem", () => {
const db = new FakeNocoDB();
await seedTicket(db, {
code: "BC26-ICE-0003",
ages: { "Ages 26-45": 2 },
adults: 2,
});
// Give the ticket 3 prepaid ice bags.
db.rows[0]["Ice Total"] = 3;
@ -112,7 +112,7 @@ describe("redeem", () => {
it("surfaces db_error when the update fails", async () => {
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;
const ctx = fakeContext(db);
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 () => {
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 results = await Promise.all(
@ -137,7 +137,7 @@ describe("redeem", () => {
describe("lookupByCode", () => {
it("returns the ticket view without mutating", async () => {
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 r = await lookupByCode(ctx, "BC26-LOOK-0001");
expect(r.ok && r.found && r.ticket.remaining).toBe(3);
@ -159,7 +159,7 @@ describe("createTicket idempotency", () => {
const input = {
name: "Jane Bear",
email: "jane@example.com",
ages: { "Ages 26-45": 2 },
counts: { adults: 2, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
submissionKey: "sub:412",
};
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 {
name: string;
adultNames?: string[];
email: string;
address?: string;
isDonor?: boolean;
donorTier?: string;
vouchers?: number;
counts: { adults: number; youth: number; kids12: number; kids9: number; kids4: number };
carParking?: boolean;
rvParking?: boolean;
utv?: boolean;
iceAccess?: boolean;
iceBags?: number; // prepaid ice bags
iceBags?: number; // prepaid ice bags/tickets
paymentMethod?: string;
ages: Record<string, number>; // NocoDB age-column title -> count
submissionKey: string;
}
@ -158,20 +162,29 @@ export async function createTicket(
code = generateCode();
}
const c = input.counts;
const fields: Record<string, unknown> = {
[COL.name]: input.name,
[COL.email]: input.email,
[COL.code]: code,
[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.iceRedeemed]: 0,
[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.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.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.paymentMethod !== undefined) fields[COL.paymentMethod] = input.paymentMethod;