Rework webhook + model for the 2026 Tickets form
New form schema: up to 10 named adults, youth 13-16, kids 10-12 / 5-9 / 0-4, donor tier/vouchers (from the lookups), parking/RV/UTV/ice payment fields. - Scannable total = adults + youth + kids 10-12 + kids 5-9 (kids 0-4 free). - Store + display adult names on a good scan; show donor tier, UTV, vouchers. - Ice: payment_ice = 1-4 tickets ($20 each), 1 ticket = 3 bags. - New NocoDB 2026 schema (with Id PK); webhook parses compound names, quantity/payment fields (nested objects or money strings). - Our webhook keeps sending the QR ticket email (FluentForms sends the receipt); from address is now info@beartariacampgrounds.com. Updated /test personas, /webhook-doc, tests, and the app display. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
718d1515b0
commit
b7c77afe02
14 changed files with 360 additions and 210 deletions
|
|
@ -1,48 +1,39 @@
|
|||
/**
|
||||
* 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.
|
||||
* Mapping for the "2026 Campground Tickets" NocoDB table, matching the 2026
|
||||
* FluentForms "Tickets 2026" schema. Change titles here if the columns differ.
|
||||
*/
|
||||
export const COL = {
|
||||
id: "Id",
|
||||
name: "Title", // first column in the 2025 table holds the purchaser name
|
||||
name: "Title", // purchaser full name
|
||||
adultNames: "Adult Names", // newline-separated list of adult attendee names
|
||||
email: "Email Address",
|
||||
address: "Address",
|
||||
isDonor: "Is Donor",
|
||||
donorTier: "Donor Tier", // member / donor / ""
|
||||
vouchers: "Vouchers",
|
||||
|
||||
// Attendee counts by group:
|
||||
adults: "Adults",
|
||||
youth: "Youth 13-16",
|
||||
kids12: "Kids 10-12",
|
||||
kids9: "Kids 5-9",
|
||||
kids4: "Kids 0-4", // free — NOT counted toward the scannable total
|
||||
|
||||
carParking: "Car Parking",
|
||||
rvParking: "RV Parking",
|
||||
utv: "UTV",
|
||||
iceAccess: "Ice Access",
|
||||
paymentMethod: "Payment Method",
|
||||
|
||||
// Columns this system adds to the table:
|
||||
// Columns this system manages:
|
||||
code: "Ticket Code",
|
||||
redeemed: "Redeemed",
|
||||
submissionKey: "SubmissionKey",
|
||||
lastScanAt: "LastScanAt",
|
||||
iceTotal: "Ice Total", // prepaid ice bags
|
||||
iceRedeemed: "Ice Redeemed", // bags picked up
|
||||
iceTotal: "Ice Total",
|
||||
iceRedeemed: "Ice Redeemed",
|
||||
} 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 {
|
||||
|
|
@ -57,23 +48,40 @@ function bool(v: unknown): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
/** Total redeemable tickets = sum of age brackets minus the free ones. */
|
||||
/**
|
||||
* Total scannable tickets = everyone except kids 0-4 (who are free):
|
||||
* adults + youth (13-16) + kids 10-12 + kids 5-9.
|
||||
*/
|
||||
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;
|
||||
return num(rec[COL.adults]) + num(rec[COL.youth]) + num(rec[COL.kids12]) + num(rec[COL.kids9]);
|
||||
}
|
||||
|
||||
/** Per-bracket breakdown for display. */
|
||||
export function computeIceTotal(rec: NocoRecord): number {
|
||||
return num(rec[COL.iceTotal]);
|
||||
}
|
||||
|
||||
/** Per-group 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);
|
||||
return [
|
||||
{ bracket: "Adults", count: num(rec[COL.adults]), free: false },
|
||||
{ bracket: "Youth 13-16", count: num(rec[COL.youth]), free: false },
|
||||
{ bracket: "Kids 10-12", count: num(rec[COL.kids12]), free: false },
|
||||
{ bracket: "Kids 5-9", count: num(rec[COL.kids9]), free: false },
|
||||
{ bracket: "Kids 0-4", count: num(rec[COL.kids4]), free: true },
|
||||
].filter((b) => b.count > 0);
|
||||
}
|
||||
|
||||
/** Adult names stored as a newline-separated list. */
|
||||
export function parseAdultNames(rec: NocoRecord): string[] {
|
||||
const raw = rec[COL.adultNames];
|
||||
if (Array.isArray(raw)) return raw.map((x) => String(x)).filter(Boolean);
|
||||
if (typeof raw === "string") {
|
||||
return raw
|
||||
.split(/\r?\n/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export interface ResourceCount {
|
||||
|
|
@ -90,20 +98,20 @@ export interface TicketView {
|
|||
redeemed: number;
|
||||
remaining: number;
|
||||
ice: ResourceCount;
|
||||
adultNames: string[];
|
||||
extras: {
|
||||
carParking: boolean;
|
||||
rvParking: boolean;
|
||||
utv: boolean;
|
||||
iceAccess: boolean;
|
||||
isDonor: boolean;
|
||||
freeUnder4: number;
|
||||
donorTier: string;
|
||||
vouchers: number;
|
||||
freeUnder5: 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]);
|
||||
|
|
@ -121,12 +129,16 @@ export function toView(rec: NocoRecord): TicketView {
|
|||
redeemed: iceRedeemed,
|
||||
remaining: Math.max(0, iceTotal - iceRedeemed),
|
||||
},
|
||||
adultNames: parseAdultNames(rec),
|
||||
extras: {
|
||||
carParking: bool(rec[COL.carParking]),
|
||||
rvParking: bool(rec[COL.rvParking]),
|
||||
utv: bool(rec[COL.utv]),
|
||||
iceAccess: bool(rec[COL.iceAccess]),
|
||||
isDonor: bool(rec[COL.isDonor]),
|
||||
freeUnder4: num(rec["Ages 0-3"]),
|
||||
donorTier: String(rec[COL.donorTier] ?? ""),
|
||||
vouchers: num(rec[COL.vouchers]),
|
||||
freeUnder5: num(rec[COL.kids4]),
|
||||
},
|
||||
ages: ageBreakdown(rec),
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue