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:
Hank 2026-07-13 03:23:06 +00:00
parent 718d1515b0
commit b7c77afe02
14 changed files with 360 additions and 210 deletions

View file

@ -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,
});
}

View file

@ -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);
});
});

View file

@ -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);