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
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue