Add /test page with sample QR codes for scanner testing
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 <noreply@anthropic.com>
This commit is contained in:
parent
8d6dd56a1a
commit
b71dce0878
6 changed files with 184 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
161
backend/src/routes/test.ts
Normal file
161
backend/src/routes/test.ts
Normal file
|
|
@ -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<string, number>;
|
||||
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<void> {
|
||||
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) => `
|
||||
<div class="card">
|
||||
<img src="${c.qr}" alt="QR for ${esc(c.code)}" />
|
||||
<div class="meta">
|
||||
<div class="name">${esc(c.name)}</div>
|
||||
<div class="code">${esc(c.code)}</div>
|
||||
<div class="blurb">${esc(c.blurb)}</div>
|
||||
</div>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Camp Scan — Test QR codes</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
body { margin:0; background:#0f1a12; color:#eaf2ec; font-family:-apple-system,Segoe UI,Roboto,Arial,sans-serif; }
|
||||
header { padding:20px 16px; background:#1b5e20; }
|
||||
h1 { margin:0; font-size:20px; }
|
||||
header p { margin:6px 0 0; font-size:14px; color:#cfe6d4; }
|
||||
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:16px; padding:16px; }
|
||||
.card { background:#16241a; border:1px solid #24382a; border-radius:14px; padding:16px; display:flex; gap:14px; align-items:center; }
|
||||
.card img { width:120px; height:120px; background:#fff; border-radius:8px; padding:6px; flex:none; }
|
||||
.name { font-weight:700; font-size:16px; }
|
||||
.code { font-family:ui-monospace,Menlo,monospace; color:#9db3a4; font-size:13px; letter-spacing:1px; margin:2px 0 6px; }
|
||||
.blurb { font-size:13px; color:#c4d6c9; line-height:1.4; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>🐻 Camp Scan — test tickets</h1>
|
||||
<p>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.</p>
|
||||
</header>
|
||||
<div class="grid">${items}</div>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -9,3 +9,8 @@ export async function renderQrPng(code: string): Promise<Buffer> {
|
|||
margin: 2,
|
||||
});
|
||||
}
|
||||
|
||||
/** Render a ticket code to a data: URI PNG (for inline <img> in HTML). */
|
||||
export async function renderQrDataUrl(code: string): Promise<string> {
|
||||
return QRCode.toDataURL(code, { errorCorrectionLevel: "M", width: 320, margin: 2 });
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue