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
|
|
@ -17,8 +17,10 @@ NOCODB_DONORS_TABLE_ID=
|
||||||
NOCODB_DONOR_ONLINE_TABLE_ID=
|
NOCODB_DONOR_ONLINE_TABLE_ID=
|
||||||
NOCODB_DONOR_OFFLINE_TABLE_ID=
|
NOCODB_DONOR_OFFLINE_TABLE_ID=
|
||||||
|
|
||||||
# Ice bags granted when a purchase includes ice but the webhook sends only a boolean
|
# Ice: form sells 1-4 ice tickets at $20 each; one ticket = 3 bags. The webhook
|
||||||
ICE_BAGS_DEFAULT=3
|
# reads payment_ice as a ticket count (1-4) or a dollar total ($20-$80).
|
||||||
|
ICE_TICKET_PRICE=20
|
||||||
|
ICE_BAGS_PER_TICKET=3
|
||||||
|
|
||||||
# Ticket-voucher entitlement (donor free tickets). Donations on/after
|
# Ticket-voucher entitlement (donor free tickets). Donations on/after
|
||||||
# VOUCHER_SINCE totalling >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the
|
# VOUCHER_SINCE totalling >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the
|
||||||
|
|
@ -33,7 +35,7 @@ ENABLE_TEST_PAGE=false
|
||||||
|
|
||||||
# MailerSend
|
# MailerSend
|
||||||
MAILERSEND_API_TOKEN=
|
MAILERSEND_API_TOKEN=
|
||||||
MAIL_FROM_EMAIL=tickets@beartariacampgrounds.com
|
MAIL_FROM_EMAIL=info@beartariacampgrounds.com
|
||||||
MAIL_FROM_NAME=Beartaria Campgrounds
|
MAIL_FROM_NAME=Beartaria Campgrounds
|
||||||
|
|
||||||
# Shared secret FluentForms sends in the X-Webhook-Secret header (long random string)
|
# Shared secret FluentForms sends in the X-Webhook-Secret header (long random string)
|
||||||
|
|
|
||||||
|
|
@ -201,11 +201,14 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti
|
||||||
}, [ticket.redeemed, showHistory]);
|
}, [ticket.redeemed, showHistory]);
|
||||||
|
|
||||||
const tags: string[] = [];
|
const tags: string[] = [];
|
||||||
if (ticket.extras.carParking) tags.push("🚗 Car");
|
const e = ticket.extras;
|
||||||
if (ticket.extras.rvParking) tags.push("🚐 RV");
|
if (e.donorTier === "member") tags.push("🐻 Member");
|
||||||
if (ticket.extras.iceAccess) tags.push("🧊 Ice");
|
else if (e.isDonor) tags.push("⭐ Donor");
|
||||||
if (ticket.extras.isDonor) tags.push("⭐ Donor");
|
if (e.carParking) tags.push("🚗 Car");
|
||||||
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} free`);
|
if (e.rvParking) tags.push("🚐 RV");
|
||||||
|
if (e.utv) tags.push("🏍️ UTV");
|
||||||
|
if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total}`);
|
||||||
|
if (e.freeUnder5 > 0) tags.push(`👶 ${e.freeUnder5} free`);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.card}>
|
<View style={styles.card}>
|
||||||
|
|
|
||||||
|
|
@ -259,6 +259,7 @@ export default function ScannerScreen() {
|
||||||
<Text style={styles.counts}>
|
<Text style={styles.counts}>
|
||||||
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
|
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
|
||||||
</Text>
|
</Text>
|
||||||
|
<AdultNames names={ticket.adultNames} />
|
||||||
<ExtrasRow ticket={ticket} />
|
<ExtrasRow ticket={ticket} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -345,11 +346,14 @@ function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticke
|
||||||
|
|
||||||
function ExtrasRow({ ticket }: { ticket: TicketView }) {
|
function ExtrasRow({ ticket }: { ticket: TicketView }) {
|
||||||
const tags: string[] = [];
|
const tags: string[] = [];
|
||||||
if (ticket.extras.carParking) tags.push("🚗 Car parking");
|
const e = ticket.extras;
|
||||||
if (ticket.extras.rvParking) tags.push("🚐 RV parking");
|
if (e.donorTier === "member") tags.push("🐻 Member");
|
||||||
if (ticket.extras.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`);
|
else if (e.isDonor) tags.push("⭐ Donor");
|
||||||
if (ticket.extras.isDonor) tags.push("⭐ Donor");
|
if (e.carParking) tags.push("🚗 Car parking");
|
||||||
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} under 4 (free)`);
|
if (e.rvParking) tags.push("🚐 RV parking");
|
||||||
|
if (e.utv) tags.push("🏍️ UTV/ATV");
|
||||||
|
if (e.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`);
|
||||||
|
if (e.freeUnder5 > 0) tags.push(`👶 ${e.freeUnder5} under 5 (free)`);
|
||||||
if (!tags.length) return null;
|
if (!tags.length) return null;
|
||||||
return (
|
return (
|
||||||
<View style={styles.tags}>
|
<View style={styles.tags}>
|
||||||
|
|
@ -362,6 +366,19 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AdultNames({ names }: { names: string[] }) {
|
||||||
|
if (!names.length) return null;
|
||||||
|
return (
|
||||||
|
<View style={styles.namesBox}>
|
||||||
|
{names.map((n, i) => (
|
||||||
|
<Text key={i} style={styles.nameLine}>
|
||||||
|
{n}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function ConfirmCard({
|
function ConfirmCard({
|
||||||
ticket,
|
ticket,
|
||||||
isIce,
|
isIce,
|
||||||
|
|
@ -393,6 +410,7 @@ function ConfirmCard({
|
||||||
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining
|
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={styles.cardSub}>{redeemed} already redeemed</Text>
|
<Text style={styles.cardSub}>{redeemed} already redeemed</Text>
|
||||||
|
{!isIce && <AdultNames names={ticket.adultNames} />}
|
||||||
{!isIce && <ExtrasRow ticket={ticket} />}
|
{!isIce && <ExtrasRow ticket={ticket} />}
|
||||||
{isIce && total === 0 && <Text style={styles.exhausted}>This ticket did not prepay for ice.</Text>}
|
{isIce && total === 0 && <Text style={styles.exhausted}>This ticket did not prepay for ice.</Text>}
|
||||||
|
|
||||||
|
|
@ -489,6 +507,8 @@ const styles = StyleSheet.create({
|
||||||
donorFigureDivider: { width: 1, alignSelf: "stretch", backgroundColor: "rgba(255,255,255,0.35)", marginVertical: 8 },
|
donorFigureDivider: { width: 1, alignSelf: "stretch", backgroundColor: "rgba(255,255,255,0.35)", marginVertical: 8 },
|
||||||
donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 18 },
|
donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 18 },
|
||||||
|
|
||||||
|
namesBox: { marginTop: 12, alignItems: "center", gap: 3 },
|
||||||
|
nameLine: { color: "#fff", fontSize: 18, fontWeight: "600", textAlign: "center" },
|
||||||
tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 },
|
tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 },
|
||||||
tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" },
|
tag: { color: "#fff", backgroundColor: "rgba(255,255,255,0.18)", paddingHorizontal: 10, paddingVertical: 5, borderRadius: 999, fontSize: 13, overflow: "hidden" },
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,16 @@ export interface TicketView {
|
||||||
redeemed: number;
|
redeemed: number;
|
||||||
remaining: number;
|
remaining: number;
|
||||||
ice: ResourceCount;
|
ice: ResourceCount;
|
||||||
|
adultNames: string[];
|
||||||
extras: {
|
extras: {
|
||||||
carParking: boolean;
|
carParking: boolean;
|
||||||
rvParking: boolean;
|
rvParking: boolean;
|
||||||
|
utv: boolean;
|
||||||
iceAccess: boolean;
|
iceAccess: boolean;
|
||||||
isDonor: boolean;
|
isDonor: boolean;
|
||||||
freeUnder4: number;
|
donorTier: string;
|
||||||
|
vouchers: number;
|
||||||
|
freeUnder5: number;
|
||||||
};
|
};
|
||||||
ages: { bracket: string; count: number; free: boolean }[];
|
ages: { bracket: string; count: number; free: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,11 @@ const schema = z.object({
|
||||||
NOCODB_DONOR_ONLINE_TABLE_ID: z.string().optional(),
|
NOCODB_DONOR_ONLINE_TABLE_ID: z.string().optional(),
|
||||||
NOCODB_DONOR_OFFLINE_TABLE_ID: z.string().optional(),
|
NOCODB_DONOR_OFFLINE_TABLE_ID: z.string().optional(),
|
||||||
|
|
||||||
// Prepaid ice bags granted when a purchase includes ice but the webhook only
|
// Ice: the form sells 1-4 ice "tickets" at $20 each; one ticket = 3 bags.
|
||||||
// sends a boolean (not an explicit bag count).
|
// The webhook reads payment_ice as either a ticket count (1-4) or a dollar
|
||||||
ICE_BAGS_DEFAULT: z.coerce.number().default(3),
|
// total ($20-$80) and stores bags = tickets * ICE_BAGS_PER_TICKET.
|
||||||
|
ICE_TICKET_PRICE: z.coerce.number().default(20),
|
||||||
|
ICE_BAGS_PER_TICKET: z.coerce.number().default(3),
|
||||||
|
|
||||||
// Public donor-eligibility lookup (for the FluentForms checkout discount).
|
// Public donor-eligibility lookup (for the FluentForms checkout discount).
|
||||||
// Disabled unless a secret is set. Returns only eligibility + tier, never
|
// Disabled unless a secret is set. Returns only eligibility + tier, never
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,39 @@
|
||||||
/**
|
/**
|
||||||
* Mapping between the NocoDB "2026 Campground Tickets" table columns, the
|
* Mapping for the "2026 Campground Tickets" NocoDB table, matching the 2026
|
||||||
* webhook payload keys, and the view we return to the app.
|
* FluentForms "Tickets 2026" schema. Change titles here if the columns differ.
|
||||||
*
|
|
||||||
* 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 = {
|
export const COL = {
|
||||||
id: "Id",
|
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",
|
email: "Email Address",
|
||||||
address: "Address",
|
address: "Address",
|
||||||
isDonor: "Is Donor",
|
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",
|
carParking: "Car Parking",
|
||||||
rvParking: "RV Parking",
|
rvParking: "RV Parking",
|
||||||
|
utv: "UTV",
|
||||||
iceAccess: "Ice Access",
|
iceAccess: "Ice Access",
|
||||||
paymentMethod: "Payment Method",
|
paymentMethod: "Payment Method",
|
||||||
|
|
||||||
// Columns this system adds to the table:
|
// Columns this system manages:
|
||||||
code: "Ticket Code",
|
code: "Ticket Code",
|
||||||
redeemed: "Redeemed",
|
redeemed: "Redeemed",
|
||||||
submissionKey: "SubmissionKey",
|
submissionKey: "SubmissionKey",
|
||||||
lastScanAt: "LastScanAt",
|
lastScanAt: "LastScanAt",
|
||||||
iceTotal: "Ice Total", // prepaid ice bags
|
iceTotal: "Ice Total",
|
||||||
iceRedeemed: "Ice Redeemed", // bags picked up
|
iceRedeemed: "Ice Redeemed",
|
||||||
} as const;
|
} 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 };
|
export type NocoRecord = Record<string, unknown> & { Id: number };
|
||||||
|
|
||||||
function num(v: unknown): number {
|
function num(v: unknown): number {
|
||||||
|
|
@ -57,23 +48,40 @@ function bool(v: unknown): boolean {
|
||||||
return false;
|
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 {
|
export function computeTotal(rec: NocoRecord): number {
|
||||||
let total = 0;
|
return num(rec[COL.adults]) + num(rec[COL.youth]) + num(rec[COL.kids12]) + num(rec[COL.kids9]);
|
||||||
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 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 }[] {
|
export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number; free: boolean }[] {
|
||||||
return AGE_COLUMNS.map((col) => ({
|
return [
|
||||||
bracket: col.replace(/^Ages /, ""),
|
{ bracket: "Adults", count: num(rec[COL.adults]), free: false },
|
||||||
count: num(rec[col]),
|
{ bracket: "Youth 13-16", count: num(rec[COL.youth]), free: false },
|
||||||
free: FREE_AGE_COLUMNS.includes(col),
|
{ bracket: "Kids 10-12", count: num(rec[COL.kids12]), free: false },
|
||||||
})).filter((b) => b.count > 0);
|
{ 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 {
|
export interface ResourceCount {
|
||||||
|
|
@ -90,20 +98,20 @@ export interface TicketView {
|
||||||
redeemed: number;
|
redeemed: number;
|
||||||
remaining: number;
|
remaining: number;
|
||||||
ice: ResourceCount;
|
ice: ResourceCount;
|
||||||
|
adultNames: string[];
|
||||||
extras: {
|
extras: {
|
||||||
carParking: boolean;
|
carParking: boolean;
|
||||||
rvParking: boolean;
|
rvParking: boolean;
|
||||||
|
utv: boolean;
|
||||||
iceAccess: boolean;
|
iceAccess: boolean;
|
||||||
isDonor: boolean;
|
isDonor: boolean;
|
||||||
freeUnder4: number;
|
donorTier: string;
|
||||||
|
vouchers: number;
|
||||||
|
freeUnder5: number;
|
||||||
};
|
};
|
||||||
ages: { bracket: string; count: number; free: boolean }[];
|
ages: { bracket: string; count: number; free: boolean }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function computeIceTotal(rec: NocoRecord): number {
|
|
||||||
return num(rec[COL.iceTotal]);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toView(rec: NocoRecord): TicketView {
|
export function toView(rec: NocoRecord): TicketView {
|
||||||
const total = computeTotal(rec);
|
const total = computeTotal(rec);
|
||||||
const redeemed = num(rec[COL.redeemed]);
|
const redeemed = num(rec[COL.redeemed]);
|
||||||
|
|
@ -121,12 +129,16 @@ export function toView(rec: NocoRecord): TicketView {
|
||||||
redeemed: iceRedeemed,
|
redeemed: iceRedeemed,
|
||||||
remaining: Math.max(0, iceTotal - iceRedeemed),
|
remaining: Math.max(0, iceTotal - iceRedeemed),
|
||||||
},
|
},
|
||||||
|
adultNames: parseAdultNames(rec),
|
||||||
extras: {
|
extras: {
|
||||||
carParking: bool(rec[COL.carParking]),
|
carParking: bool(rec[COL.carParking]),
|
||||||
rvParking: bool(rec[COL.rvParking]),
|
rvParking: bool(rec[COL.rvParking]),
|
||||||
|
utv: bool(rec[COL.utv]),
|
||||||
iceAccess: bool(rec[COL.iceAccess]),
|
iceAccess: bool(rec[COL.iceAccess]),
|
||||||
isDonor: bool(rec[COL.isDonor]),
|
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),
|
ages: ageBreakdown(rec),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,57 +7,68 @@ interface Persona {
|
||||||
key: string;
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
email: string;
|
email: string;
|
||||||
ages: Record<string, number>;
|
adultNames?: string[];
|
||||||
|
counts: { adults: number; youth: number; kids12: number; kids9: number; kids4: number };
|
||||||
iceBags?: number;
|
iceBags?: number;
|
||||||
carParking?: boolean;
|
carParking?: boolean;
|
||||||
rvParking?: boolean;
|
rvParking?: boolean;
|
||||||
|
utv?: boolean;
|
||||||
isDonor?: boolean;
|
isDonor?: boolean;
|
||||||
|
donorTier?: string;
|
||||||
exhaust?: boolean; // pre-redeem all tickets so it scans as "exhausted"
|
exhaust?: boolean; // pre-redeem all tickets so it scans as "exhausted"
|
||||||
blurb: string;
|
blurb: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const C = (adults = 0, youth = 0, kids12 = 0, kids9 = 0, kids4 = 0) => ({ adults, youth, kids12, kids9, kids4 });
|
||||||
|
|
||||||
// A curated set covering the different attribute combinations to test.
|
// A curated set covering the different attribute combinations to test.
|
||||||
const PERSONAS: Persona[] = [
|
const PERSONAS: Persona[] = [
|
||||||
{
|
{
|
||||||
key: "solo",
|
key: "solo",
|
||||||
name: "Solo Sam",
|
name: "Solo Sam",
|
||||||
email: "solo@test.beartaria",
|
email: "solo@test.beartaria",
|
||||||
ages: { "Ages 18-25": 1 },
|
adultNames: ["Solo Sam"],
|
||||||
|
counts: C(1),
|
||||||
blurb: "1 ticket, no extras. Check-in mode → green, 1/1.",
|
blurb: "1 ticket, no extras. Check-in mode → green, 1/1.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "family",
|
key: "family",
|
||||||
name: "Family Fay",
|
name: "Family Fay",
|
||||||
email: "family@test.beartaria",
|
email: "family@test.beartaria",
|
||||||
ages: { "Ages 0-3": 2, "Ages 8-12": 3, "Ages 26-45": 2 },
|
adultNames: ["Family Fay", "Frank Fay"],
|
||||||
|
counts: C(2, 0, 0, 3, 2), // 2 adults + 3 kids(5-9) = 5 scannable; 2 kids 0-4 free
|
||||||
iceBags: 3,
|
iceBags: 3,
|
||||||
carParking: true,
|
carParking: true,
|
||||||
blurb:
|
blurb:
|
||||||
"5 tickets (2 under-4 free), car parking, 3 ice bags. Check-in a few at a time to test QR reuse; then Ice mode.",
|
"5 tickets (2 adults + 3 kids 5-9; two 0-4 free), car parking, 3 ice bags. Check-in a few at a time to test QR reuse + see adult names; then Ice mode.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "donor2",
|
key: "donor2",
|
||||||
name: "Donor Dan",
|
name: "Donor Dan",
|
||||||
email: "donor@example.test",
|
email: "donor@example.test",
|
||||||
ages: { "Ages 26-45": 2 },
|
adultNames: ["Donor Dan", "Donna Dan"],
|
||||||
|
counts: C(2),
|
||||||
rvParking: true,
|
rvParking: true,
|
||||||
|
utv: true,
|
||||||
isDonor: true,
|
isDonor: true,
|
||||||
|
donorTier: "member",
|
||||||
blurb:
|
blurb:
|
||||||
"2 tickets, RV parking, donor-flagged. Banquet mode: this test email has no real donations, so use Banquet's manual email lookup with a real donor's address to see totals.",
|
"2 tickets, RV + UTV, donor/member. Banquet mode: this test email has no real donations — use Banquet's manual email lookup with a real donor's address.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "ice",
|
key: "ice",
|
||||||
name: "Ice Ike",
|
name: "Ice Ike",
|
||||||
email: "ice@test.beartaria",
|
email: "ice@test.beartaria",
|
||||||
ages: { "Ages 18-25": 1 },
|
adultNames: ["Ice Ike"],
|
||||||
iceBags: 3,
|
counts: C(1),
|
||||||
blurb: "1 ticket + 3 ice bags. Ice mode → grab all 3 at once, then scan again → exhausted.",
|
iceBags: 6, // 2 ice tickets
|
||||||
|
blurb: "1 ticket + 6 ice bags (2 ice tickets). Ice mode → grab bags, then scan again → exhausted.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "exhausted",
|
key: "exhausted",
|
||||||
name: "Done Dora",
|
name: "Done Dora",
|
||||||
email: "done@test.beartaria",
|
email: "done@test.beartaria",
|
||||||
ages: { "Ages 26-45": 2 },
|
counts: C(2),
|
||||||
exhaust: true,
|
exhaust: true,
|
||||||
blurb: "2 tickets, already fully redeemed. Check-in mode → red 'exhausted'.",
|
blurb: "2 tickets, already fully redeemed. Check-in mode → red 'exhausted'.",
|
||||||
},
|
},
|
||||||
|
|
@ -74,22 +85,22 @@ export async function testRoutes(app: FastifyInstance): Promise<void> {
|
||||||
for (const p of PERSONAS) {
|
for (const p of PERSONAS) {
|
||||||
const result = await createTicket(app.ctx, {
|
const result = await createTicket(app.ctx, {
|
||||||
name: p.name,
|
name: p.name,
|
||||||
|
adultNames: p.adultNames,
|
||||||
email: p.email,
|
email: p.email,
|
||||||
ages: p.ages,
|
counts: p.counts,
|
||||||
iceBags: p.iceBags,
|
iceBags: p.iceBags,
|
||||||
carParking: p.carParking,
|
carParking: p.carParking,
|
||||||
rvParking: p.rvParking,
|
rvParking: p.rvParking,
|
||||||
|
utv: p.utv,
|
||||||
isDonor: p.isDonor,
|
isDonor: p.isDonor,
|
||||||
|
donorTier: p.donorTier,
|
||||||
submissionKey: `test:${p.key}`,
|
submissionKey: `test:${p.key}`,
|
||||||
});
|
});
|
||||||
// Keep the "exhausted" persona fully redeemed on every load so its state
|
// Keep the "exhausted" persona fully redeemed on every load so its state
|
||||||
// is deterministic (compute the total from the persona's own age counts,
|
// is deterministic (total = scannable count from the persona's counts).
|
||||||
// since NocoDB's create response may not echo them back).
|
|
||||||
if (p.exhaust) {
|
if (p.exhaust) {
|
||||||
const total = Object.entries(p.ages)
|
const { adults, youth, kids12, kids9 } = p.counts;
|
||||||
.filter(([col]) => col !== "Ages 0-3")
|
await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth + kids12 + kids9 });
|
||||||
.reduce((s, [, n]) => s + n, 0);
|
|
||||||
await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: total });
|
|
||||||
}
|
}
|
||||||
cards.push({
|
cards.push({
|
||||||
code: result.code,
|
code: result.code,
|
||||||
|
|
|
||||||
|
|
@ -167,11 +167,16 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
|
||||||
schema: {
|
schema: {
|
||||||
body: {
|
body: {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["name", "ages"],
|
required: ["name"],
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: "string", minLength: 1 },
|
name: { type: "string", minLength: 1 },
|
||||||
email: { type: "string" },
|
email: { type: "string" },
|
||||||
ages: { type: "object" },
|
adults: { type: "integer", minimum: 0 },
|
||||||
|
youth: { type: "integer", minimum: 0 },
|
||||||
|
kids12: { type: "integer", minimum: 0 },
|
||||||
|
kids9: { type: "integer", minimum: 0 },
|
||||||
|
kids4: { type: "integer", minimum: 0 },
|
||||||
|
adultNames: { type: "array", items: { type: "string" } },
|
||||||
sendEmail: { type: "boolean" },
|
sendEmail: { type: "boolean" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -182,8 +187,15 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
|
||||||
const submissionKey = `manual:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`;
|
const submissionKey = `manual:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`;
|
||||||
const result = await createTicket(app.ctx, {
|
const result = await createTicket(app.ctx, {
|
||||||
name: b.name,
|
name: b.name,
|
||||||
|
adultNames: b.adultNames,
|
||||||
email: b.email ?? "",
|
email: b.email ?? "",
|
||||||
ages: b.ages,
|
counts: {
|
||||||
|
adults: b.adults ?? 1,
|
||||||
|
youth: b.youth ?? 0,
|
||||||
|
kids12: b.kids12 ?? 0,
|
||||||
|
kids9: b.kids9 ?? 0,
|
||||||
|
kids4: b.kids4 ?? 0,
|
||||||
|
},
|
||||||
submissionKey,
|
submissionKey,
|
||||||
});
|
});
|
||||||
if (b.sendEmail && b.email && !app.ctx.mailer.isBlockedRecipient(b.email)) {
|
if (b.sendEmail && b.email && !app.ctx.mailer.isBlockedRecipient(b.email)) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { createHash, timingSafeEqual } from "node:crypto";
|
import { createHash, timingSafeEqual } from "node:crypto";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import { AGE_COLUMNS, toBool, toNumber } from "../fields.js";
|
import { toBool, toNumber } from "../fields.js";
|
||||||
import { createTicket } from "../ticketService.js";
|
import { createTicket } from "../ticketService.js";
|
||||||
import { renderQrPng } from "../services/qrcode.js";
|
import { renderQrPng } from "../services/qrcode.js";
|
||||||
|
|
||||||
|
|
@ -11,18 +11,48 @@ function safeEqual(a: string, b: string): boolean {
|
||||||
return timingSafeEqual(ba, bb);
|
return timingSafeEqual(ba, bb);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map webhook payload keys -> NocoDB age-column titles. Keys are what you map
|
/** Read a FluentForms compound name field, given as a nested object
|
||||||
// the FluentForms fields to in the webhook feed.
|
* (`names: {first_name,...}`) or flattened bracket keys (`names[first_name]`). */
|
||||||
const AGE_KEY_TO_COL: Record<string, string> = {
|
function nameGroup(body: Record<string, any>, base: string): string {
|
||||||
ages_0_3: "Ages 0-3",
|
const obj = body[base];
|
||||||
ages_4_7: "Ages 4-7",
|
let first: any, middle: any, last: any;
|
||||||
ages_8_12: "Ages 8-12",
|
if (obj && typeof obj === "object") {
|
||||||
ages_13_17: "Ages 13-17",
|
({ first_name: first, middle_name: middle, last_name: last } = obj);
|
||||||
ages_18_25: "Ages 18-25",
|
} else {
|
||||||
ages_26_45: "Ages 26-45",
|
first = body[`${base}[first_name]`];
|
||||||
ages_46_64: "Ages 46-64",
|
middle = body[`${base}[middle_name]`];
|
||||||
ages_65: "Ages 65+",
|
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"). */
|
||||||
|
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. */
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adult name field bases, in order (purchaser first).
|
||||||
|
const ADULT_NAME_BASES = ["names", "names_1", "names_2", "names_3", "names_4", "names_5", "names_6", "names_7", "names_8", "names_9"];
|
||||||
|
|
||||||
export async function webhookRoutes(app: FastifyInstance): Promise<void> {
|
export async function webhookRoutes(app: FastifyInstance): Promise<void> {
|
||||||
const handler = async (req: any, reply: any) => {
|
const handler = async (req: any, reply: any) => {
|
||||||
|
|
@ -31,57 +61,87 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
|
||||||
return reply.code(401).send({ error: "unauthorized" });
|
return reply.code(401).send({ error: "unauthorized" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
const body = (req.body ?? {}) as Record<string, any>;
|
||||||
const name = String(body.name ?? "").trim();
|
|
||||||
|
// Purchaser = the first adult name group; fall back to a plain `name` field.
|
||||||
|
const name = nameGroup(body, "names") || String(body.name ?? "").trim();
|
||||||
const email = String(body.email ?? "").trim();
|
const email = String(body.email ?? "").trim();
|
||||||
if (!name || !email) {
|
if (!name) {
|
||||||
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" });
|
return reply.code(400).send({ error: "missing_fields", detail: "purchaser name is required" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build age-bracket counts from whichever keys were provided.
|
// Adult attendee names (non-empty groups, in order).
|
||||||
const ages: Record<string, number> = {};
|
const adultNames = ADULT_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean);
|
||||||
for (const [key, col] of Object.entries(AGE_KEY_TO_COL)) {
|
|
||||||
if (body[key] !== undefined && body[key] !== null && body[key] !== "") {
|
// Attendee counts.
|
||||||
ages[col] = toNumber(body[key]);
|
const counts = {
|
||||||
}
|
adults: qty(body.item_quantity_adult_ticket_reg) + qty(body.item_quantity_adult_ticket_donor),
|
||||||
}
|
youth: qty(body.item_quantity_youth_ticket_reg) + qty(body.item_quantity_youth_ticket_donor),
|
||||||
const anyAge = AGE_COLUMNS.some((c) => (ages[c] ?? 0) > 0);
|
kids12: qty(body.item_quantity_kids_12),
|
||||||
if (!anyAge) {
|
kids9: qty(body.item_quantity_kids_9),
|
||||||
return reply.code(400).send({ error: "no_tickets", detail: "no age-bracket counts provided" });
|
kids4: qty(body.item_quantity_kids_4),
|
||||||
|
};
|
||||||
|
const scannable = counts.adults + counts.youth + counts.kids12 + counts.kids9;
|
||||||
|
if (scannable <= 0) {
|
||||||
|
// Nothing to check in at the gate. Log the payload so we can calibrate.
|
||||||
|
req.log.warn({ body }, "webhook: no scannable tickets in submission");
|
||||||
|
return reply.code(400).send({ error: "no_tickets", detail: "no scannable tickets (adults/youth/kids 5+)" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotency key: prefer a stable submission id, else hash the content.
|
// Donor info (hidden fields from the eligibility/voucher lookups) + radio.
|
||||||
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id;
|
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?"
|
||||||
|
const vouchers = qty(body.vouchers);
|
||||||
|
|
||||||
|
// Extras (best-effort from payment fields — donor variants may be free/$0).
|
||||||
|
const carParking = selected(body.payment_parking_reg) || selected(body.payment_parking_donor);
|
||||||
|
const rvParking = selected(body.payment_rv_reg) || selected(body.payment_rv_donor);
|
||||||
|
const utv = selected(body.payment_utv_reg) || selected(body.payment_utv_donor);
|
||||||
|
// Ice: payment_ice is either a ticket count (1-4) or a dollar total
|
||||||
|
// ($20-$80). One ice ticket = ICE_BAGS_PER_TICKET bags.
|
||||||
|
const iceRaw = qty(body.payment_ice);
|
||||||
|
const iceTickets = iceRaw >= app.ctx.config.ICE_TICKET_PRICE ? Math.round(iceRaw / app.ctx.config.ICE_TICKET_PRICE) : Math.round(iceRaw);
|
||||||
|
const iceBags = Math.max(0, iceTickets) * app.ctx.config.ICE_BAGS_PER_TICKET;
|
||||||
|
const iceAccess = iceBags > 0 || selected(body.input_radio_7);
|
||||||
|
|
||||||
|
const address =
|
||||||
|
body.address_1 && typeof body.address_1 === "object"
|
||||||
|
? Object.values(body.address_1).filter(Boolean).join(", ")
|
||||||
|
: body.address_1 !== undefined
|
||||||
|
? String(body.address_1)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Idempotency: prefer a stable submission id, else hash the content.
|
||||||
|
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id ?? body.id;
|
||||||
const submissionKey = submissionId
|
const submissionKey = submissionId
|
||||||
? `sub:${String(submissionId)}`
|
? `sub:${String(submissionId)}`
|
||||||
: "hash:" +
|
: "hash:" +
|
||||||
createHash("sha256")
|
createHash("sha256")
|
||||||
.update(`${email}|${name}|${JSON.stringify(ages)}`)
|
.update(`${email}|${name}|${JSON.stringify(counts)}`)
|
||||||
.digest("hex")
|
.digest("hex")
|
||||||
.slice(0, 32);
|
.slice(0, 32);
|
||||||
|
|
||||||
// Ice: prefer an explicit bag count; else grant the default when a boolean
|
|
||||||
// ice option is truthy; else 0.
|
|
||||||
let iceBags = 0;
|
|
||||||
if (body.ice_bags !== undefined && body.ice_bags !== null && body.ice_bags !== "") {
|
|
||||||
iceBags = toNumber(body.ice_bags);
|
|
||||||
} else if (body.ice_access !== undefined && toBool(body.ice_access)) {
|
|
||||||
iceBags = app.ctx.config.ICE_BAGS_DEFAULT;
|
|
||||||
}
|
|
||||||
|
|
||||||
let result: Awaited<ReturnType<typeof createTicket>>;
|
let result: Awaited<ReturnType<typeof createTicket>>;
|
||||||
try {
|
try {
|
||||||
result = await createTicket(app.ctx, {
|
result = await createTicket(app.ctx, {
|
||||||
name,
|
name,
|
||||||
|
adultNames,
|
||||||
email,
|
email,
|
||||||
address: body.address !== undefined ? String(body.address) : undefined,
|
address,
|
||||||
isDonor: body.is_donor !== undefined ? toBool(body.is_donor) : undefined,
|
isDonor,
|
||||||
carParking: body.car_parking !== undefined ? toBool(body.car_parking) : undefined,
|
donorTier,
|
||||||
rvParking: body.rv_parking !== undefined ? toBool(body.rv_parking) : undefined,
|
vouchers,
|
||||||
iceAccess: body.ice_access !== undefined ? toBool(body.ice_access) : undefined,
|
counts,
|
||||||
|
carParking,
|
||||||
|
rvParking,
|
||||||
|
utv,
|
||||||
|
iceAccess,
|
||||||
iceBags,
|
iceBags,
|
||||||
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
|
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
|
||||||
ages,
|
|
||||||
submissionKey,
|
submissionKey,
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
@ -93,9 +153,13 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
|
||||||
return { status: "duplicate", code: result.code };
|
return { status: "duplicate", code: result.code };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send the ticket email. If it fails, the row already exists — report 502
|
// Send the ticket QR email (FluentForms sends the receipt separately). If it
|
||||||
// so the failure is visible in FluentForms' delivery log; the ticket can be
|
// fails, the row already exists — report 502 so it's visible in the feed
|
||||||
// re-sent later via POST /api/tickets/:code/resend-email.
|
// log; re-send later via POST /api/tickets/:code/resend-email.
|
||||||
|
if (!email) {
|
||||||
|
req.log.warn({ code: result.code }, "webhook: ticket created but no email to send to");
|
||||||
|
return { status: "created", code: result.code, emailSent: false, emailSkipped: "no_email" };
|
||||||
|
}
|
||||||
if (app.ctx.mailer.isBlockedRecipient(email)) {
|
if (app.ctx.mailer.isBlockedRecipient(email)) {
|
||||||
req.log.warn({ email }, "webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send");
|
req.log.warn({ email }, "webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send");
|
||||||
return { status: "created", code: result.code, emailSent: false, emailSkipped: "trial_restriction" };
|
return { status: "created", code: result.code, emailSent: false, emailSkipped: "trial_restriction" };
|
||||||
|
|
@ -103,13 +167,11 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const qr = await renderQrPng(result.code);
|
const qr = await renderQrPng(result.code);
|
||||||
const quantity = // redeemable total for the email copy
|
|
||||||
AGE_COLUMNS.filter((c) => c !== "Ages 0-3").reduce((s, c) => s + (ages[c] ?? 0), 0);
|
|
||||||
await app.ctx.mailer.sendTicket({
|
await app.ctx.mailer.sendTicket({
|
||||||
toEmail: email,
|
toEmail: email,
|
||||||
toName: name,
|
toName: name,
|
||||||
code: result.code,
|
code: result.code,
|
||||||
quantity,
|
quantity: scannable,
|
||||||
qrPng: qr,
|
qrPng: qr,
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
|
|
|
||||||
|
|
@ -11,29 +11,26 @@ interface Field {
|
||||||
}
|
}
|
||||||
|
|
||||||
const FIELDS: Field[] = [
|
const FIELDS: Field[] = [
|
||||||
{ key: "name", req: "required", type: "text", desc: "Purchaser's full name." },
|
{ key: "names", req: "required", type: "name (compound)", desc: "Purchaser / Adult #1 — object {first_name, middle_name, last_name}. Also accepts flat names[first_name] keys." },
|
||||||
{ key: "email", req: "required", type: "email", desc: "Purchaser's email — the QR ticket is sent here." },
|
{ key: "names_1 … names_9", req: "optional", type: "name (compound)", desc: "Additional adult attendee names (Adults #2–#10). Empty groups are ignored. Stored as the adult-name list shown at the gate." },
|
||||||
{
|
{ key: "email", req: "optional", type: "email", desc: "Purchaser email — the QR ticket is sent here (FluentForms sends the receipt separately)." },
|
||||||
key: "submission_id",
|
{ key: "address_1", req: "optional", type: "address (compound)", desc: "Mailing address object; joined into one line." },
|
||||||
req: "optional",
|
{ key: "item_quantity_adult_ticket_reg", req: "required", type: "quantity", desc: "Adult tickets (regular)." },
|
||||||
type: "text/number",
|
{ key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "Adult tickets (donor). Added to the regular adults." },
|
||||||
desc: "Form entry/submission ID. Used for idempotency so retries or double-submits don't create duplicate tickets. If omitted, a hash of name+email+counts is used instead. (Aliases: submissionId, entry_id.)",
|
{ key: "item_quantity_youth_ticket_reg / _donor", req: "optional", type: "quantity", desc: "Youth 13-16 tickets (regular + donor)." },
|
||||||
},
|
{ key: "item_quantity_kids_12", req: "optional", type: "quantity", desc: "Kids 10-12. Counts toward the scannable total." },
|
||||||
{ key: "ages_0_3", req: "optional", type: "number", desc: "Headcount ages 0–3. Admitted free — NOT counted toward redeemable tickets." },
|
{ key: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. Counts toward the scannable total." },
|
||||||
{ key: "ages_4_7", req: "optional", type: "number", desc: "Headcount ages 4–7." },
|
{ key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE — NOT counted toward the scannable ticket total." },
|
||||||
{ key: "ages_8_12", req: "optional", type: "number", desc: "Headcount ages 8–12." },
|
{ key: "donor_tier", req: "optional", type: "hidden", desc: "member / donor / empty (from the donor-eligibility lookup)." },
|
||||||
{ key: "ages_13_17", req: "optional", type: "number", desc: "Headcount ages 13–17." },
|
{ key: "donor_eligible", req: "optional", type: "hidden", desc: "true / false (from the donor-eligibility lookup)." },
|
||||||
{ key: "ages_18_25", req: "optional", type: "number", desc: "Headcount ages 18–25." },
|
{ key: "vouchers", req: "optional", type: "hidden", desc: "Integer voucher count (from the ticket-voucher lookup)." },
|
||||||
{ key: "ages_26_45", req: "optional", type: "number", desc: "Headcount ages 26–45." },
|
{ key: "input_radio", req: "optional", type: "choice", desc: "'Are you a campground donor?' — also used as a donor signal." },
|
||||||
{ key: "ages_46_64", req: "optional", type: "number", desc: "Headcount ages 46–64." },
|
{ key: "payment_parking_reg / _donor", req: "optional", type: "payment", desc: "Car parking. Flagged if either variant is selected." },
|
||||||
{ key: "ages_65", req: "optional", type: "number", desc: "Headcount ages 65+." },
|
{ key: "payment_rv_reg / _donor", req: "optional", type: "payment", desc: "RV. Flagged if either variant is selected." },
|
||||||
{ key: "ice_bags", req: "optional", type: "number", desc: "Prepaid ice bags. If omitted and ice_access is truthy, defaults to the configured amount (3)." },
|
{ key: "payment_utv_reg / _donor", req: "optional", type: "payment", desc: "ATV/UTV. Flagged if either variant is selected." },
|
||||||
{ key: "ice_access", req: "optional", type: "yes/no", desc: "Whether they bought ice access. Accepts 1/0, true/false, yes/no." },
|
{ key: "payment_ice", req: "optional", type: "payment", desc: "Ice tickets (1-4 at $20 each). One ice ticket = 3 bags; stored as bags = tickets × 3. Accepts a ticket count (1-4) or a dollar total ($20-$80)." },
|
||||||
{ key: "car_parking", req: "optional", type: "yes/no", desc: "Car parking pass." },
|
{ key: "payment_method", req: "optional", type: "payment", desc: "Payment method label." },
|
||||||
{ key: "rv_parking", req: "optional", type: "yes/no", desc: "RV parking pass." },
|
{ key: "id / submission_id", req: "optional", type: "text", desc: "Entry/submission id for idempotency (retries won't duplicate). Falls back to a content hash." },
|
||||||
{ key: "is_donor", req: "optional", type: "yes/no", desc: "Donor flag." },
|
|
||||||
{ key: "address", req: "optional", type: "text", desc: "Mailing address." },
|
|
||||||
{ key: "payment_method", req: "optional", type: "text", desc: "Payment method label." },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function esc(s: string): string {
|
function esc(s: string): string {
|
||||||
|
|
@ -48,28 +45,34 @@ export async function webhookDocRoutes(app: FastifyInstance): Promise<void> {
|
||||||
|
|
||||||
const rows = FIELDS.map(
|
const rows = FIELDS.map(
|
||||||
(f) => `<tr>
|
(f) => `<tr>
|
||||||
<td><code>${f.key}</code></td>
|
<td><code>${esc(f.key)}</code></td>
|
||||||
<td class="${f.req === "required" ? "req" : "opt"}">${f.req}</td>
|
<td class="${f.req === "required" ? "req" : "opt"}">${f.req}</td>
|
||||||
<td>${f.type}</td>
|
<td>${esc(f.type)}</td>
|
||||||
<td>${esc(f.desc)}</td>
|
<td>${esc(f.desc)}</td>
|
||||||
</tr>`,
|
</tr>`,
|
||||||
).join("");
|
).join("");
|
||||||
|
|
||||||
const exampleJson = esc(`{
|
const exampleJson = esc(`{
|
||||||
"name": "Jane Bear",
|
"id": "412",
|
||||||
|
"names": { "first_name": "Jane", "last_name": "Bear" },
|
||||||
|
"names_1": { "first_name": "John", "last_name": "Bear" },
|
||||||
"email": "jane@example.com",
|
"email": "jane@example.com",
|
||||||
"submission_id": "12345",
|
"item_quantity_adult_ticket_reg": 2,
|
||||||
"ages_0_3": 2,
|
"item_quantity_adult_ticket_donor": 0,
|
||||||
"ages_8_12": 3,
|
"item_quantity_youth_ticket_reg": 1,
|
||||||
"ages_26_45": 2,
|
"item_quantity_kids_9": 2,
|
||||||
"car_parking": "yes",
|
"item_quantity_kids_4": 2,
|
||||||
"ice_access": "yes"
|
"donor_tier": "member",
|
||||||
|
"vouchers": 2,
|
||||||
|
"payment_parking_reg": "$40.00",
|
||||||
|
"payment_ice": 2,
|
||||||
|
"payment_method": "stripe"
|
||||||
}`);
|
}`);
|
||||||
|
|
||||||
const exampleCurl = esc(`curl -X POST ${WEBHOOK_URL} \\
|
const exampleCurl = esc(`curl -X POST ${WEBHOOK_URL} \\
|
||||||
-H "Content-Type: application/json" \\
|
-H "Content-Type: application/json" \\
|
||||||
-H "X-Webhook-Secret: <your WEBHOOK_SECRET>" \\
|
-H "X-Webhook-Secret: <your WEBHOOK_SECRET>" \\
|
||||||
-d '{"name":"Jane Bear","email":"jane@example.com","submission_id":"12345","ages_26_45":2,"ice_access":"yes"}'`);
|
-d @submission.json`);
|
||||||
|
|
||||||
const PAGE = `<!doctype html>
|
const PAGE = `<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
@ -82,7 +85,7 @@ const PAGE = `<!doctype html>
|
||||||
:root { color-scheme: dark; }
|
:root { color-scheme: dark; }
|
||||||
* { box-sizing: border-box; }
|
* { box-sizing: border-box; }
|
||||||
body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.55; }
|
body { margin: 0; background: #0f1a12; color: #eaf2ec; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Arial, sans-serif; line-height: 1.55; }
|
||||||
.wrap { max-width: 820px; margin: 0 auto; padding: 28px 20px 64px; }
|
.wrap { max-width: 900px; margin: 0 auto; padding: 28px 20px 64px; }
|
||||||
h1 { font-size: 26px; margin: 0 0 4px; }
|
h1 { font-size: 26px; margin: 0 0 4px; }
|
||||||
h2 { font-size: 20px; margin: 32px 0 10px; border-bottom: 1px solid #24382a; padding-bottom: 6px; }
|
h2 { font-size: 20px; margin: 32px 0 10px; border-bottom: 1px solid #24382a; padding-bottom: 6px; }
|
||||||
.sub { color: #9db3a4; margin: 0 0 8px; }
|
.sub { color: #9db3a4; margin: 0 0 8px; }
|
||||||
|
|
@ -104,31 +107,32 @@ const PAGE = `<!doctype html>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<h1>🐻 Camp Scan — Purchase Webhook</h1>
|
<h1>🐻 Camp Scan — Purchase Webhook (Tickets 2026)</h1>
|
||||||
<p class="sub">How the FluentForms ticket checkout notifies the ticketing backend to create a ticket and email the QR code.</p>
|
<p class="sub">How the FluentForms "Tickets 2026" checkout notifies the ticketing backend to create a ticket and email the QR code.</p>
|
||||||
|
|
||||||
<div class="kv">
|
<div class="kv">
|
||||||
<div><b>Endpoint</b> <span class="pill">POST</span> <code>${WEBHOOK_URL}</code></div>
|
<div><b>Endpoint</b> <span class="pill">POST</span> <code>${WEBHOOK_URL}</code></div>
|
||||||
<div><b>Auth header</b> <code>X-Webhook-Secret: <the shared WEBHOOK_SECRET></code></div>
|
<div><b>Auth header</b> <code>X-Webhook-Secret: <the shared WEBHOOK_SECRET></code></div>
|
||||||
<div><b>Body format</b> JSON (<code>application/json</code>) or form-encoded — both accepted.</div>
|
<div><b>Body format</b> JSON (<code>application/json</code>) or form-encoded — both accepted. Send all form fields.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>What it does</h2>
|
<h2>What it does</h2>
|
||||||
<p>On a valid request the backend generates a unique ticket code, creates a row in the "2026 Campground Tickets" NocoDB table, renders a QR code, and emails it to the purchaser (subject: <b>"2026 Beartaria Campgrounds Tickets"</b>). The total number of redeemable tickets is the <b>sum of the age-bracket counts, excluding ages 0–3</b> (who are free).</p>
|
<p>On a valid request the backend generates a unique ticket code, creates a NocoDB row, and emails the QR code to the purchaser (subject <b>"2026 Beartaria Campgrounds Tickets"</b>). FluentForms sends the payment receipt separately.</p>
|
||||||
|
<p><b>Scannable ticket total</b> = adults + youth (13-16) + kids 10-12 + kids 5-9. <b>Kids 0-4 are free</b> and not counted. Each adult name provided is stored and shown to gate staff on a successful scan.</p>
|
||||||
|
|
||||||
<h2>Fields</h2>
|
<h2>Fields</h2>
|
||||||
<table>
|
<table>
|
||||||
<thead><tr><th>Key</th><th>Required</th><th>Type</th><th>Description</th></tr></thead>
|
<thead><tr><th>Key</th><th>Required</th><th>Type</th><th>Description</th></tr></thead>
|
||||||
<tbody>${rows}</tbody>
|
<tbody>${rows}</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p class="sub">At least one non-zero age-bracket count is required (otherwise there are no tickets to issue). Booleans accept <code>1/0</code>, <code>true/false</code>, or <code>yes/no</code>.</p>
|
<p class="sub">Compound name fields arrive as objects (<code>names: {first_name,…}</code>) or flattened <code>names[first_name]</code> keys — both handled. Quantity/payment fields accept numbers, money strings ("$40.00"), or <code>{quantity}</code> objects.</p>
|
||||||
|
|
||||||
<h2>Idempotency</h2>
|
<h2>Idempotency</h2>
|
||||||
<p>Send a stable <code>submission_id</code>. If the backend sees the same one again it returns <code>{"status":"duplicate"}</code> without creating a second ticket or re-sending email — so FluentForms retries and accidental double-submits are safe.</p>
|
<p>Send a stable <code>id</code> / <code>submission_id</code>. A repeat returns <code>{"status":"duplicate"}</code> without creating a second ticket or re-emailing — safe for retries and double-submits.</p>
|
||||||
|
|
||||||
<h2>Example payload</h2>
|
<h2>Example payload</h2>
|
||||||
<pre><code>${exampleJson}</code></pre>
|
<pre><code>${exampleJson}</code></pre>
|
||||||
<p class="sub">This issues 5 redeemable tickets (3×8–12 + 2×26–45; the two 0–3 are free), with car parking and 3 ice bags.</p>
|
<p class="sub">This issues 5 scannable tickets (2 adults + 1 youth + 2 kids 5-9; the two kids 0-4 are free), member donor with 2 vouchers, car parking, and 6 bags of ice (2 ice tickets).</p>
|
||||||
|
|
||||||
<h2>Test with curl</h2>
|
<h2>Test with curl</h2>
|
||||||
<pre><code>${exampleCurl}</code></pre>
|
<pre><code>${exampleCurl}</code></pre>
|
||||||
|
|
@ -139,7 +143,7 @@ const PAGE = `<!doctype html>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr><td>200</td><td><code>{"status":"created","code":"BC26-…","emailSent":true}</code></td><td>Ticket created and emailed.</td></tr>
|
<tr><td>200</td><td><code>{"status":"created","code":"BC26-…","emailSent":true}</code></td><td>Ticket created and emailed.</td></tr>
|
||||||
<tr><td>200</td><td><code>{"status":"duplicate","code":"BC26-…"}</code></td><td>Same submission already processed — no-op.</td></tr>
|
<tr><td>200</td><td><code>{"status":"duplicate","code":"BC26-…"}</code></td><td>Same submission already processed — no-op.</td></tr>
|
||||||
<tr><td>400</td><td><code>{"error":"missing_fields"}</code> / <code>"no_tickets"</code></td><td>Missing name/email, or no age counts.</td></tr>
|
<tr><td>400</td><td><code>{"error":"missing_fields"}</code> / <code>"no_tickets"</code></td><td>Missing purchaser name, or zero scannable tickets.</td></tr>
|
||||||
<tr><td>401</td><td><code>{"error":"unauthorized"}</code></td><td>Missing or wrong <code>X-Webhook-Secret</code>.</td></tr>
|
<tr><td>401</td><td><code>{"error":"unauthorized"}</code></td><td>Missing or wrong <code>X-Webhook-Secret</code>.</td></tr>
|
||||||
<tr><td>502</td><td><code>{"status":"created","emailSent":false,…}</code></td><td>Ticket row created but the email failed — re-send from the admin app.</td></tr>
|
<tr><td>502</td><td><code>{"status":"created","emailSent":false,…}</code></td><td>Ticket row created but the email failed — re-send from the admin app.</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
@ -148,11 +152,10 @@ const PAGE = `<!doctype html>
|
||||||
<h2>FluentForms setup</h2>
|
<h2>FluentForms setup</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li>On the ticket form: <b>Settings & Integrations → Webhook → Add Webhook</b>.</li>
|
<li>On the ticket form: <b>Settings & Integrations → Webhook → Add Webhook</b>.</li>
|
||||||
<li><b>Request URL:</b> <code>${WEBHOOK_URL}</code></li>
|
<li><b>Request URL:</b> <code>${WEBHOOK_URL}</code> · <b>Method:</b> <code>POST</code> · <b>Format:</b> <code>JSON</code></li>
|
||||||
<li><b>Request Method:</b> <code>POST</code> · <b>Format:</b> <code>JSON</code></li>
|
|
||||||
<li><b>Request Headers:</b> add <code>X-Webhook-Secret</code> = the shared secret.</li>
|
<li><b>Request Headers:</b> add <code>X-Webhook-Secret</code> = the shared secret.</li>
|
||||||
<li><b>Request Body:</b> map each form field to the keys in the table above.</li>
|
<li><b>Request Body:</b> send <b>all fields</b> (the field names above are the FluentForms field keys).</li>
|
||||||
<li>Save, then submit a test purchase and confirm the QR email arrives.</li>
|
<li>Save, submit a test purchase, and confirm the QR email arrives.</li>
|
||||||
</ol>
|
</ol>
|
||||||
|
|
||||||
<footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer>
|
<footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer>
|
||||||
|
|
|
||||||
|
|
@ -86,14 +86,13 @@ export function fakeContext(db: FakeNocoDB): AppContext {
|
||||||
|
|
||||||
export async function seedTicket(
|
export async function seedTicket(
|
||||||
db: FakeNocoDB,
|
db: FakeNocoDB,
|
||||||
opts: { code: string; name?: string; email?: string; ages?: Record<string, number>; redeemed?: number },
|
opts: { code: string; name?: string; email?: string; adults?: number; redeemed?: number },
|
||||||
): Promise<NocoRecord> {
|
): Promise<NocoRecord> {
|
||||||
const ages = opts.ages ?? { "Ages 18-25": 2, "Ages 26-45": 3, "Ages 0-3": 1 };
|
|
||||||
return db.create({
|
return db.create({
|
||||||
[COL.code]: opts.code,
|
[COL.code]: opts.code,
|
||||||
[COL.name]: opts.name ?? "Test Bear",
|
[COL.name]: opts.name ?? "Test Bear",
|
||||||
[COL.email]: opts.email ?? "test@example.com",
|
[COL.email]: opts.email ?? "test@example.com",
|
||||||
|
[COL.adults]: opts.adults ?? 5,
|
||||||
[COL.redeemed]: opts.redeemed ?? 0,
|
[COL.redeemed]: opts.redeemed ?? 0,
|
||||||
...ages,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,45 +2,52 @@ import { describe, it, expect } from "vitest";
|
||||||
import { computeTotal, toView, COL } from "../fields.js";
|
import { computeTotal, toView, COL } from "../fields.js";
|
||||||
|
|
||||||
describe("computeTotal", () => {
|
describe("computeTotal", () => {
|
||||||
it("sums age brackets but excludes Ages 0-3 (free)", () => {
|
it("sums adults + youth + kids 10-12 + kids 5-9, excluding kids 0-4 (free)", () => {
|
||||||
const rec = {
|
const rec = {
|
||||||
Id: 1,
|
Id: 1,
|
||||||
"Ages 0-3": 2, // free, not counted
|
[COL.adults]: 2,
|
||||||
"Ages 4-7": 1,
|
[COL.youth]: 1,
|
||||||
"Ages 18-25": 2,
|
[COL.kids12]: 1,
|
||||||
"Ages 26-45": 1,
|
[COL.kids9]: 1,
|
||||||
|
[COL.kids4]: 3, // free, not counted
|
||||||
};
|
};
|
||||||
expect(computeTotal(rec)).toBe(4);
|
expect(computeTotal(rec)).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("coerces string counts and treats blanks as 0", () => {
|
it("coerces string counts and treats blanks as 0", () => {
|
||||||
const rec = { Id: 1, "Ages 18-25": "3", "Ages 26-45": "" } as any;
|
const rec = { Id: 1, [COL.adults]: "3", [COL.youth]: "" } as any;
|
||||||
expect(computeTotal(rec)).toBe(3);
|
expect(computeTotal(rec)).toBe(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("toView", () => {
|
describe("toView", () => {
|
||||||
it("derives remaining and surfaces extras", () => {
|
it("derives remaining and surfaces adult names, donor tier, and extras", () => {
|
||||||
const rec = {
|
const rec = {
|
||||||
Id: 7,
|
Id: 7,
|
||||||
[COL.code]: "BC26-ABCD-2345",
|
[COL.code]: "BC26-ABCD-2345",
|
||||||
[COL.name]: "Jane Bear",
|
[COL.name]: "Jane Bear",
|
||||||
[COL.email]: "jane@example.com",
|
[COL.email]: "jane@example.com",
|
||||||
|
[COL.adultNames]: "Jane Bear\nJohn Bear",
|
||||||
[COL.redeemed]: 2,
|
[COL.redeemed]: 2,
|
||||||
|
[COL.adults]: 2,
|
||||||
|
[COL.youth]: 3,
|
||||||
|
[COL.kids4]: 1,
|
||||||
[COL.carParking]: true,
|
[COL.carParking]: true,
|
||||||
[COL.iceAccess]: "yes",
|
[COL.iceAccess]: "yes",
|
||||||
"Ages 0-3": 1,
|
[COL.donorTier]: "member",
|
||||||
"Ages 18-25": 2,
|
[COL.vouchers]: 2,
|
||||||
"Ages 26-45": 3,
|
|
||||||
};
|
};
|
||||||
const v = toView(rec);
|
const v = toView(rec);
|
||||||
expect(v.total).toBe(5);
|
expect(v.total).toBe(5);
|
||||||
expect(v.redeemed).toBe(2);
|
expect(v.redeemed).toBe(2);
|
||||||
expect(v.remaining).toBe(3);
|
expect(v.remaining).toBe(3);
|
||||||
|
expect(v.adultNames).toEqual(["Jane Bear", "John Bear"]);
|
||||||
expect(v.extras.carParking).toBe(true);
|
expect(v.extras.carParking).toBe(true);
|
||||||
expect(v.extras.iceAccess).toBe(true);
|
expect(v.extras.iceAccess).toBe(true);
|
||||||
expect(v.extras.rvParking).toBe(false);
|
expect(v.extras.rvParking).toBe(false);
|
||||||
expect(v.extras.freeUnder4).toBe(1);
|
expect(v.extras.donorTier).toBe("member");
|
||||||
expect(v.ages.find((a) => a.bracket === "0-3")?.free).toBe(true);
|
expect(v.extras.vouchers).toBe(2);
|
||||||
|
expect(v.extras.freeUnder5).toBe(1);
|
||||||
|
expect(v.ages.find((a) => a.bracket === "Kids 0-4")?.free).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { COL } from "../fields.js";
|
||||||
describe("redeem", () => {
|
describe("redeem", () => {
|
||||||
it("checks in a single walk-up (default count 1)", async () => {
|
it("checks in a single walk-up (default count 1)", async () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
await seedTicket(db, { code: "BC26-AAAA-1111", ages: { "Ages 26-45": 4 } });
|
await seedTicket(db, { code: "BC26-AAAA-1111", adults: 4 });
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
const r = await redeem(ctx, "BC26-AAAA-1111", 1);
|
const r = await redeem(ctx, "BC26-AAAA-1111", 1);
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
|
|
@ -20,7 +20,7 @@ describe("redeem", () => {
|
||||||
it("supports group check-in and QR reuse across visits", async () => {
|
it("supports group check-in and QR reuse across visits", async () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
// Party of 7 (2 free under-4 not counted): total 5.
|
// Party of 7 (2 free under-4 not counted): total 5.
|
||||||
await seedTicket(db, { code: "BC26-FAM-0001", ages: { "Ages 0-3": 2, "Ages 26-45": 2, "Ages 8-12": 3 } });
|
await seedTicket(db, { code: "BC26-FAM-0001", adults: 5 });
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
|
|
||||||
const first = await redeem(ctx, "BC26-FAM-0001", 2); // father + son
|
const first = await redeem(ctx, "BC26-FAM-0001", 2); // father + son
|
||||||
|
|
@ -36,7 +36,7 @@ describe("redeem", () => {
|
||||||
|
|
||||||
it("rejects over-redemption without mutating", async () => {
|
it("rejects over-redemption without mutating", async () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
await seedTicket(db, { code: "BC26-BBBB-2222", ages: { "Ages 26-45": 2 } });
|
await seedTicket(db, { code: "BC26-BBBB-2222", adults: 2 });
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
const r = await redeem(ctx, "BC26-BBBB-2222", 5);
|
const r = await redeem(ctx, "BC26-BBBB-2222", 5);
|
||||||
expect(r.ok).toBe(false);
|
expect(r.ok).toBe(false);
|
||||||
|
|
@ -46,7 +46,7 @@ describe("redeem", () => {
|
||||||
|
|
||||||
it("allows negative count to undo, clamped at zero", async () => {
|
it("allows negative count to undo, clamped at zero", async () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
await seedTicket(db, { code: "BC26-CCCC-3333", ages: { "Ages 26-45": 3 }, redeemed: 2 });
|
await seedTicket(db, { code: "BC26-CCCC-3333", adults: 3, redeemed: 2 });
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
const r = await redeem(ctx, "BC26-CCCC-3333", -5);
|
const r = await redeem(ctx, "BC26-CCCC-3333", -5);
|
||||||
expect(r.ok).toBe(true);
|
expect(r.ok).toBe(true);
|
||||||
|
|
@ -55,7 +55,7 @@ describe("redeem", () => {
|
||||||
|
|
||||||
it("writes an audit entry on each successful check-in and undo", async () => {
|
it("writes an audit entry on each successful check-in and undo", async () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
await seedTicket(db, { code: "BC26-AUDT-0001", ages: { "Ages 26-45": 4 } });
|
await seedTicket(db, { code: "BC26-AUDT-0001", adults: 4 });
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
await redeem(ctx, "BC26-AUDT-0001", 2);
|
await redeem(ctx, "BC26-AUDT-0001", 2);
|
||||||
await redeem(ctx, "BC26-AUDT-0001", -1);
|
await redeem(ctx, "BC26-AUDT-0001", -1);
|
||||||
|
|
@ -67,7 +67,7 @@ describe("redeem", () => {
|
||||||
|
|
||||||
it("does not audit a no-op (undo when nothing redeemed)", async () => {
|
it("does not audit a no-op (undo when nothing redeemed)", async () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
await seedTicket(db, { code: "BC26-AUDT-0002", ages: { "Ages 26-45": 3 }, redeemed: 0 });
|
await seedTicket(db, { code: "BC26-AUDT-0002", adults: 3, redeemed: 0 });
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
await redeem(ctx, "BC26-AUDT-0002", -2); // clamps to 0, delta 0
|
await redeem(ctx, "BC26-AUDT-0002", -2); // clamps to 0, delta 0
|
||||||
expect((ctx.audit as any).entries).toHaveLength(0);
|
expect((ctx.audit as any).entries).toHaveLength(0);
|
||||||
|
|
@ -77,7 +77,7 @@ describe("redeem", () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
await seedTicket(db, {
|
await seedTicket(db, {
|
||||||
code: "BC26-ICE-0003",
|
code: "BC26-ICE-0003",
|
||||||
ages: { "Ages 26-45": 2 },
|
adults: 2,
|
||||||
});
|
});
|
||||||
// Give the ticket 3 prepaid ice bags.
|
// Give the ticket 3 prepaid ice bags.
|
||||||
db.rows[0]["Ice Total"] = 3;
|
db.rows[0]["Ice Total"] = 3;
|
||||||
|
|
@ -112,7 +112,7 @@ describe("redeem", () => {
|
||||||
|
|
||||||
it("surfaces db_error when the update fails", async () => {
|
it("surfaces db_error when the update fails", async () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
await seedTicket(db, { code: "BC26-DDDD-4444", ages: { "Ages 26-45": 3 } });
|
await seedTicket(db, { code: "BC26-DDDD-4444", adults: 3 });
|
||||||
db.failNext = true;
|
db.failNext = true;
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
const r = await redeem(ctx, "BC26-DDDD-4444", 1);
|
const r = await redeem(ctx, "BC26-DDDD-4444", 1);
|
||||||
|
|
@ -122,7 +122,7 @@ describe("redeem", () => {
|
||||||
|
|
||||||
it("CONCURRENCY: 20 parallel single check-ins on a 5-ticket code yield exactly 5", async () => {
|
it("CONCURRENCY: 20 parallel single check-ins on a 5-ticket code yield exactly 5", async () => {
|
||||||
const db = new FakeNocoDB(8);
|
const db = new FakeNocoDB(8);
|
||||||
await seedTicket(db, { code: "BC26-RACE-0005", ages: { "Ages 26-45": 5 } });
|
await seedTicket(db, { code: "BC26-RACE-0005", adults: 5 });
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
|
|
||||||
const results = await Promise.all(
|
const results = await Promise.all(
|
||||||
|
|
@ -137,7 +137,7 @@ describe("redeem", () => {
|
||||||
describe("lookupByCode", () => {
|
describe("lookupByCode", () => {
|
||||||
it("returns the ticket view without mutating", async () => {
|
it("returns the ticket view without mutating", async () => {
|
||||||
const db = new FakeNocoDB();
|
const db = new FakeNocoDB();
|
||||||
await seedTicket(db, { code: "BC26-LOOK-0001", ages: { "Ages 26-45": 3 } });
|
await seedTicket(db, { code: "BC26-LOOK-0001", adults: 3 });
|
||||||
const ctx = fakeContext(db);
|
const ctx = fakeContext(db);
|
||||||
const r = await lookupByCode(ctx, "BC26-LOOK-0001");
|
const r = await lookupByCode(ctx, "BC26-LOOK-0001");
|
||||||
expect(r.ok && r.found && r.ticket.remaining).toBe(3);
|
expect(r.ok && r.found && r.ticket.remaining).toBe(3);
|
||||||
|
|
@ -159,7 +159,7 @@ describe("createTicket idempotency", () => {
|
||||||
const input = {
|
const input = {
|
||||||
name: "Jane Bear",
|
name: "Jane Bear",
|
||||||
email: "jane@example.com",
|
email: "jane@example.com",
|
||||||
ages: { "Ages 26-45": 2 },
|
counts: { adults: 2, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
|
||||||
submissionKey: "sub:412",
|
submissionKey: "sub:412",
|
||||||
};
|
};
|
||||||
const a = await createTicket(ctx, input);
|
const a = await createTicket(ctx, input);
|
||||||
|
|
|
||||||
|
|
@ -128,15 +128,19 @@ export async function search(ctx: AppContext, query: string): Promise<TicketView
|
||||||
|
|
||||||
export interface WebhookInput {
|
export interface WebhookInput {
|
||||||
name: string;
|
name: string;
|
||||||
|
adultNames?: string[];
|
||||||
email: string;
|
email: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
isDonor?: boolean;
|
isDonor?: boolean;
|
||||||
|
donorTier?: string;
|
||||||
|
vouchers?: number;
|
||||||
|
counts: { adults: number; youth: number; kids12: number; kids9: number; kids4: number };
|
||||||
carParking?: boolean;
|
carParking?: boolean;
|
||||||
rvParking?: boolean;
|
rvParking?: boolean;
|
||||||
|
utv?: boolean;
|
||||||
iceAccess?: boolean;
|
iceAccess?: boolean;
|
||||||
iceBags?: number; // prepaid ice bags
|
iceBags?: number; // prepaid ice bags/tickets
|
||||||
paymentMethod?: string;
|
paymentMethod?: string;
|
||||||
ages: Record<string, number>; // NocoDB age-column title -> count
|
|
||||||
submissionKey: string;
|
submissionKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -158,20 +162,29 @@ export async function createTicket(
|
||||||
code = generateCode();
|
code = generateCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const c = input.counts;
|
||||||
const fields: Record<string, unknown> = {
|
const fields: Record<string, unknown> = {
|
||||||
[COL.name]: input.name,
|
[COL.name]: input.name,
|
||||||
[COL.email]: input.email,
|
[COL.email]: input.email,
|
||||||
[COL.code]: code,
|
[COL.code]: code,
|
||||||
[COL.redeemed]: 0,
|
[COL.redeemed]: 0,
|
||||||
|
[COL.adults]: c.adults,
|
||||||
|
[COL.youth]: c.youth,
|
||||||
|
[COL.kids12]: c.kids12,
|
||||||
|
[COL.kids9]: c.kids9,
|
||||||
|
[COL.kids4]: c.kids4,
|
||||||
[COL.iceTotal]: input.iceBags ?? 0,
|
[COL.iceTotal]: input.iceBags ?? 0,
|
||||||
[COL.iceRedeemed]: 0,
|
[COL.iceRedeemed]: 0,
|
||||||
[COL.submissionKey]: input.submissionKey,
|
[COL.submissionKey]: input.submissionKey,
|
||||||
...input.ages,
|
|
||||||
};
|
};
|
||||||
|
if (input.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n");
|
||||||
if (input.address !== undefined) fields[COL.address] = input.address;
|
if (input.address !== undefined) fields[COL.address] = input.address;
|
||||||
if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor;
|
if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor;
|
||||||
|
if (input.donorTier !== undefined) fields[COL.donorTier] = input.donorTier;
|
||||||
|
if (input.vouchers !== undefined) fields[COL.vouchers] = input.vouchers;
|
||||||
if (input.carParking !== undefined) fields[COL.carParking] = input.carParking;
|
if (input.carParking !== undefined) fields[COL.carParking] = input.carParking;
|
||||||
if (input.rvParking !== undefined) fields[COL.rvParking] = input.rvParking;
|
if (input.rvParking !== undefined) fields[COL.rvParking] = input.rvParking;
|
||||||
|
if (input.utv !== undefined) fields[COL.utv] = input.utv;
|
||||||
if (input.iceAccess !== undefined) fields[COL.iceAccess] = input.iceAccess;
|
if (input.iceAccess !== undefined) fields[COL.iceAccess] = input.iceAccess;
|
||||||
if (input.paymentMethod !== undefined) fields[COL.paymentMethod] = input.paymentMethod;
|
if (input.paymentMethod !== undefined) fields[COL.paymentMethod] = input.paymentMethod;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue