import { timingSafeEqual } from "node:crypto"; import { toBool, toNumber } from "./fields.js"; /** Constant-time string compare for shared webhook secrets. */ export 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); } /** Read a FluentForms compound name field, given as a nested object * (`names: {first_name,...}`) or flattened bracket keys (`names[first_name]`). */ export function nameGroup(body: Record, base: string): string { const obj = body[base]; let first: any, middle: any, last: any; if (obj && typeof obj === "object") { ({ first_name: first, middle_name: middle, last_name: last } = obj); } else { first = body[`${base}[first_name]`]; middle = body[`${base}[middle_name]`]; last = body[`${base}[last_name]`]; } return [first, middle, last] .map((x) => (x == null ? "" : String(x).trim())) .filter(Boolean) .join(" "); } /** Read an item_quantity / payment field's numeric value (handles nested * objects like {quantity} / {value} and money strings like "$40.00"). */ export function qty(v: any): number { if (v == null || v === "") return 0; if (typeof v === "object") return toNumber(v.quantity ?? v.value ?? v.item_quantity ?? v.amount ?? 0); if (typeof v === "string") return toNumber(v.replace(/[^0-9.\-]/g, "")); return toNumber(v); } /** A payment/extra field counts as "selected" if it has a meaningful value. * Donor (free) items can be $0, so a non-empty, non-"no"/"0" value also counts. */ export function selected(v: any): boolean { if (v == null || v === "") return false; if (typeof v === "object") { if ("selected" in v) return toBool((v as any).selected); return qty(v) > 0 || Object.keys(v).length > 0; } const s = String(v).trim().toLowerCase(); if (!s || s === "no" || s === "0" || s === "$0" || s === "$0.00" || s === "false" || s === "none") return false; return true; } /** Flatten a FluentForms compound address (`address_1`) to a single line. */ export function addressLine(v: any): string | undefined { if (v && typeof v === "object") return Object.values(v).filter(Boolean).join(", "); if (v !== undefined) return String(v); return undefined; } const NUMBER_WORDS: Record = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6 }; /** * Total bags of ice from the `payment_ice` field. The form sends a descriptive * option label, e.g. "One Ice ticket good for one bag per day (3 total bags)", * so the reliable signal is the "(N total bags)" the label states. Falls back to * a worded ticket count ("Two Ice tickets" → 2 × bagsPerTicket), then to a * numeric dollar-total/ticket-count for forward compatibility. */ export function iceBagsFromPayment( value: unknown, opts: { bagsPerTicket: number; ticketPrice: number }, ): number { const { bagsPerTicket, ticketPrice } = opts; const s = typeof value === "string" ? value : ""; // Preferred: the label states the total bags directly. const bagsMatch = s.match(/(\d+)\s*total\s*bags/i); if (bagsMatch) return Math.max(0, parseInt(bagsMatch[1], 10)); // Worded ticket count: "One Ice ticket", "Two Ice tickets". const wordMatch = s.match(/\b(one|two|three|four|five|six)\b\s+ice/i); if (wordMatch) return NUMBER_WORDS[wordMatch[1].toLowerCase()] * bagsPerTicket; // Numeric fallback: a dollar total (>= price) → tickets; else a small count. const n = qty(value); if (n <= 0) return 0; const tickets = n >= ticketPrice ? Math.round(n / ticketPrice) : Math.round(n); return Math.max(0, tickets) * bagsPerTicket; } /** Donor status from the hidden lookup fields + the "are you a donor?" radio. */ export function readDonor(body: Record): { isDonor: boolean; donorTier: string } { const donorTier = String(body.donor_tier ?? "").trim(); const isDonor = donorTier === "member" || donorTier === "donor" || toBool(body.donor_eligible) || selected(body.input_radio); // "Are you a campground donor?" return { isDonor, donorTier }; }