Webhook: customer_name purchaser + allow ticketless orders

Two fixes for the updated Tickets 2026 form:

1. Purchaser name now comes from the new customer_name (billing) field,
   falling back to the first attendee then a plain `name`. Donor-only
   and buy-for-others orders (where the Adult #1 `names` group is empty)
   no longer 400 with "purchaser name is required". The ticket title is
   the first attendee if present, else the customer; the QR email is
   addressed to the customer.

2. Tickets are now optional. A customer can buy ice / ATV-UTV / parking
   with no admission ticket. A record + QR is created whenever there's
   anything to redeem or verify at the gate (ticket, ice, or add-on);
   only a truly empty order is rejected (no_items, replacing no_tickets).

The ticket email adapts its copy for ticketless (add-on-only) orders —
it reads as a gate pass for ice/parking/UTV instead of "0 tickets", and
names the ice bag count when present. Idempotency hash now includes
ice/extras so distinct add-on-only orders don't collide. Doc updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-17 17:59:56 +00:00
parent e86651723d
commit a987e046da
3 changed files with 74 additions and 28 deletions

View file

@ -24,15 +24,19 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
const body = (req.body ?? {}) as Record<string, any>; 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(); 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); 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. // Attendee counts.
const counts = { const counts = {
@ -45,11 +49,6 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
// Paid/scannable admissions = adults + youth 13-16. Children 12 & under are // 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. // free (charging starts at 13) and are stored but not counted at the gate.
const scannable = counts.adults + counts.youth; 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. // Donor info (hidden fields from the eligibility/voucher lookups) + radio.
const donorTier = String(body.donor_tier ?? "").trim(); const donorTier = String(body.donor_tier ?? "").trim();
@ -71,22 +70,32 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
const iceBags = Math.max(0, iceTickets) * app.ctx.config.ICE_BAGS_PER_TICKET; const iceBags = Math.max(0, iceTickets) * app.ctx.config.ICE_BAGS_PER_TICKET;
const iceAccess = iceBags > 0 || selected(body.input_radio_7); 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); 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 submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id;
const submissionKey = submissionId const submissionKey = submissionId
? `sub:${String(submissionId)}` ? `sub:${String(submissionId)}`
: "hash:" + : "hash:" +
createHash("sha256") createHash("sha256")
.update(`${email}|${name}|${JSON.stringify(counts)}`) .update(`${email}|${title}|${JSON.stringify(counts)}|${iceBags}|${carParking}|${rvParking}|${utv}`)
.digest("hex") .digest("hex")
.slice(0, 32); .slice(0, 32);
let result: Awaited<ReturnType<typeof createTicket>>; let result: Awaited<ReturnType<typeof createTicket>>;
try { try {
result = await createTicket(app.ctx, { result = await createTicket(app.ctx, {
name, name: title,
adultNames, adultNames,
email, email,
address, address,
@ -127,10 +136,11 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
const qr = await renderQrPng(result.code); const qr = await renderQrPng(result.code);
await app.ctx.mailer.sendTicket({ await app.ctx.mailer.sendTicket({
toEmail: email, toEmail: email,
toName: name, toName: purchaser,
code: result.code, code: result.code,
quantity: scannable, quantity: scannable,
qrPng: qr, qrPng: qr,
iceBags,
}); });
} catch (e: any) { } catch (e: any) {
req.log.error({ err: e, code: result.code }, "webhook: ticket created but email failed"); req.log.error({ err: e, code: result.code }, "webhook: ticket created but email failed");

View file

@ -11,7 +11,8 @@ interface Field {
} }
const FIELDS: 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_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: "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)." }, { 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(`{ const exampleJson = esc(`{
"id": "412", "id": "412",
"customer_name": { "first_name": "Jane", "last_name": "Bear" },
"names": { "first_name": "Jane", "last_name": "Bear" }, "names": { "first_name": "Jane", "last_name": "Bear" },
"names_1": { "first_name": "John", "last_name": "Bear" }, "names_1": { "first_name": "John", "last_name": "Bear" },
"email": "jane@example.com", "email": "jane@example.com",
@ -120,6 +122,7 @@ const PAGE = `<!doctype html>
<h2>What it does</h2> <h2>What it does</h2>
<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>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). <b>Children 12 &amp; under are free</b> (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.</p> <p><b>Scannable ticket total</b> = adults + youth (13-16). <b>Children 12 &amp; under are free</b> (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.</p>
<p><b>Tickets are optional.</b> 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.</p>
<h2>Fields</h2> <h2>Fields</h2>
<table> <table>
@ -144,7 +147,8 @@ const PAGE = `<!doctype html>
<tbody> <tbody>
<tr><td>200</td><td><code>{"status":"created","code":"BC26-…","emailSent":true}</code></td><td>Ticket created and emailed.</td></tr> <tr><td>200</td><td><code>{"status":"created","code":"BC26-…","emailSent":true}</code></td><td>Ticket created and emailed.</td></tr>
<tr><td>200</td><td><code>{"status":"duplicate","code":"BC26-…"}</code></td><td>Same submission already processed no-op.</td></tr> <tr><td>200</td><td><code>{"status":"duplicate","code":"BC26-…"}</code></td><td>Same submission already processed no-op.</td></tr>
<tr><td>400</td><td><code>{"error":"missing_fields"}</code> / <code>"no_tickets"</code></td><td>Missing purchaser name, or zero scannable tickets.</td></tr> <tr><td>400</td><td><code>{"error":"missing_fields"}</code></td><td>No customer name and no attendee names.</td></tr>
<tr><td>400</td><td><code>{"error":"no_items"}</code></td><td>Empty order no tickets, ice, or add-ons.</td></tr>
<tr><td>401</td><td><code>{"error":"unauthorized"}</code></td><td>Missing or wrong <code>X-Webhook-Secret</code>.</td></tr> <tr><td>401</td><td><code>{"error":"unauthorized"}</code></td><td>Missing or wrong <code>X-Webhook-Secret</code>.</td></tr>
<tr><td>502</td><td><code>{"status":"created","emailSent":false,}</code></td><td>Ticket row created but the email failed re-send from the admin app.</td></tr> <tr><td>502</td><td><code>{"status":"created","emailSent":false,}</code></td><td>Ticket row created but the email failed re-send from the admin app.</td></tr>
</tbody> </tbody>

View file

@ -9,6 +9,43 @@ export interface TicketEmail {
code: string; code: string;
quantity: number; quantity: number;
qrPng: Buffer; 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 <strong>${qty} ${w}</strong> 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 <strong>${bags} bag${bags === 1 ? "" : "s"} of ice</strong>.` : "";
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 { export class MailerSendError extends Error {
@ -94,8 +131,7 @@ function esc(s: string): string {
function renderHtml(mail: TicketEmail): string { function renderHtml(mail: TicketEmail): string {
const name = esc(mail.toName || ""); const name = esc(mail.toName || "");
const qty = mail.quantity; const { lead, footer } = purchaseSummary(mail);
const ticketWord = qty === 1 ? "ticket" : "tickets";
return `<!doctype html> return `<!doctype html>
<html> <html>
<body style="margin:0;padding:0;background:#0f1a12;font-family:Arial,Helvetica,sans-serif;color:#0f1a12;"> <body style="margin:0;padding:0;background:#0f1a12;font-family:Arial,Helvetica,sans-serif;color:#0f1a12;">
@ -108,9 +144,7 @@ function renderHtml(mail: TicketEmail): string {
<tr><td style="padding:24px;"> <tr><td style="padding:24px;">
<p style="margin:0 0 12px;font-size:16px;">Hi ${name || "there"},</p> <p style="margin:0 0 12px;font-size:16px;">Hi ${name || "there"},</p>
<p style="margin:0 0 16px;font-size:15px;line-height:1.5;"> <p style="margin:0 0 16px;font-size:15px;line-height:1.5;">
Thank you for your purchase! This email is your ticket for Thank you for your purchase! ${lead}
<strong>${qty} ${ticketWord}</strong> to the 2026 Beartaria Campgrounds event.
Show the QR code below at the gate.
</p> </p>
<div style="text-align:center;margin:20px 0;"> <div style="text-align:center;margin:20px 0;">
<img src="cid:qrcode" alt="Ticket QR code" width="280" height="280" <img src="cid:qrcode" alt="Ticket QR code" width="280" height="280"
@ -121,8 +155,7 @@ function renderHtml(mail: TicketEmail): string {
${esc(mail.code)} ${esc(mail.code)}
</p> </p>
<p style="margin:0;font-size:13px;color:#777;line-height:1.5;"> <p style="margin:0;font-size:13px;color:#777;line-height:1.5;">
Each ticket admits one entry. This code is good for all ${qty} ${ticketWord} on one purchase ${footer}
gate staff will check people in against it. See you there!
</p> </p>
</td></tr> </td></tr>
</table> </table>
@ -134,17 +167,16 @@ function renderHtml(mail: TicketEmail): string {
} }
function renderText(mail: TicketEmail): string { function renderText(mail: TicketEmail): string {
const qty = mail.quantity; const { lead, footer } = purchaseSummaryText(mail);
const ticketWord = qty === 1 ? "ticket" : "tickets";
return [ return [
`Hi ${mail.toName || "there"},`, `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}`, `Your ticket code: ${mail.code}`,
"", "",
"Show this code (or the QR code in the HTML version of this email) at the gate.", "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!", "See you there!",
"Beartaria Campgrounds · beartariacampgrounds.com", "Beartaria Campgrounds · beartariacampgrounds.com",