From b71dce0878317d9ccdc659b72c772ff926d24bb9 Mon Sep 17 00:00:00 2001 From: Hank Date: Wed, 8 Jul 2026 04:47:47 +0000 Subject: [PATCH] Add /test page with sample QR codes for scanner testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /test (gated by ENABLE_TEST_PAGE) renders scannable QR codes for a set of personas covering different attributes — solo, family with ice+parking, a real donor for Banquet mode, ice-only, a pre-exhausted ticket, and an invalid code. Idempotently seeds them into the current NocoDB table. Co-Authored-By: Claude Fable 5 --- .env.example | 4 + README.md | 5 + backend/src/config.ts | 7 ++ backend/src/routes/test.ts | 161 +++++++++++++++++++++++++++++++++ backend/src/server.ts | 2 + backend/src/services/qrcode.ts | 5 + 6 files changed, 184 insertions(+) create mode 100644 backend/src/routes/test.ts diff --git a/.env.example b/.env.example index d52597f..c52cdc0 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,10 @@ NOCODB_DONOR_OFFLINE_TABLE_ID= # Ice bags granted when a purchase includes ice but the webhook sends only a boolean ICE_BAGS_DEFAULT=3 +# Serve /test with sample QR codes (seeds test personas into the CURRENT table). +# Keep this false/empty in production — only enable when pointed at a TEST table. +ENABLE_TEST_PAGE=false + # MailerSend MAILERSEND_API_TOKEN= MAIL_FROM_EMAIL=tickets@beartariacampgrounds.com diff --git a/README.md b/README.md index 0f2ee50..a3b8964 100644 --- a/README.md +++ b/README.md @@ -198,5 +198,10 @@ A Forgejo Actions runner builds a signed APK whenever a `vX.Y.Z` tag is pushed. | `GET /api/audit?code=&limit=` | Recent check-in log (all, or one code) | | `POST /api/tickets/{code}/resend-email` | Re-send the QR email | | `GET /api/health` | Health + NocoDB probe | +| `GET /test` | Sample QR codes for testing (only when `ENABLE_TEST_PAGE=true`) | + +### Test page + +With `ENABLE_TEST_PAGE=true`, `GET /test` renders a page of scannable QR codes covering different attribute combinations (solo, family with ice + parking, a real donor for Banquet mode, ice-only, and a pre-exhausted ticket, plus an invalid code). It idempotently seeds these personas into the **current** NocoDB table, so only enable it against a TEST table — never in production. Concurrency is safe within the single instance: redeem operations serialize per ticket code, so two gates scanning the same code can't over-redeem. **Do not scale the service to multiple replicas** — the serialization is in-process. diff --git a/backend/src/config.ts b/backend/src/config.ts index d0b790e..3b98337 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -21,6 +21,13 @@ const schema = z.object({ // sends a boolean (not an explicit bag count). ICE_BAGS_DEFAULT: z.coerce.number().default(3), + // Serve GET /test with sample QR codes. Seeds test personas into the current + // NocoDB table, so keep this OFF in production (only enable against a TEST table). + ENABLE_TEST_PAGE: z + .string() + .optional() + .transform((v) => v === "true" || v === "1"), + MAILERSEND_API_TOKEN: z.string().min(1), MAIL_FROM_EMAIL: z.string().email(), MAIL_FROM_NAME: z.string().default("Beartaria Campgrounds"), diff --git a/backend/src/routes/test.ts b/backend/src/routes/test.ts new file mode 100644 index 0000000..3d4a680 --- /dev/null +++ b/backend/src/routes/test.ts @@ -0,0 +1,161 @@ +import type { FastifyInstance } from "fastify"; +import { createTicket } from "../ticketService.js"; +import { renderQrDataUrl } from "../services/qrcode.js"; +import { COL } from "../fields.js"; + +interface Persona { + key: string; + name: string; + email: string; + ages: Record; + iceBags?: number; + carParking?: boolean; + rvParking?: boolean; + isDonor?: boolean; + exhaust?: boolean; // pre-redeem all tickets so it scans as "exhausted" + blurb: string; +} + +// 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 }, + 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 }, + 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.", + }, + { + key: "donor", + name: "Adam Stevens (real donor)", + email: "adam21stevens@gmail.com", + ages: { "Ages 26-45": 2 }, + rvParking: true, + isDonor: true, + blurb: "2 tickets, RV parking. Banquet mode → shows real donation total ($801).", + }, + { + 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.", + }, + { + key: "exhausted", + name: "Done Dora", + email: "done@test.beartaria", + ages: { "Ages 26-45": 2 }, + exhaust: true, + blurb: "2 tickets, already fully redeemed. Check-in mode → red 'exhausted'.", + }, +]; + +const INVALID_CODE = "BC26-0000-0000"; // not in the DB → scans as "not found" + +export async function testRoutes(app: FastifyInstance): Promise { + if (!app.ctx.config.ENABLE_TEST_PAGE) return; + + app.get("/test", async (_req, reply) => { + const cards: { code: string; qr: string; name: string; blurb: string }[] = []; + + for (const p of PERSONAS) { + const result = await createTicket(app.ctx, { + name: p.name, + email: p.email, + ages: p.ages, + iceBags: p.iceBags, + carParking: p.carParking, + rvParking: p.rvParking, + isDonor: p.isDonor, + 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). + 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 }); + } + cards.push({ + code: result.code, + qr: await renderQrDataUrl(result.code), + name: p.name, + blurb: p.blurb, + }); + } + + // An invalid one to test the not-found path. + cards.push({ + code: INVALID_CODE, + qr: await renderQrDataUrl(INVALID_CODE), + name: "Not a real ticket", + blurb: "Any mode → red 'not a valid ticket'.", + }); + + reply.type("text/html").send(renderPage(cards)); + }); +} + +function esc(s: string): string { + return String(s).replace(/[&<>"]/g, (c) => + c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """, + ); +} + +function renderPage(cards: { code: string; qr: string; name: string; blurb: string }[]): string { + const items = cards + .map( + (c) => ` +
+ QR for ${esc(c.code)} +
+
${esc(c.name)}
+
${esc(c.code)}
+
${esc(c.blurb)}
+
+
`, + ) + .join(""); + return ` + + + + +Camp Scan — Test QR codes + + + +
+

