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>;
// 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<void> {
// 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<void> {
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<ReturnType<typeof createTicket>>;
try {
result = await createTicket(app.ctx, {
name,
name: title,
adultNames,
email,
address,
@ -127,10 +136,11 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
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");

View file

@ -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 = `<!doctype html>
<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><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>
<table>
@ -144,7 +147,8 @@ 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 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>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>

View file

@ -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 <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 {
@ -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 `<!doctype html>
<html>
<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;">
<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;">
Thank you for your purchase! This email is your ticket for
<strong>${qty} ${ticketWord}</strong> to the 2026 Beartaria Campgrounds event.
Show the QR code below at the gate.
Thank you for your purchase! ${lead}
</p>
<div style="text-align:center;margin:20px 0;">
<img src="cid:qrcode" alt="Ticket QR code" width="280" height="280"
@ -121,8 +155,7 @@ function renderHtml(mail: TicketEmail): string {
${esc(mail.code)}
</p>
<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
gate staff will check people in against it. See you there!
${footer}
</p>
</td></tr>
</table>
@ -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",