/** * Mapping between the NocoDB "2026 Campground Tickets" table columns, the * webhook payload keys, and the view we return to the app. * * The 2026 table is a clone of the 2025 submission table (per-purchase record * with age-bracket headcounts, parking/ice flags, donor flag) PLUS four columns * this system adds: Ticket Code, Redeemed, SubmissionKey, LastScanAt. * * If the real column titles differ, change them here in one place. */ export const COL = { id: "Id", name: "Title", // first column in the 2025 table holds the purchaser name email: "Email Address", address: "Address", isDonor: "Is Donor", carParking: "Car Parking", rvParking: "RV Parking", iceAccess: "Ice Access", paymentMethod: "Payment Method", // Columns this system adds to the table: code: "Ticket Code", redeemed: "Redeemed", submissionKey: "SubmissionKey", lastScanAt: "LastScanAt", iceTotal: "Ice Total", // prepaid ice bags iceRedeemed: "Ice Redeemed", // bags picked up } as const; /** Age-bracket columns, in order. */ export const AGE_COLUMNS = [ "Ages 0-3", "Ages 4-7", "Ages 8-12", "Ages 13-17", "Ages 18-25", "Ages 26-45", "Ages 46-64", "Ages 65+", ] as const; /** Age brackets admitted free and NOT counted as redeemable tickets. */ export const FREE_AGE_COLUMNS: readonly string[] = ["Ages 0-3"]; export type NocoRecord = Record & { Id: number }; function num(v: unknown): number { const n = Number(v); return Number.isFinite(n) ? n : 0; } function bool(v: unknown): boolean { if (typeof v === "boolean") return v; if (typeof v === "number") return v !== 0; if (typeof v === "string") return /^(1|true|yes|y|on)$/i.test(v.trim()); return false; } /** Total redeemable tickets = sum of age brackets minus the free ones. */ export function computeTotal(rec: NocoRecord): number { let total = 0; for (const col of AGE_COLUMNS) { if (FREE_AGE_COLUMNS.includes(col)) continue; total += num(rec[col]); } return total; } /** Per-bracket breakdown for display. */ export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number; free: boolean }[] { return AGE_COLUMNS.map((col) => ({ bracket: col.replace(/^Ages /, ""), count: num(rec[col]), free: FREE_AGE_COLUMNS.includes(col), })).filter((b) => b.count > 0); } export interface ResourceCount { total: number; redeemed: number; remaining: number; } export interface TicketView { code: string; name: string; email: string; total: number; redeemed: number; remaining: number; ice: ResourceCount; extras: { carParking: boolean; rvParking: boolean; iceAccess: boolean; isDonor: boolean; freeUnder4: number; }; ages: { bracket: string; count: number; free: boolean }[]; } export function computeIceTotal(rec: NocoRecord): number { return num(rec[COL.iceTotal]); } export function toView(rec: NocoRecord): TicketView { const total = computeTotal(rec); const redeemed = num(rec[COL.redeemed]); const iceTotal = computeIceTotal(rec); const iceRedeemed = num(rec[COL.iceRedeemed]); return { code: String(rec[COL.code] ?? ""), name: String(rec[COL.name] ?? ""), email: String(rec[COL.email] ?? ""), total, redeemed, remaining: Math.max(0, total - redeemed), ice: { total: iceTotal, redeemed: iceRedeemed, remaining: Math.max(0, iceTotal - iceRedeemed), }, extras: { carParking: bool(rec[COL.carParking]), rvParking: bool(rec[COL.rvParking]), iceAccess: bool(rec[COL.iceAccess]), isDonor: bool(rec[COL.isDonor]), freeUnder4: num(rec["Ages 0-3"]), }, ages: ageBreakdown(rec), }; } export { num as toNumber, bool as toBool };