diff --git a/backend/src/routes/webhook.ts b/backend/src/routes/webhook.ts index 08df9e0..3cf3347 100644 --- a/backend/src/routes/webhook.ts +++ b/backend/src/routes/webhook.ts @@ -24,15 +24,19 @@ export async function webhookRoutes(app: FastifyInstance): Promise { 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) { - return reply.code(400).send({ error: "missing_fields", detail: "purchaser name is required" }); - } - // Adult attendee names (non-empty groups, in order). + // Billing/customer name (the purchaser — may differ from attendees, e.g. + // buying for others or add-ons only) + the attendee name groups. + const customerName = nameGroup(body, "customer_name") || String(body.name ?? "").trim(); const adultNames = ADULT_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean); + // Person the email is addressed to (the buyer). + const purchaser = customerName || adultNames[0]; + // Title shown at the gate: the first attendee if any, else the customer. + const title = adultNames[0] || customerName; + if (!title) { + return reply.code(400).send({ error: "missing_fields", detail: "customer or attendee name is required" }); + } // Attendee counts. const counts = { @@ -45,11 +49,6 @@ export async function webhookRoutes(app: FastifyInstance): Promise { // Paid/scannable admissions = adults + youth 13-16. Children 12 & under are // free (charging starts at 13) and are stored but not counted at the gate. const scannable = counts.adults + counts.youth; - 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 paid tickets (adults / youth 13-16)" }); - } // Donor info (hidden fields from the eligibility/voucher lookups) + radio. const donorTier = String(body.donor_tier ?? "").trim(); @@ -71,22 +70,32 @@ export async function webhookRoutes(app: FastifyInstance): Promise { const iceBags = Math.max(0, iceTickets) * app.ctx.config.ICE_BAGS_PER_TICKET; const iceAccess = iceBags > 0 || selected(body.input_radio_7); + // Tickets are optional: a customer can buy ice/UTV/parking with no admission + // ticket, or buy tickets for others. Only reject a truly empty order — + // nothing to check in, redeem, or verify at the gate. + const hasIssuable = scannable > 0 || iceBags > 0 || utv || carParking || rvParking; + if (!hasIssuable) { + req.log.warn({ body }, "webhook: submission has nothing to issue"); + return reply.code(400).send({ error: "no_items", detail: "no tickets, ice, or add-ons in submission" }); + } + const address = addressLine(body.address_1); - // Idempotency: prefer a stable submission id, else hash the content. + // Idempotency: prefer a stable submission id, else hash the content + // (include ice/extras so distinct add-on-only orders don't collide). 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(counts)}`) + .update(`${email}|${title}|${JSON.stringify(counts)}|${iceBags}|${carParking}|${rvParking}|${utv}`) .digest("hex") .slice(0, 32); let result: Awaited>; try { result = await createTicket(app.ctx, { - name, + name: title, adultNames, email, address, @@ -127,10 +136,11 @@ export async function webhookRoutes(app: FastifyInstance): Promise { const qr = await renderQrPng(result.code); await app.ctx.mailer.sendTicket({ toEmail: email, - toName: name, + toName: purchaser, code: result.code, quantity: scannable, qrPng: qr, + iceBags, }); } catch (e: any) { req.log.error({ err: e, code: result.code }, "webhook: ticket created but email failed"); diff --git a/backend/src/routes/webhookDoc.ts b/backend/src/routes/webhookDoc.ts index b9930fb..ea5f10a 100644 --- a/backend/src/routes/webhookDoc.ts +++ b/backend/src/routes/webhookDoc.ts @@ -11,7 +11,8 @@ interface Field { } const FIELDS: Field[] = [ - { 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: "customer_name", req: "required", type: "name (compound)", desc: "Billing / customer name — the buyer. Used to address the email and as the ticket title when there are no attendee names (add-on-only orders). Object {first_name, middle_name, last_name}; flat customer_name[first_name] keys also accepted." }, + { key: "names", req: "optional", type: "name (compound)", desc: "Adult Ticket #1 attendee — object {first_name, middle_name, last_name}. Also accepts flat names[first_name] keys. May be empty when buying only donor tickets or add-ons." }, { 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: "names_Donor_1 / names_Donor_2", req: "optional", type: "name (compound)", desc: "Donor (voucher) adult ticket names — the free adult admissions. Counted via item_quantity_adult_ticket_donor and added to the gate name list." }, { key: "email", req: "optional", type: "email", desc: "Purchaser email — the QR ticket is sent here (FluentForms sends the receipt separately)." }, @@ -55,6 +56,7 @@ const rows = FIELDS.map( const exampleJson = esc(`{ "id": "412", + "customer_name": { "first_name": "Jane", "last_name": "Bear" }, "names": { "first_name": "Jane", "last_name": "Bear" }, "names_1": { "first_name": "John", "last_name": "Bear" }, "email": "jane@example.com", @@ -120,6 +122,7 @@ const PAGE = `

