Add /crush33 comp-ticket portal + ticket-type badge on scan
Some checks failed
Build Android APK / build-apk (push) Failing after 1h11m4s
Some checks failed
Build Android APK / build-apk (push) Failing after 1h11m4s
Portal (/crush33): password-gated page (PORTAL_PASSWORD) for admins to create entry-only tickets from just name + email, with a category (Guest/Worker/Performer/Volunteer/Speaker). Creates a 1-admission ticket, emails the QR, and shows the QR on-screen. New Ticket Type column. Scanner: shows a prominent TYPE badge (🎭 PERFORMER, 🛠️ WORKER, …) on the confirm + success screens and in admin, so staff can see it's a special ticket. Added Worker + Performer personas to /test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
b7c77afe02
commit
4b78c82b35
9 changed files with 230 additions and 0 deletions
|
|
@ -48,6 +48,8 @@ const schema = z.object({
|
|||
|
||||
WEBHOOK_SECRET: z.string().min(1),
|
||||
EVENT_PIN: z.string().min(1),
|
||||
// Shared password for the /crush33 comp-ticket portal (workers/guests).
|
||||
PORTAL_PASSWORD: z.string().optional(),
|
||||
TOKEN_SECRET: z.string().min(16),
|
||||
TOKEN_TTL: z.string().default("30d"),
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export const COL = {
|
|||
utv: "UTV",
|
||||
iceAccess: "Ice Access",
|
||||
paymentMethod: "Payment Method",
|
||||
ticketType: "Ticket Type", // "" for regular; Guest/Worker/Performer/Volunteer/Speaker for portal comps
|
||||
|
||||
// Columns this system manages:
|
||||
code: "Ticket Code",
|
||||
|
|
@ -94,6 +95,7 @@ export interface TicketView {
|
|||
code: string;
|
||||
name: string;
|
||||
email: string;
|
||||
ticketType: string; // "" for regular; Guest/Worker/... for special tickets
|
||||
total: number;
|
||||
redeemed: number;
|
||||
remaining: number;
|
||||
|
|
@ -121,6 +123,7 @@ export function toView(rec: NocoRecord): TicketView {
|
|||
code: String(rec[COL.code] ?? ""),
|
||||
name: String(rec[COL.name] ?? ""),
|
||||
email: String(rec[COL.email] ?? ""),
|
||||
ticketType: String(rec[COL.ticketType] ?? ""),
|
||||
total,
|
||||
redeemed,
|
||||
remaining: Math.max(0, total - redeemed),
|
||||
|
|
|
|||
170
backend/src/routes/portal.ts
Normal file
170
backend/src/routes/portal.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import { timingSafeEqual } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { createTicket } from "../ticketService.js";
|
||||
import { renderQrPng, renderQrDataUrl } from "../services/qrcode.js";
|
||||
|
||||
const TYPES = ["Guest", "Worker", "Performer", "Volunteer", "Speaker"];
|
||||
|
||||
function safeEqual(a: string, b: string): boolean {
|
||||
const ba = Buffer.from(a || "");
|
||||
const bb = Buffer.from(b || "");
|
||||
if (ba.length !== bb.length) return false;
|
||||
return timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
/**
|
||||
* /crush33 — password-gated comp-ticket portal for admins. Creates entry-only
|
||||
* tickets (1 admission, no demographics/ice) with a category (Guest/Worker/…)
|
||||
* that shows on the scanner. Password checked server-side per request.
|
||||
*/
|
||||
export async function portalRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get("/crush33", async (_req, reply) => {
|
||||
reply.type("text/html").send(PAGE);
|
||||
});
|
||||
|
||||
app.post(
|
||||
"/api/portal/create-ticket",
|
||||
{ config: { rateLimit: { max: 20, timeWindow: "1 minute" } } },
|
||||
async (req, reply) => {
|
||||
const cfg = app.ctx.config;
|
||||
if (!cfg.PORTAL_PASSWORD) return reply.code(404).send({ error: "portal_disabled" });
|
||||
|
||||
const b = (req.body ?? {}) as { password?: string; name?: string; email?: string; type?: string };
|
||||
if (!b.password || !safeEqual(b.password, cfg.PORTAL_PASSWORD)) {
|
||||
return reply.code(401).send({ error: "bad_password" });
|
||||
}
|
||||
const name = String(b.name ?? "").trim();
|
||||
const email = String(b.email ?? "").trim();
|
||||
const type = TYPES.includes(String(b.type)) ? String(b.type) : "Guest";
|
||||
if (!name || !email) {
|
||||
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" });
|
||||
}
|
||||
|
||||
let result: Awaited<ReturnType<typeof createTicket>>;
|
||||
try {
|
||||
result = await createTicket(app.ctx, {
|
||||
name,
|
||||
adultNames: [name],
|
||||
email,
|
||||
ticketType: type,
|
||||
counts: { adults: 1, youth: 0, kids12: 0, kids9: 0, kids4: 0 },
|
||||
submissionKey: `portal:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`,
|
||||
});
|
||||
} catch (e: any) {
|
||||
req.log.error({ err: e }, "portal: create failed");
|
||||
return reply.code(502).send({ error: "db_error", detail: e?.message });
|
||||
}
|
||||
|
||||
// Email the QR (best-effort — the portal also shows it on-screen).
|
||||
let emailSent = false;
|
||||
if (!app.ctx.mailer.isBlockedRecipient(email)) {
|
||||
try {
|
||||
const png = await renderQrPng(result.code);
|
||||
await app.ctx.mailer.sendTicket({ toEmail: email, toName: name, code: result.code, quantity: 1, qrPng: png });
|
||||
emailSent = true;
|
||||
} catch (e: any) {
|
||||
req.log.error({ err: e, code: result.code }, "portal: email failed");
|
||||
}
|
||||
}
|
||||
|
||||
const qr = await renderQrDataUrl(result.code);
|
||||
return { ok: true, code: result.code, type, name, emailSent, qr };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE = `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f1a12" />
|
||||
<title>Camp Scan — Comp Tickets</title>
|
||||
<style>
|
||||
: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; }
|
||||
.wrap { max-width: 460px; margin: 0 auto; padding: 28px 20px 64px; }
|
||||
header { text-align: center; margin-bottom: 22px; }
|
||||
.logo { font-size: 52px; }
|
||||
h1 { font-size: 22px; margin: 8px 0 2px; }
|
||||
.sub { color: #9db3a4; font-size: 14px; margin: 0; }
|
||||
label { display: block; font-size: 13px; color: #9db3a4; margin: 14px 0 5px; }
|
||||
input, select { width: 100%; background: #16241a; border: 1px solid #24382a; border-radius: 12px; padding: 14px; color: #eaf2ec; font-size: 16px; }
|
||||
button { width: 100%; background: #25c05a; color: #06210f; font-weight: 800; font-size: 18px; border: none; padding: 15px; border-radius: 13px; margin-top: 18px; }
|
||||
button:disabled { opacity: 0.5; }
|
||||
.msg { margin-top: 14px; font-size: 15px; font-weight: 600; text-align: center; min-height: 20px; }
|
||||
.err { color: #e04343; }
|
||||
.ok { color: #58d68d; }
|
||||
.result { display: none; text-align: center; margin-top: 18px; background: #16241a; border: 1px solid #24382a; border-radius: 14px; padding: 18px; }
|
||||
.result img { width: 220px; height: 220px; background: #fff; border-radius: 10px; padding: 8px; }
|
||||
.result .code { font-family: ui-monospace, Menlo, monospace; font-size: 20px; letter-spacing: 2px; margin: 12px 0 4px; color: #58d68d; }
|
||||
.result .who { font-size: 16px; color: #c4d6c9; }
|
||||
.hint { color: #6c8f74; font-size: 12px; text-align: center; margin-top: 10px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="logo">🐻</div>
|
||||
<h1>Comp Ticket Portal</h1>
|
||||
<p class="sub">Entry-only tickets for workers & guests</p>
|
||||
</header>
|
||||
|
||||
<label>Portal password</label>
|
||||
<input id="pw" type="password" autocomplete="current-password" placeholder="Shared admin password" />
|
||||
|
||||
<label>Ticket type</label>
|
||||
<select id="type">
|
||||
<option>Guest</option><option>Worker</option><option>Performer</option>
|
||||
<option>Volunteer</option><option>Speaker</option>
|
||||
</select>
|
||||
|
||||
<label>Full name</label>
|
||||
<input id="name" type="text" autocomplete="off" placeholder="Attendee name" />
|
||||
|
||||
<label>Email</label>
|
||||
<input id="email" type="email" autocomplete="off" autocapitalize="none" placeholder="Where to send the ticket" />
|
||||
|
||||
<button id="go">Create ticket</button>
|
||||
<div id="msg" class="msg"></div>
|
||||
|
||||
<div id="result" class="result">
|
||||
<img id="qr" alt="Ticket QR" />
|
||||
<div class="code" id="rcode"></div>
|
||||
<div class="who" id="rwho"></div>
|
||||
<div class="hint" id="rmail"></div>
|
||||
<button id="another" style="background:transparent;color:#eaf2ec;border:1px solid #2e7d32;font-size:15px;">Create another</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
function setMsg(t, ok) { var m = $("msg"); m.textContent = t; m.className = "msg " + (ok ? "ok" : "err"); }
|
||||
|
||||
$("go").addEventListener("click", function () {
|
||||
var pw = $("pw").value, name = $("name").value.trim(), email = $("email").value.trim(), type = $("type").value;
|
||||
if (!pw) return setMsg("Enter the portal password.");
|
||||
if (!name || !email) return setMsg("Name and email are required.");
|
||||
$("go").disabled = true; setMsg("Creating…", true);
|
||||
fetch("/api/portal/create-ticket", {
|
||||
method: "POST", headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ password: pw, name: name, email: email, type: type })
|
||||
}).then(function (r) { return r.json().then(function (d) { return { s: r.status, d: d }; }); })
|
||||
.then(function (x) {
|
||||
$("go").disabled = false;
|
||||
if (x.s === 401) return setMsg("Wrong password.");
|
||||
if (x.s !== 200 || !x.d.ok) return setMsg(x.d.detail || x.d.error || "Failed to create ticket.");
|
||||
setMsg("");
|
||||
$("qr").src = x.d.qr; $("rcode").textContent = x.d.code;
|
||||
$("rwho").textContent = x.d.type + " · " + x.d.name;
|
||||
$("rmail").textContent = x.d.emailSent ? "Emailed to " + email : "Email not sent — show/screenshot this QR.";
|
||||
$("result").style.display = "block";
|
||||
$("name").value = ""; $("email").value = "";
|
||||
})
|
||||
.catch(function () { $("go").disabled = false; setMsg("Network error."); });
|
||||
});
|
||||
$("another").addEventListener("click", function () { $("result").style.display = "none"; $("name").focus(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
|
@ -15,6 +15,7 @@ interface Persona {
|
|||
utv?: boolean;
|
||||
isDonor?: boolean;
|
||||
donorTier?: string;
|
||||
ticketType?: string;
|
||||
exhaust?: boolean; // pre-redeem all tickets so it scans as "exhausted"
|
||||
blurb: string;
|
||||
}
|
||||
|
|
@ -72,6 +73,24 @@ const PERSONAS: Persona[] = [
|
|||
exhaust: true,
|
||||
blurb: "2 tickets, already fully redeemed. Check-in mode → red 'exhausted'.",
|
||||
},
|
||||
{
|
||||
key: "worker",
|
||||
name: "Wanda Worker",
|
||||
email: "worker@test.beartaria",
|
||||
adultNames: ["Wanda Worker"],
|
||||
counts: C(1),
|
||||
ticketType: "Worker",
|
||||
blurb: "Entry-only WORKER comp ticket. Check-in mode → green with a Worker badge.",
|
||||
},
|
||||
{
|
||||
key: "performer",
|
||||
name: "Perry Performer",
|
||||
email: "performer@test.beartaria",
|
||||
adultNames: ["Perry Performer"],
|
||||
counts: C(1),
|
||||
ticketType: "Performer",
|
||||
blurb: "Entry-only PERFORMER comp ticket. Check-in mode → green with a Performer badge.",
|
||||
},
|
||||
];
|
||||
|
||||
const INVALID_CODE = "BC26-0000-0000"; // not in the DB → scans as "not found"
|
||||
|
|
@ -94,6 +113,7 @@ export async function testRoutes(app: FastifyInstance): Promise<void> {
|
|||
utv: p.utv,
|
||||
isDonor: p.isDonor,
|
||||
donorTier: p.donorTier,
|
||||
ticketType: p.ticketType,
|
||||
submissionKey: `test:${p.key}`,
|
||||
});
|
||||
// Keep the "exhausted" persona fully redeemed on every load so its state
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { testRoutes } from "./routes/test.js";
|
|||
import { installRoutes } from "./routes/install.js";
|
||||
import { webhookDocRoutes } from "./routes/webhookDoc.js";
|
||||
import { publicLookupRoutes } from "./routes/publicLookup.js";
|
||||
import { portalRoutes } from "./routes/portal.js";
|
||||
|
||||
export async function build() {
|
||||
const config = loadConfig();
|
||||
|
|
@ -36,6 +37,7 @@ export async function build() {
|
|||
await app.register(installRoutes);
|
||||
await app.register(webhookDocRoutes);
|
||||
await app.register(publicLookupRoutes);
|
||||
await app.register(portalRoutes);
|
||||
|
||||
// Serve the exported Expo web build (if present) with SPA fallback.
|
||||
const webDir = config.WEB_DIR ?? join(process.cwd(), "web");
|
||||
|
|
|
|||
|
|
@ -130,6 +130,7 @@ export interface WebhookInput {
|
|||
name: string;
|
||||
adultNames?: string[];
|
||||
email: string;
|
||||
ticketType?: string; // Guest/Worker/Performer/Volunteer/Speaker for portal comps
|
||||
address?: string;
|
||||
isDonor?: boolean;
|
||||
donorTier?: string;
|
||||
|
|
@ -178,6 +179,7 @@ export async function createTicket(
|
|||
[COL.submissionKey]: input.submissionKey,
|
||||
};
|
||||
if (input.adultNames && input.adultNames.length) fields[COL.adultNames] = input.adultNames.join("\n");
|
||||
if (input.ticketType) fields[COL.ticketType] = input.ticketType;
|
||||
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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue