CampgroundTickets/backend/src/fields.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

135 lines
3.7 KiB
TypeScript

/**
* 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<string, unknown> & { 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 };