What it does

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). Children 12 & under are free (charging starts at 13) — their counts are stored and shown to gate staff, but not counted toward the ticket total. Each adult name provided is stored and shown on a successful scan.

+

Tickets are optional. A customer can buy ice, an ATV/UTV pass, or parking with no admission ticket, or buy tickets for other people. A record + QR is still created as long as there's something to redeem or verify at the gate (a ticket, ice, or an add-on). Only a truly empty order is rejected.

Fields

@@ -144,7 +147,8 @@ const PAGE = ` - + + diff --git a/backend/src/services/mailer.ts b/backend/src/services/mailer.ts index a27ce84..a97f10b 100644 --- a/backend/src/services/mailer.ts +++ b/backend/src/services/mailer.ts @@ -9,6 +9,43 @@ export interface TicketEmail { code: string; quantity: number; qrPng: Buffer; + iceBags?: number; // for add-on-only (ticketless) orders +} + +/** Describe what a purchase is good for — handles ticketless (ice/UTV) orders. */ +function purchaseSummary(mail: TicketEmail): { lead: string; footer: string } { + const qty = mail.quantity; + if (qty > 0) { + const w = qty === 1 ? "ticket" : "tickets"; + return { + lead: `This email is your ticket for ${qty} ${w} to the 2026 Beartaria Campgrounds event. Show the QR code below at the gate.`, + footer: `Each ticket admits one entry. This code is good for all ${qty} ${w} on one purchase — gate staff will check people in against it. See you there!`, + }; + } + const bags = mail.iceBags ?? 0; + const extra = bags > 0 ? ` It includes ${bags} bag${bags === 1 ? "" : "s"} of ice.` : ""; + return { + lead: `This email is your gate pass for your 2026 Beartaria Campgrounds purchase (add-ons such as ice, parking, or an ATV/UTV).${extra} Show the QR code below at the gate.`, + footer: `Show this QR at the gate and staff will redeem your add-ons against it. See you there!`, + }; +} + +/** Plain-text version of purchaseSummary (no HTML tags). */ +function purchaseSummaryText(mail: TicketEmail): { lead: string; footer: string } { + const qty = mail.quantity; + if (qty > 0) { + const w = qty === 1 ? "ticket" : "tickets"; + return { + lead: `This is your ticket for ${qty} ${w} to the 2026 Beartaria Campgrounds event.`, + footer: `It is good for all ${qty} ${w} on this purchase.`, + }; + } + const bags = mail.iceBags ?? 0; + const extra = bags > 0 ? ` It includes ${bags} bag${bags === 1 ? "" : "s"} of ice.` : ""; + return { + lead: `This is your gate pass for your purchase (add-ons such as ice, parking, or an ATV/UTV).${extra}`, + footer: `Show this code at the gate and staff will redeem your add-ons against it.`, + }; } export class MailerSendError extends Error { @@ -94,8 +131,7 @@ function esc(s: string): string { function renderHtml(mail: TicketEmail): string { const name = esc(mail.toName || ""); - const qty = mail.quantity; - const ticketWord = qty === 1 ? "ticket" : "tickets"; + const { lead, footer } = purchaseSummary(mail); return ` @@ -108,9 +144,7 @@ function renderHtml(mail: TicketEmail): string {
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 purchaser name, or zero scannable tickets.
400{"error":"missing_fields"}No customer name and no attendee names.
400{"error":"no_items"}Empty order — no tickets, ice, or add-ons.
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.

Hi ${name || "there"},

- Thank you for your purchase! This email is your ticket for - ${qty} ${ticketWord} to the 2026 Beartaria Campgrounds event. - Show the QR code below at the gate. + Thank you for your purchase! ${lead}

Ticket QR code

- Each ticket admits one entry. This code is good for all ${qty} ${ticketWord} on one purchase — - gate staff will check people in against it. See you there! + ${footer}

@@ -134,17 +167,16 @@ function renderHtml(mail: TicketEmail): string { } function renderText(mail: TicketEmail): string { - const qty = mail.quantity; - const ticketWord = qty === 1 ? "ticket" : "tickets"; + const { lead, footer } = purchaseSummaryText(mail); return [ `Hi ${mail.toName || "there"},`, "", - `Thank you for your purchase! This is your ticket for ${qty} ${ticketWord} to the 2026 Beartaria Campgrounds event.`, + `Thank you for your purchase! ${lead}`, "", `Your ticket code: ${mail.code}`, "", "Show this code (or the QR code in the HTML version of this email) at the gate.", - `It is good for all ${qty} ${ticketWord} on this purchase.`, + footer, "", "See you there!", "Beartaria Campgrounds · beartariacampgrounds.com",