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_OFFLINE_TABLE_ID=
|
||||
|
||||
# Ice bags granted when a purchase includes ice but the webhook sends only a boolean
|
||||
ICE_BAGS_DEFAULT=3
|
||||
# Ice: form sells 1-4 ice tickets at $20 each; one ticket = 3 bags. The webhook
|
||||
# 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
|
||||
# VOUCHER_SINCE totalling >= TIER1 earn 1 voucher, >= TIER2 earn 2. Bump the
|
||||
|
|
@ -33,7 +35,7 @@ ENABLE_TEST_PAGE=false
|
|||
|
||||
# MailerSend
|
||||
MAILERSEND_API_TOKEN=
|
||||
MAIL_FROM_EMAIL=tickets@beartariacampgrounds.com
|
||||
MAIL_FROM_EMAIL=info@beartariacampgrounds.com
|
||||
MAIL_FROM_NAME=Beartaria Campgrounds
|
||||
|
||||
# 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]);
|
||||
|
||||
const tags: string[] = [];
|
||||
if (ticket.extras.carParking) tags.push("🚗 Car");
|
||||
if (ticket.extras.rvParking) tags.push("🚐 RV");
|
||||
if (ticket.extras.iceAccess) tags.push("🧊 Ice");
|
||||
if (ticket.extras.isDonor) tags.push("⭐ Donor");
|
||||
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} free`);
|
||||
const e = ticket.extras;
|
||||
if (e.donorTier === "member") tags.push("🐻 Member");
|
||||
else if (e.isDonor) tags.push("⭐ Donor");
|
||||
if (e.carParking) tags.push("🚗 Car");
|
||||
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 (
|
||||
<View style={styles.card}>
|
||||
|
|
|
|||
|
|
@ -259,6 +259,7 @@ export default function ScannerScreen() {
|
|||
<Text style={styles.counts}>
|
||||
{ticket.redeemed} of {ticket.total} redeemed · {ticket.remaining} remaining
|
||||
</Text>
|
||||
<AdultNames names={ticket.adultNames} />
|
||||
<ExtrasRow ticket={ticket} />
|
||||
</>
|
||||
)}
|
||||
|
|
@ -345,11 +346,14 @@ function BanquetResult({ donor, ticketName }: { donor: DonorLookup | null; ticke
|
|||
|
||||
function ExtrasRow({ ticket }: { ticket: TicketView }) {
|
||||
const tags: string[] = [];
|
||||
if (ticket.extras.carParking) tags.push("🚗 Car parking");
|
||||
if (ticket.extras.rvParking) tags.push("🚐 RV parking");
|
||||
if (ticket.extras.iceAccess || ticket.ice.total > 0) tags.push(`🧊 ${ticket.ice.remaining}/${ticket.ice.total} ice`);
|
||||
if (ticket.extras.isDonor) tags.push("⭐ Donor");
|
||||
if (ticket.extras.freeUnder4 > 0) tags.push(`👶 ${ticket.extras.freeUnder4} under 4 (free)`);
|
||||
const e = ticket.extras;
|
||||
if (e.donorTier === "member") tags.push("🐻 Member");
|
||||
else if (e.isDonor) tags.push("⭐ Donor");
|
||||
if (e.carParking) tags.push("🚗 Car parking");
|
||||
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;
|
||||
return (
|
||||
<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({
|
||||
ticket,
|
||||
isIce,
|
||||
|
|
@ -393,6 +410,7 @@ function ConfirmCard({
|
|||
<Text style={{ color: theme.successBright, fontWeight: "800" }}>{remaining}</Text> of {total} {unit} remaining
|
||||
</Text>
|
||||
<Text style={styles.cardSub}>{redeemed} already redeemed</Text>
|
||||
{!isIce && <AdultNames names={ticket.adultNames} />}
|
||||
{!isIce && <ExtrasRow ticket={ticket} />}
|
||||
{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 },
|
||||
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 },
|
||||
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;
|
||||
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 }[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@ const schema = z.object({
|
|||
NOCODB_DONOR_ONLINE_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
|
||||
// sends a boolean (not an explicit bag count).
|
||||
ICE_BAGS_DEFAULT: z.coerce.number().default(3),
|
||||
// Ice: the form sells 1-4 ice "tickets" at $20 each; one ticket = 3 bags.
|
||||
// The webhook reads payment_ice as either a ticket count (1-4) or a dollar
|
||||
// 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).
|
||||
// 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
|
||||
* 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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,57 +7,68 @@ interface Persona {
|
|||
key: string;
|
||||
name: string;
|
||||
email: string;
|
||||
ages: Record<string, number>;
|
||||
adultNames?: string[];
|
||||
counts: { adults: number; youth: number; kids12: number; kids9: number; kids4: number };
|
||||
iceBags?: number;
|
||||
carParking?: boolean;
|
||||
rvParking?: boolean;
|
||||
utv?: boolean;
|
||||
isDonor?: boolean;
|
||||
donorTier?: string;
|
||||
exhaust?: boolean; // pre-redeem all tickets so it scans as "exhausted"
|
||||
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.
|
||||
const PERSONAS: Persona[] = [
|
||||
{
|
||||
key: "solo",
|
||||
name: "Solo Sam",
|
||||
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.",
|
||||
},
|
||||
{
|
||||
key: "family",
|
||||
name: "Family Fay",
|
||||
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,
|
||||
carParking: true,
|
||||
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",
|
||||
name: "Donor Dan",
|
||||
email: "donor@example.test",
|
||||
ages: { "Ages 26-45": 2 },
|
||||
adultNames: ["Donor Dan", "Donna Dan"],
|
||||
counts: C(2),
|
||||
rvParking: true,
|
||||
utv: true,
|
||||
isDonor: true,
|
||||
donorTier: "member",
|
||||
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",
|
||||
name: "Ice Ike",
|
||||
email: "ice@test.beartaria",
|
||||
ages: { "Ages 18-25": 1 },
|
||||
iceBags: 3,
|
||||
blurb: "1 ticket + 3 ice bags. Ice mode → grab all 3 at once, then scan again → exhausted.",
|
||||
adultNames: ["Ice Ike"],
|
||||
counts: C(1),
|
||||
iceBags: 6, // 2 ice tickets
|
||||
blurb: "1 ticket + 6 ice bags (2 ice tickets). Ice mode → grab bags, then scan again → exhausted.",
|
||||
},
|
||||
{
|
||||
key: "exhausted",
|
||||
name: "Done Dora",
|
||||
email: "done@test.beartaria",
|
||||
ages: { "Ages 26-45": 2 },
|
||||
counts: C(2),
|
||||
exhaust: true,
|
||||
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) {
|
||||
const result = await createTicket(app.ctx, {
|
||||
name: p.name,
|
||||
adultNames: p.adultNames,
|
||||
email: p.email,
|
||||
ages: p.ages,
|
||||
counts: p.counts,
|
||||
iceBags: p.iceBags,
|
||||
carParking: p.carParking,
|
||||
rvParking: p.rvParking,
|
||||
utv: p.utv,
|
||||
isDonor: p.isDonor,
|
||||
donorTier: p.donorTier,
|
||||
submissionKey: `test:${p.key}`,
|
||||
});
|
||||
// Keep the "exhausted" persona fully redeemed on every load so its state
|
||||
// is deterministic (compute the total from the persona's own age counts,
|
||||
// since NocoDB's create response may not echo them back).
|
||||
// is deterministic (total = scannable count from the persona's counts).
|
||||
if (p.exhaust) {
|
||||
const total = Object.entries(p.ages)
|
||||
.filter(([col]) => col !== "Ages 0-3")
|
||||
.reduce((s, [, n]) => s + n, 0);
|
||||
await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: total });
|
||||
const { adults, youth, kids12, kids9 } = p.counts;
|
||||
await app.ctx.nocodb.update(result.record.Id, { [COL.redeemed]: adults + youth + kids12 + kids9 });
|
||||
}
|
||||
cards.push({
|
||||
code: result.code,
|
||||
|
|
|
|||
|
|
@ -167,11 +167,16 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
|
|||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["name", "ages"],
|
||||
required: ["name"],
|
||||
properties: {
|
||||
name: { type: "string", minLength: 1 },
|
||||
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" },
|
||||
},
|
||||
},
|
||||
|
|
@ -182,8 +187,15 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
|
|||
const submissionKey = `manual:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`;
|
||||
const result = await createTicket(app.ctx, {
|
||||
name: b.name,
|
||||
adultNames: b.adultNames,
|
||||
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,
|
||||
});
|
||||
if (b.sendEmail && b.email && !app.ctx.mailer.isBlockedRecipient(b.email)) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { createHash, timingSafeEqual } from "node:crypto";
|
||||
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 { renderQrPng } from "../services/qrcode.js";
|
||||
|
||||
|
|
@ -11,18 +11,48 @@ function safeEqual(a: string, b: string): boolean {
|
|||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
// Map webhook payload keys -> NocoDB age-column titles. Keys are what you map
|
||||
// the FluentForms fields to in the webhook feed.
|
||||
const AGE_KEY_TO_COL: Record<string, string> = {
|
||||
ages_0_3: "Ages 0-3",
|
||||
ages_4_7: "Ages 4-7",
|
||||
ages_8_12: "Ages 8-12",
|
||||
ages_13_17: "Ages 13-17",
|
||||
ages_18_25: "Ages 18-25",
|
||||
ages_26_45: "Ages 26-45",
|
||||
ages_46_64: "Ages 46-64",
|
||||
ages_65: "Ages 65+",
|
||||
};
|
||||
/** Read a FluentForms compound name field, given as a nested object
|
||||
* (`names: {first_name,...}`) or flattened bracket keys (`names[first_name]`). */
|
||||
function nameGroup(body: Record<string, any>, base: string): string {
|
||||
const obj = body[base];
|
||||
let first: any, middle: any, last: any;
|
||||
if (obj && typeof obj === "object") {
|
||||
({ first_name: first, middle_name: middle, last_name: last } = obj);
|
||||
} else {
|
||||
first = body[`${base}[first_name]`];
|
||||
middle = body[`${base}[middle_name]`];
|
||||
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> {
|
||||
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" });
|
||||
}
|
||||
|
||||
const body = (req.body ?? {}) as Record<string, unknown>;
|
||||
const name = String(body.name ?? "").trim();
|
||||
const body = (req.body ?? {}) as Record<string, any>;
|
||||
|
||||
// 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();
|
||||
if (!name || !email) {
|
||||
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" });
|
||||
if (!name) {
|
||||
return reply.code(400).send({ error: "missing_fields", detail: "purchaser name is required" });
|
||||
}
|
||||
|
||||
// Build age-bracket counts from whichever keys were provided.
|
||||
const ages: Record<string, number> = {};
|
||||
for (const [key, col] of Object.entries(AGE_KEY_TO_COL)) {
|
||||
if (body[key] !== undefined && body[key] !== null && body[key] !== "") {
|
||||
ages[col] = toNumber(body[key]);
|
||||
}
|
||||
}
|
||||
const anyAge = AGE_COLUMNS.some((c) => (ages[c] ?? 0) > 0);
|
||||
if (!anyAge) {
|
||||
return reply.code(400).send({ error: "no_tickets", detail: "no age-bracket counts provided" });
|
||||
// Adult attendee names (non-empty groups, in order).
|
||||
const adultNames = ADULT_NAME_BASES.map((b) => nameGroup(body, b)).filter(Boolean);
|
||||
|
||||
// Attendee counts.
|
||||
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),
|
||||
kids12: qty(body.item_quantity_kids_12),
|
||||
kids9: qty(body.item_quantity_kids_9),
|
||||
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.
|
||||
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id;
|
||||
// Donor info (hidden fields from the eligibility/voucher lookups) + radio.
|
||||
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
|
||||
? `sub:${String(submissionId)}`
|
||||
: "hash:" +
|
||||
createHash("sha256")
|
||||
.update(`${email}|${name}|${JSON.stringify(ages)}`)
|
||||
.update(`${email}|${name}|${JSON.stringify(counts)}`)
|
||||
.digest("hex")
|
||||
.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>>;
|
||||
try {
|
||||
result = await createTicket(app.ctx, {
|
||||
name,
|
||||
adultNames,
|
||||
email,
|
||||
address: body.address !== undefined ? String(body.address) : undefined,
|
||||
isDonor: body.is_donor !== undefined ? toBool(body.is_donor) : undefined,
|
||||
carParking: body.car_parking !== undefined ? toBool(body.car_parking) : undefined,
|
||||
rvParking: body.rv_parking !== undefined ? toBool(body.rv_parking) : undefined,
|
||||
iceAccess: body.ice_access !== undefined ? toBool(body.ice_access) : undefined,
|
||||
address,
|
||||
isDonor,
|
||||
donorTier,
|
||||
vouchers,
|
||||
counts,
|
||||
carParking,
|
||||
rvParking,
|
||||
utv,
|
||||
iceAccess,
|
||||
iceBags,
|
||||
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
|
||||
ages,
|
||||
submissionKey,
|
||||
});
|
||||
} catch (e: any) {
|
||||
|
|
@ -93,9 +153,13 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
|
|||
return { status: "duplicate", code: result.code };
|
||||
}
|
||||
|
||||
// Send the ticket email. If it fails, the row already exists — report 502
|
||||
// so the failure is visible in FluentForms' delivery log; the ticket can be
|
||||
// re-sent later via POST /api/tickets/:code/resend-email.
|
||||
// Send the ticket QR email (FluentForms sends the receipt separately). If it
|
||||
// fails, the row already exists — report 502 so it's visible in the feed
|
||||
// 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)) {
|
||||
req.log.warn({ email }, "webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send");
|
||||
return { status: "created", code: result.code, emailSent: false, emailSkipped: "trial_restriction" };
|
||||
|
|
@ -103,13 +167,11 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
|
|||
|
||||
try {
|
||||
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({
|
||||
toEmail: email,
|
||||
toName: name,
|
||||
code: result.code,
|
||||
quantity,
|
||||
quantity: scannable,
|
||||
qrPng: qr,
|
||||
});
|
||||
} catch (e: any) {
|
||||
|
|
|
|||
|
|
@ -11,29 +11,26 @@ interface Field {
|
|||
}
|
||||
|
||||
const FIELDS: Field[] = [
|
||||
{ key: "name", req: "required", type: "text", desc: "Purchaser's full name." },
|
||||
{ key: "email", req: "required", type: "email", desc: "Purchaser's email — the QR ticket is sent here." },
|
||||
{
|
||||
key: "submission_id",
|
||||
req: "optional",
|
||||
type: "text/number",
|
||||
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: "ages_0_3", req: "optional", type: "number", desc: "Headcount ages 0–3. Admitted free — NOT counted toward redeemable tickets." },
|
||||
{ key: "ages_4_7", req: "optional", type: "number", desc: "Headcount ages 4–7." },
|
||||
{ key: "ages_8_12", req: "optional", type: "number", desc: "Headcount ages 8–12." },
|
||||
{ key: "ages_13_17", req: "optional", type: "number", desc: "Headcount ages 13–17." },
|
||||
{ key: "ages_18_25", req: "optional", type: "number", desc: "Headcount ages 18–25." },
|
||||
{ key: "ages_26_45", req: "optional", type: "number", desc: "Headcount ages 26–45." },
|
||||
{ key: "ages_46_64", req: "optional", type: "number", desc: "Headcount ages 46–64." },
|
||||
{ key: "ages_65", req: "optional", type: "number", desc: "Headcount ages 65+." },
|
||||
{ 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: "ice_access", req: "optional", type: "yes/no", desc: "Whether they bought ice access. Accepts 1/0, true/false, yes/no." },
|
||||
{ key: "car_parking", req: "optional", type: "yes/no", desc: "Car parking pass." },
|
||||
{ key: "rv_parking", req: "optional", type: "yes/no", desc: "RV parking pass." },
|
||||
{ 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." },
|
||||
{ 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: "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: "address_1", req: "optional", type: "address (compound)", desc: "Mailing address object; joined into one line." },
|
||||
{ key: "item_quantity_adult_ticket_reg", req: "required", type: "quantity", desc: "Adult tickets (regular)." },
|
||||
{ key: "item_quantity_adult_ticket_donor", req: "required", type: "quantity", desc: "Adult tickets (donor). Added to the regular adults." },
|
||||
{ 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: "item_quantity_kids_9", req: "optional", type: "quantity", desc: "Kids 5-9. Counts toward the scannable total." },
|
||||
{ key: "item_quantity_kids_4", req: "optional", type: "quantity", desc: "Kids 0-4. FREE — NOT counted toward the scannable ticket total." },
|
||||
{ key: "donor_tier", req: "optional", type: "hidden", desc: "member / donor / empty (from the donor-eligibility lookup)." },
|
||||
{ key: "donor_eligible", req: "optional", type: "hidden", desc: "true / false (from the donor-eligibility lookup)." },
|
||||
{ key: "vouchers", req: "optional", type: "hidden", desc: "Integer voucher count (from the ticket-voucher lookup)." },
|
||||
{ key: "input_radio", req: "optional", type: "choice", desc: "'Are you a campground donor?' — also used as a donor signal." },
|
||||
{ key: "payment_parking_reg / _donor", req: "optional", type: "payment", desc: "Car parking. Flagged if either variant is selected." },
|
||||
{ key: "payment_rv_reg / _donor", req: "optional", type: "payment", desc: "RV. Flagged if either variant is selected." },
|
||||
{ key: "payment_utv_reg / _donor", req: "optional", type: "payment", desc: "ATV/UTV. Flagged if either variant is selected." },
|
||||
{ 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: "payment_method", req: "optional", type: "payment", desc: "Payment method label." },
|
||||
{ key: "id / submission_id", req: "optional", type: "text", desc: "Entry/submission id for idempotency (retries won't duplicate). Falls back to a content hash." },
|
||||
];
|
||||
|
||||
function esc(s: string): string {
|
||||
|
|
@ -48,28 +45,34 @@ export async function webhookDocRoutes(app: FastifyInstance): Promise<void> {
|
|||
|
||||
const rows = FIELDS.map(
|
||||
(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>${f.type}</td>
|
||||
<td>${esc(f.type)}</td>
|
||||
<td>${esc(f.desc)}</td>
|
||||
</tr>`,
|
||||
).join("");
|
||||
|
||||
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",
|
||||
"submission_id": "12345",
|
||||
"ages_0_3": 2,
|
||||
"ages_8_12": 3,
|
||||
"ages_26_45": 2,
|
||||
"car_parking": "yes",
|
||||
"ice_access": "yes"
|
||||
"item_quantity_adult_ticket_reg": 2,
|
||||
"item_quantity_adult_ticket_donor": 0,
|
||||
"item_quantity_youth_ticket_reg": 1,
|
||||
"item_quantity_kids_9": 2,
|
||||
"item_quantity_kids_4": 2,
|
||||
"donor_tier": "member",
|
||||
"vouchers": 2,
|
||||
"payment_parking_reg": "$40.00",
|
||||
"payment_ice": 2,
|
||||
"payment_method": "stripe"
|
||||
}`);
|
||||
|
||||
const exampleCurl = esc(`curl -X POST ${WEBHOOK_URL} \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-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>
|
||||
<html lang="en">
|
||||
|
|
@ -82,7 +85,7 @@ const PAGE = `<!doctype html>
|
|||
:root { color-scheme: dark; }
|
||||
* { 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; }
|
||||
.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; }
|
||||
h2 { font-size: 20px; margin: 32px 0 10px; border-bottom: 1px solid #24382a; padding-bottom: 6px; }
|
||||
.sub { color: #9db3a4; margin: 0 0 8px; }
|
||||
|
|
@ -104,31 +107,32 @@ const PAGE = `<!doctype html>
|
|||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>🐻 Camp Scan — Purchase Webhook</h1>
|
||||
<p class="sub">How the FluentForms ticket checkout notifies the ticketing backend to create a ticket and email the QR code.</p>
|
||||
<h1>🐻 Camp Scan — Purchase Webhook (Tickets 2026)</h1>
|
||||
<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><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>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>
|
||||
|
||||
<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>
|
||||
<table>
|
||||
<thead><tr><th>Key</th><th>Required</th><th>Type</th><th>Description</th></tr></thead>
|
||||
<tbody>${rows}</tbody>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<pre><code>${exampleCurl}</code></pre>
|
||||
|
|
@ -139,7 +143,7 @@ const PAGE = `<!doctype html>
|
|||
<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":"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>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>
|
||||
|
|
@ -148,11 +152,10 @@ const PAGE = `<!doctype html>
|
|||
<h2>FluentForms setup</h2>
|
||||
<ol>
|
||||
<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 Method:</b> <code>POST</code> · <b>Format:</b> <code>JSON</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 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>Save, then submit a test purchase and confirm the QR email arrives.</li>
|
||||
<li><b>Request Body:</b> send <b>all fields</b> (the field names above are the FluentForms field keys).</li>
|
||||
<li>Save, submit a test purchase, and confirm the QR email arrives.</li>
|
||||
</ol>
|
||||
|
||||
<footer>Beartaria Campgrounds · scan.beartariacampgrounds.com</footer>
|
||||
|
|
|
|||
|
|
@ -86,14 +86,13 @@ export function fakeContext(db: FakeNocoDB): AppContext {
|
|||
|
||||
export async function seedTicket(
|
||||
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> {
|
||||
const ages = opts.ages ?? { "Ages 18-25": 2, "Ages 26-45": 3, "Ages 0-3": 1 };
|
||||
return db.create({
|
||||
[COL.code]: opts.code,
|
||||
[COL.name]: opts.name ?? "Test Bear",
|
||||
[COL.email]: opts.email ?? "test@example.com",
|
||||
[COL.adults]: opts.adults ?? 5,
|
||||
[COL.redeemed]: opts.redeemed ?? 0,
|
||||
...ages,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,45 +2,52 @@ import { describe, it, expect } from "vitest";
|
|||
import { computeTotal, toView, COL } from "../fields.js";
|
||||
|
||||
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 = {
|
||||
Id: 1,
|
||||
"Ages 0-3": 2, // free, not counted
|
||||
"Ages 4-7": 1,
|
||||
"Ages 18-25": 2,
|
||||
"Ages 26-45": 1,
|
||||
[COL.adults]: 2,
|
||||
[COL.youth]: 1,
|
||||
[COL.kids12]: 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", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toView", () => {
|
||||
it("derives remaining and surfaces extras", () => {
|
||||
it("derives remaining and surfaces adult names, donor tier, and extras", () => {
|
||||
const rec = {
|
||||
Id: 7,
|
||||
[COL.code]: "BC26-ABCD-2345",
|
||||
[COL.name]: "Jane Bear",
|
||||
[COL.email]: "jane@example.com",
|
||||
[COL.adultNames]: "Jane Bear\nJohn Bear",
|
||||
[COL.redeemed]: 2,
|
||||
[COL.adults]: 2,
|
||||
[COL.youth]: 3,
|
||||
[COL.kids4]: 1,
|
||||
[COL.carParking]: true,
|
||||
[COL.iceAccess]: "yes",
|
||||
"Ages 0-3": 1,
|
||||
"Ages 18-25": 2,
|
||||
"Ages 26-45": 3,
|
||||
[COL.donorTier]: "member",
|
||||
[COL.vouchers]: 2,
|
||||
};
|
||||
const v = toView(rec);
|
||||
expect(v.total).toBe(5);
|
||||
expect(v.redeemed).toBe(2);
|
||||
expect(v.remaining).toBe(3);
|
||||
expect(v.adultNames).toEqual(["Jane Bear", "John Bear"]);
|
||||
expect(v.extras.carParking).toBe(true);
|
||||
expect(v.extras.iceAccess).toBe(true);
|
||||
expect(v.extras.rvParking).toBe(false);
|
||||
expect(v.extras.freeUnder4).toBe(1);
|
||||
expect(v.ages.find((a) => a.bracket === "0-3")?.free).toBe(true);
|
||||
expect(v.extras.donorTier).toBe("member");
|
||||
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", () => {
|
||||
it("checks in a single walk-up (default count 1)", async () => {
|
||||
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 r = await redeem(ctx, "BC26-AAAA-1111", 1);
|
||||
expect(r.ok).toBe(true);
|
||||
|
|
@ -20,7 +20,7 @@ describe("redeem", () => {
|
|||
it("supports group check-in and QR reuse across visits", async () => {
|
||||
const db = new FakeNocoDB();
|
||||
// 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 first = await redeem(ctx, "BC26-FAM-0001", 2); // father + son
|
||||
|
|
@ -36,7 +36,7 @@ describe("redeem", () => {
|
|||
|
||||
it("rejects over-redemption without mutating", async () => {
|
||||
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 r = await redeem(ctx, "BC26-BBBB-2222", 5);
|
||||
expect(r.ok).toBe(false);
|
||||
|
|
@ -46,7 +46,7 @@ describe("redeem", () => {
|
|||
|
||||
it("allows negative count to undo, clamped at zero", async () => {
|
||||
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 r = await redeem(ctx, "BC26-CCCC-3333", -5);
|
||||
expect(r.ok).toBe(true);
|
||||
|
|
@ -55,7 +55,7 @@ describe("redeem", () => {
|
|||
|
||||
it("writes an audit entry on each successful check-in and undo", async () => {
|
||||
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);
|
||||
await redeem(ctx, "BC26-AUDT-0001", 2);
|
||||
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 () => {
|
||||
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);
|
||||
await redeem(ctx, "BC26-AUDT-0002", -2); // clamps to 0, delta 0
|
||||
expect((ctx.audit as any).entries).toHaveLength(0);
|
||||
|
|
@ -77,7 +77,7 @@ describe("redeem", () => {
|
|||
const db = new FakeNocoDB();
|
||||
await seedTicket(db, {
|
||||
code: "BC26-ICE-0003",
|
||||
ages: { "Ages 26-45": 2 },
|
||||
adults: 2,
|
||||
});
|
||||
// Give the ticket 3 prepaid ice bags.
|
||||
db.rows[0]["Ice Total"] = 3;
|
||||
|
|
@ -112,7 +112,7 @@ describe("redeem", () => {
|
|||
|
||||
it("surfaces db_error when the update fails", async () => {
|
||||
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;
|
||||
const ctx = fakeContext(db);
|
||||
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 () => {
|
||||
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 results = await Promise.all(
|
||||
|
|
@ -137,7 +137,7 @@ describe("redeem", () => {
|
|||
describe("lookupByCode", () => {
|
||||
it("returns the ticket view without mutating", async () => {
|
||||
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 r = await lookupByCode(ctx, "BC26-LOOK-0001");
|
||||
expect(r.ok && r.found && r.ticket.remaining).toBe(3);
|
||||
|
|
@ -159,7 +159,7 @@ describe("createTicket idempotency", () => {
|
|||
const input = {
|
||||
name: "Jane Bear",
|
||||
email: "jane@example.com",
|
||||
ages: { "Ages 26-45": 2 },
|
||||
counts: { adults: 2, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
|
||||
submissionKey: "sub:412",
|
||||
};
|
||||
const a = await createTicket(ctx, input);
|
||||
|
|
|
|||
|
|
@ -128,15 +128,19 @@ export async function search(ctx: AppContext, query: string): Promise<TicketView
|
|||
|
||||
export interface WebhookInput {
|
||||
name: string;
|
||||
adultNames?: string[];
|
||||
email: string;
|
||||
address?: string;
|
||||
isDonor?: boolean;
|
||||
donorTier?: string;
|
||||
vouchers?: number;
|
||||
counts: { adults: number; youth: number; kids12: number; kids9: number; kids4: number };
|
||||
carParking?: boolean;
|
||||
rvParking?: boolean;
|
||||
utv?: boolean;
|
||||
iceAccess?: boolean;
|
||||
iceBags?: number; // prepaid ice bags
|
||||
iceBags?: number; // prepaid ice bags/tickets
|
||||
paymentMethod?: string;
|
||||
ages: Record<string, number>; // NocoDB age-column title -> count
|
||||
submissionKey: string;
|
||||
}
|
||||
|
||||
|
|
@ -158,20 +162,29 @@ export async function createTicket(
|
|||
code = generateCode();
|
||||
}
|
||||
|
||||
const c = input.counts;
|
||||
const fields: Record<string, unknown> = {
|
||||
[COL.name]: input.name,
|
||||
[COL.email]: input.email,
|
||||
[COL.code]: code,
|
||||
[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.iceRedeemed]: 0,
|
||||
[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.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.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.paymentMethod !== undefined) fields[COL.paymentMethod] = input.paymentMethod;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue