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:
parent
e86651723d
commit
a987e046da
3 changed files with 74 additions and 28 deletions
|
|
@ -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");
|
||||||
|
|
|
||||||
|
|
@ -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 & 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 & 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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue