- 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>
135 lines
3.7 KiB
TypeScript
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 };
|