🐻 Camp Scan — test tickets

+

Open the app on another device and scan these. Switch modes (Check-in / Ice / Banquet) to test each. Reload to re-seed if a record was deleted.

+
+
${items}
+ +`; +} diff --git a/backend/src/server.ts b/backend/src/server.ts index 4bfdf1b..cd5f362 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -10,6 +10,7 @@ import { buildContext } from "./context.js"; import { authRoutes } from "./routes/auth.js"; import { webhookRoutes } from "./routes/webhook.js"; import { ticketRoutes } from "./routes/tickets.js"; +import { testRoutes } from "./routes/test.js"; export async function build() { const config = loadConfig(); @@ -28,6 +29,7 @@ export async function build() { await app.register(authRoutes); await app.register(webhookRoutes); await app.register(ticketRoutes); + await app.register(testRoutes); // Serve the exported Expo web build (if present) with SPA fallback. const webDir = config.WEB_DIR ?? join(process.cwd(), "web"); diff --git a/backend/src/services/qrcode.ts b/backend/src/services/qrcode.ts index 69d0522..774a618 100644 --- a/backend/src/services/qrcode.ts +++ b/backend/src/services/qrcode.ts @@ -9,3 +9,8 @@ export async function renderQrPng(code: string): Promise { margin: 2, }); } + +/** Render a ticket code to a data: URI PNG (for inline in HTML). */ +export async function renderQrDataUrl(code: string): Promise { + return QRCode.toDataURL(code, { errorCorrectionLevel: "M", width: 320, margin: 2 }); +}