From 4b78c82b35eee5e2c352468e0124cf749b42f4da Mon Sep 17 00:00:00 2001 From: Hank Date: Mon, 13 Jul 2026 03:30:08 +0000 Subject: [PATCH] Add /crush33 comp-ticket portal + ticket-type badge on scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- app/app/admin.tsx | 1 + app/app/index.tsx | 29 ++++++ app/lib/api.ts | 1 + backend/src/config.ts | 2 + backend/src/fields.ts | 3 + backend/src/routes/portal.ts | 170 +++++++++++++++++++++++++++++++++++ backend/src/routes/test.ts | 20 +++++ backend/src/server.ts | 2 + backend/src/ticketService.ts | 2 + 9 files changed, 230 insertions(+) create mode 100644 backend/src/routes/portal.ts diff --git a/app/app/admin.tsx b/app/app/admin.tsx index f492fc9..e978059 100644 --- a/app/app/admin.tsx +++ b/app/app/admin.tsx @@ -202,6 +202,7 @@ function TicketCard({ ticket, onAdjust }: { ticket: TicketView; onAdjust: (t: Ti const tags: string[] = []; const e = ticket.extras; + if (ticket.ticketType) tags.push(`🎫 ${ticket.ticketType}`); if (e.donorTier === "member") tags.push("🐻 Member"); else if (e.isDonor) tags.push("⭐ Donor"); if (e.carParking) tags.push("πŸš— Car"); diff --git a/app/app/index.tsx b/app/app/index.tsx index 297e1d9..ab552ff 100644 --- a/app/app/index.tsx +++ b/app/app/index.tsx @@ -242,6 +242,7 @@ export default function ScannerScreen() { {phase === "success" && ticket && ( βœ“ + {isIce ? ( <> @@ -366,6 +367,25 @@ function ExtrasRow({ ticket }: { ticket: TicketView }) { ); } +const TYPE_ICON: Record = { + Guest: "🎫", + Worker: "πŸ› οΈ", + Performer: "🎭", + Volunteer: "πŸ™Œ", + Speaker: "🎀", +}; + +function TypeBadge({ type }: { type: string }) { + if (!type) return null; + return ( + + + {(TYPE_ICON[type] ?? "🎫") + " " + type.toUpperCase()} + + + ); +} + function AdultNames({ names }: { names: string[] }) { if (!names.length) return null; return ( @@ -405,6 +425,7 @@ function ConfirmCard({ return ( {ticket.name} + {ticket.code} {remaining} of {total} {unit} remaining @@ -507,6 +528,14 @@ const styles = StyleSheet.create({ donorFigureDivider: { width: 1, alignSelf: "stretch", backgroundColor: "rgba(255,255,255,0.35)", marginVertical: 8 }, donorEmail: { color: "rgba(255,255,255,0.85)", fontSize: 14, marginTop: 18 }, + typeBadge: { + backgroundColor: "rgba(255,255,255,0.22)", + borderRadius: 999, + paddingHorizontal: 18, + paddingVertical: 8, + marginTop: 10, + }, + typeBadgeText: { color: "#fff", fontSize: 20, fontWeight: "900", letterSpacing: 1 }, namesBox: { marginTop: 12, alignItems: "center", gap: 3 }, nameLine: { color: "#fff", fontSize: 18, fontWeight: "600", textAlign: "center" }, tags: { flexDirection: "row", flexWrap: "wrap", justifyContent: "center", gap: 8, marginTop: 14 }, diff --git a/app/lib/api.ts b/app/lib/api.ts index 84dd309..2123da8 100644 --- a/app/lib/api.ts +++ b/app/lib/api.ts @@ -21,6 +21,7 @@ export interface TicketView { code: string; name: string; email: string; + ticketType: string; total: number; redeemed: number; remaining: number; diff --git a/backend/src/config.ts b/backend/src/config.ts index df123e5..ef9f7f5 100644 --- a/backend/src/config.ts +++ b/backend/src/config.ts @@ -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"), diff --git a/backend/src/fields.ts b/backend/src/fields.ts index 5c669e8..c6b4cc8 100644 --- a/backend/src/fields.ts +++ b/backend/src/fields.ts @@ -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), diff --git a/backend/src/routes/portal.ts b/backend/src/routes/portal.ts new file mode 100644 index 0000000..9a1f077 --- /dev/null +++ b/backend/src/routes/portal.ts @@ -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 { + 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>; + 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 = ` + + + + + +Camp Scan β€” Comp Tickets + + + +
+
+ +

Comp Ticket Portal

+

Entry-only tickets for workers & guests

+
+ + + + + + + + + + + + + + +
+ +
+ Ticket QR +
+
+
+ +
+
+ + + +`; diff --git a/backend/src/routes/test.ts b/backend/src/routes/test.ts index 4118f79..1096416 100644 --- a/backend/src/routes/test.ts +++ b/backend/src/routes/test.ts @@ -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 { 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 diff --git a/backend/src/server.ts b/backend/src/server.ts index 79fba69..b10433e 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -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"); diff --git a/backend/src/ticketService.ts b/backend/src/ticketService.ts index f4e03aa..c894f76 100644 --- a/backend/src/ticketService.ts +++ b/backend/src/ticketService.ts @@ -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;