- 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>
127 lines
5.1 KiB
TypeScript
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);
|
|
}
|