diff --git a/.env.example b/.env.example index edaaaef..2fe3055 100644 --- a/.env.example +++ b/.env.example @@ -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) diff --git a/app/app/admin.tsx b/app/app/admin.tsx index fd9c121..f492fc9 100644 --- a/app/app/admin.tsx +++ b/app/app/admin.tsx @@ -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 ( diff --git a/app/app/index.tsx b/app/app/index.tsx index 851d9c7..297e1d9 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -259,6 +259,7 @@ export default function ScannerScreen() { {ticket.redeemed} of {ticket.total} redeemed ยท {ticket.remaining} remaining + )} @@ -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 ( @@ -362,6 +366,19 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) { ); } +function AdultNames({ names }: { names: string[] }) { + if (!names.length) return null; + return ( + + {names.map((n, i) => ( + + {n} + + ))} + + ); +} + function ConfirmCard({ ticket, isIce, @@ -393,6 +410,7 @@ function ConfirmCard({ {remaining} of {total} {unit} remaining {redeemed} already redeemed + {!isIce && } {!isIce && } {isIce && total === 0 && This ticket did not prepay for ice.} @@ -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" }, diff --git a/app/lib/api.ts b/app/lib/api.ts index 17f2bea..84dd309 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -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 }[]; } diff --git a/backend/src/config.ts b/backend/src/config.ts index b8941ef..df123e5 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -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 diff --git a/backend/src/fields.ts b/backend/src/fields.ts index c34bff7..5c669e8 100644 --- a/backend/src/fields.ts +++ b/backend/src/fields.ts @@ -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 & { 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), }; diff --git a/backend/src/routes/test.ts b/backend/src/routes/test.ts index 4e51d1e..4118f79 100644 --- a/backend/src/routes/test.ts +++ b/backend/src/routes/test.ts @@ -7,57 +7,68 @@ interface Persona { key: string; name: string; email: string; - ages: Record; + 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 { 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, diff --git a/backend/src/routes/tickets.ts b/backend/src/routes/tickets.ts index c99c2b8..eaa2a67 100644 --- a/backend/src/routes/tickets.ts +++ b/backend/src/routes/tickets.ts @@ -167,11 +167,16 @@ export async function ticketRoutes(app: FastifyInstance): Promise { 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 { 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)) { diff --git a/backend/src/routes/webhook.ts b/backend/src/routes/webhook.ts index b1352a8..9d96961 100644 --- a/backend/src/routes/webhook.ts +++ b/backend/src/routes/webhook.ts @@ -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 = { - 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, 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 { const handler = async (req: any, reply: any) => { @@ -31,57 +61,87 @@ export async function webhookRoutes(app: FastifyInstance): Promise { return reply.code(401).send({ error: "unauthorized" }); } - const body = (req.body ?? {}) as Record; - const name = String(body.name ?? "").trim(); + const body = (req.body ?? {}) as Record; + + // 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 = {}; - 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>; 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 { 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 { 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) { diff --git a/backend/src/routes/webhookDoc.ts b/backend/src/routes/webhookDoc.ts index 89a1b34..8f7bd8a 100644 --- a/backend/src/routes/webhookDoc.ts +++ b/backend/src/routes/webhookDoc.ts @@ -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 0โ€“3. Admitted free โ€” NOT counted toward redeemable tickets." }, - { key: "ages_4_7", req: "optional", type: "number", desc: "Headcount ages 4โ€“7." }, - { key: "ages_8_12", req: "optional", type: "number", desc: "Headcount ages 8โ€“12." }, - { key: "ages_13_17", req: "optional", type: "number", desc: "Headcount ages 13โ€“17." }, - { key: "ages_18_25", req: "optional", type: "number", desc: "Headcount ages 18โ€“25." }, - { key: "ages_26_45", req: "optional", type: "number", desc: "Headcount ages 26โ€“45." }, - { key: "ages_46_64", req: "optional", type: "number", desc: "Headcount ages 46โ€“64." }, - { 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 { const rows = FIELDS.map( (f) => ` - ${f.key} + ${esc(f.key)} ${f.req} - ${f.type} + ${esc(f.type)} ${esc(f.desc)} `, ).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: " \\ - -d '{"name":"Jane Bear","email":"jane@example.com","submission_id":"12345","ages_26_45":2,"ice_access":"yes"}'`); + -d @submission.json`); const PAGE = ` @@ -82,7 +85,7 @@ const PAGE = ` :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 = `
-

๐Ÿป Camp Scan โ€” Purchase Webhook

-

How the FluentForms ticket checkout notifies the ticketing backend to create a ticket and email the QR code.

+

๐Ÿป Camp Scan โ€” Purchase Webhook (Tickets 2026)

+

How the FluentForms "Tickets 2026" checkout notifies the ticketing backend to create a ticket and email the QR code.

Endpoint  POST ${WEBHOOK_URL}
Auth header  X-Webhook-Secret: <the shared WEBHOOK_SECRET>
-
Body format  JSON (application/json) or form-encoded โ€” both accepted.
+
Body format  JSON (application/json) or form-encoded โ€” both accepted. Send all form fields.

What it does

-

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: "2026 Beartaria Campgrounds Tickets"). The total number of redeemable tickets is the sum of the age-bracket counts, excluding ages 0โ€“3 (who are free).

+

On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject "2026 Beartaria Campgrounds Tickets"). FluentForms sends the payment receipt separately.

+

Scannable ticket total = adults + youth (13-16) + kids 10-12 + kids 5-9. Kids 0-4 are free and not counted. Each adult name provided is stored and shown to gate staff on a successful scan.

Fields

${rows}
KeyRequiredTypeDescription
-

At least one non-zero age-bracket count is required (otherwise there are no tickets to issue). Booleans accept 1/0, true/false, or yes/no.

+

Compound name fields arrive as objects (names: {first_name,โ€ฆ}) or flattened names[first_name] keys โ€” both handled. Quantity/payment fields accept numbers, money strings ("$40.00"), or {quantity} objects.

Idempotency

-

Send a stable submission_id. If the backend sees the same one again it returns {"status":"duplicate"} without creating a second ticket or re-sending email โ€” so FluentForms retries and accidental double-submits are safe.

+

Send a stable id / submission_id. A repeat returns {"status":"duplicate"} without creating a second ticket or re-emailing โ€” safe for retries and double-submits.

Example payload

${exampleJson}
-

This issues 5 redeemable tickets (3ร—8โ€“12 + 2ร—26โ€“45; the two 0โ€“3 are free), with car parking and 3 ice bags.

+

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).

Test with curl

${exampleCurl}
@@ -139,7 +143,7 @@ const PAGE = ` 200{"status":"created","code":"BC26-โ€ฆ","emailSent":true}Ticket created and emailed. 200{"status":"duplicate","code":"BC26-โ€ฆ"}Same submission already processed โ€” no-op. - 400{"error":"missing_fields"} / "no_tickets"Missing name/email, or no age counts. + 400{"error":"missing_fields"} / "no_tickets"Missing purchaser name, or zero scannable tickets. 401{"error":"unauthorized"}Missing or wrong X-Webhook-Secret. 502{"status":"created","emailSent":false,โ€ฆ}Ticket row created but the email failed โ€” re-send from the admin app. @@ -148,11 +152,10 @@ const PAGE = `

FluentForms setup

  1. On the ticket form: Settings & Integrations โ†’ Webhook โ†’ Add Webhook.
  2. -
  3. Request URL: ${WEBHOOK_URL}
  4. -
  5. Request Method: POST  ยท  Format: JSON
  6. +
  7. Request URL: ${WEBHOOK_URL}  ยท  Method: POST  ยท  Format: JSON
  8. Request Headers: add X-Webhook-Secret = the shared secret.
  9. -
  10. Request Body: map each form field to the keys in the table above.
  11. -
  12. Save, then submit a test purchase and confirm the QR email arrives.
  13. +
  14. Request Body: send all fields (the field names above are the FluentForms field keys).
  15. +
  16. Save, submit a test purchase, and confirm the QR email arrives.
Beartaria Campgrounds ยท scan.beartariacampgrounds.com
diff --git a/backend/src/test/fakeNocodb.ts b/backend/src/test/fakeNocodb.ts index 46cbd89..9a158f8 100644 --- a/backend/src/test/fakeNocodb.ts +++ b/backend/src/test/fakeNocodb.ts @@ -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; redeemed?: number }, + opts: { code: string; name?: string; email?: string; adults?: number; redeemed?: number }, ): Promise { - 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, }); } diff --git a/backend/src/test/fields.test.ts b/backend/src/test/fields.test.ts index 2287a11..834de59 100644 --- a/backend/src/test/fields.test.ts +++ b/backend/src/test/fields.test.ts @@ -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); }); }); diff --git a/backend/src/test/redeem.test.ts b/backend/src/test/redeem.test.ts index 72738a6..b2255e1 100644 --- a/backend/src/test/redeem.test.ts +++ b/backend/src/test/redeem.test.ts @@ -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); diff --git a/backend/src/ticketService.ts b/backend/src/ticketService.ts index 76fa4dc..f4e03aa 100644 --- a/backend/src/ticketService.ts +++ b/backend/src/ticketService.ts @@ -128,15 +128,19 @@ export async function search(ctx: AppContext, query: string): Promise; // NocoDB age-column title -> count submissionKey: string; } @@ -158,20 +162,29 @@ export async function createTicket( code = generateCode(); } + const c = input.counts; const fields: Record = { [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;