CampgroundTickets/backend/src/routes/webhook.ts
Hank d293e53ee6 Add Check-in / Ice / Banquet scan modes
- Ice mode: prepaid ice bags (Ice Total / Ice Redeemed columns) redeemed
  independently of ticket check-ins; grab all bags at once or some now.
- Banquet mode: donor total (online + offline) looked up by the ticket's
  email via the Donors Master List, with a manual email override. New
  DonorService + POST /api/banquet.
- Redeem generalized over a resource (tickets|ice); audit records ice actions.
- App gains a mode selector; webhook maps ice_bags (defaults to
  ICE_BAGS_DEFAULT when only a boolean ice option is present).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 04:07:51 +00:00

127 lines
5.1 KiB
TypeScript

import { createHash, timingSafeEqual } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { AGE_COLUMNS, toBool, toNumber } from "../fields.js";
import { createTicket } from "../ticketService.js";
import { renderQrPng } from "../services/qrcode.js";
function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a || "");
const bb = Buffer.from(b || "");
if (ba.length !== bb.length) return false;
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<string, string> = {
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+",
};
export async function webhookRoutes(app: FastifyInstance): Promise<void> {
const handler = async (req: any, reply: any) => {
const secret = req.headers["x-webhook-secret"];
if (typeof secret !== "string" || !safeEqual(secret, app.ctx.config.WEBHOOK_SECRET)) {
return reply.code(401).send({ error: "unauthorized" });
}
const body = (req.body ?? {}) as Record<string, unknown>;
const name = 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" });
}
// Build age-bracket counts from whichever keys were provided.
const ages: Record<string, number> = {};
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" });
}
// Idempotency key: prefer a stable submission id, else hash the content.
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id;
const submissionKey = submissionId
? `sub:${String(submissionId)}`
: "hash:" +
createHash("sha256")
.update(`${email}|${name}|${JSON.stringify(ages)}`)
.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<ReturnType<typeof createTicket>>;
try {
result = await createTicket(app.ctx, {
name,
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,
iceBags,
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
ages,
submissionKey,
});
} catch (e: any) {
req.log.error({ err: e }, "webhook: failed to create ticket");
return reply.code(502).send({ error: "db_error", detail: e?.message });
}
if (result.status === "duplicate") {
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.
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" };
}
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,
qrPng: qr,
});
} catch (e: any) {
req.log.error({ err: e, code: result.code }, "webhook: ticket created but email failed");
return reply.code(502).send({ status: "created", code: result.code, emailSent: false, error: e?.message });
}
return { status: "created", code: result.code, emailSent: true };
};
// Public path (configure this in FluentForms): https://scan.beartariacampgrounds.com/webhook
app.post("/webhook", handler);
// Explicit alias.
app.post("/api/webhook/fluentforms", handler);
}