Initial Camp Scan ticketing system

Backend (Fastify + TS): FluentForms webhook -> NocoDB row + QR + MailerSend
email; PIN auth; scan/lookup/redeem with per-code serialization; reusable QR
codes with count-based check-in; admin search.

App (Expo, one codebase): Android APK + iPhone PWA. Login, camera scanner
(native + web barcode-detector split), green/red overlay with sound + haptics,
admin lookup/redeem. Session token persisted per device.

Ops: multi-stage Dockerfile serving API + PWA same-origin, compose bound to
127.0.0.1; Forgejo Actions runner + tag-triggered signed APK build for Obtainium.
Docs in README.md and INSTALL.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-08 03:47:59 +00:00
commit 3397e3e3ec
60 changed files with 14703 additions and 0 deletions

View file

@ -0,0 +1,40 @@
import { timingSafeEqual } from "node:crypto";
import type { FastifyInstance } from "fastify";
function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
const bb = Buffer.from(b);
if (ba.length !== bb.length) {
// Still do a comparison to keep timing roughly constant.
timingSafeEqual(ba, ba);
return false;
}
return timingSafeEqual(ba, bb);
}
export async function authRoutes(app: FastifyInstance): Promise<void> {
app.post(
"/api/auth/login",
{
config: { rateLimit: { max: 10, timeWindow: "1 minute" } },
schema: {
body: {
type: "object",
required: ["pin"],
properties: { pin: { type: "string", minLength: 1, maxLength: 100 } },
},
},
},
async (req, reply) => {
const { pin } = req.body as { pin: string };
if (!safeEqual(pin, app.ctx.config.EVENT_PIN)) {
return reply.code(401).send({ error: "invalid_pin" });
}
const token = await reply.jwtSign(
{ role: "staff" },
{ expiresIn: app.ctx.config.TOKEN_TTL },
);
return { token };
},
);
}

View file

@ -0,0 +1,158 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { normalizeCode, looksLikeCode } from "../services/code.js";
import { lookupByCode, redeem, search, createTicket } from "../ticketService.js";
import { renderQrPng } from "../services/qrcode.js";
import { COL } from "../fields.js";
async function requireStaff(req: FastifyRequest, reply: FastifyReply): Promise<void> {
try {
await req.jwtVerify();
} catch {
reply.code(401).send({ error: "unauthorized" });
}
}
export async function ticketRoutes(app: FastifyInstance): Promise<void> {
// Health (unauthenticated) — includes a NocoDB connectivity probe.
app.get("/api/health", async (_req, reply) => {
try {
await app.ctx.nocodb.ping();
return { ok: true, nocodb: true };
} catch (e: any) {
return reply.code(503).send({ ok: false, nocodb: false, detail: e?.message });
}
});
// Look up a ticket by scanned code (no mutation).
app.post(
"/api/lookup",
{
preHandler: requireStaff,
schema: {
body: {
type: "object",
required: ["code"],
properties: { code: { type: "string", minLength: 1, maxLength: 64 } },
},
},
},
async (req) => {
const { code } = req.body as { code: string };
return lookupByCode(app.ctx, normalizeCode(code));
},
);
// Search by name/email or exact code for the admin panel.
app.get(
"/api/tickets",
{ preHandler: requireStaff },
async (req) => {
const q = String((req.query as any)?.q ?? "").trim();
if (!q) return { results: [] };
if (looksLikeCode(q)) {
const res = await lookupByCode(app.ctx, normalizeCode(q));
return { results: res.ok && res.found ? [res.ticket] : [] };
}
return { results: await search(app.ctx, q) };
},
);
// Redeem N tickets against a code (scanner check-in and admin adjust share this).
app.post(
"/api/redeem",
{
preHandler: requireStaff,
schema: {
body: {
type: "object",
required: ["code"],
properties: {
code: { type: "string", minLength: 1, maxLength: 64 },
count: { type: "integer", minimum: -100, maximum: 100 },
},
},
},
},
async (req) => {
const { code, count } = req.body as { code: string; count?: number };
return redeem(app.ctx, normalizeCode(code), count ?? 1);
},
);
// Re-send the ticket email (recovery when the webhook send failed).
app.post(
"/api/tickets/:code/resend-email",
{ preHandler: requireStaff },
async (req, reply) => {
const code = normalizeCode((req.params as any).code);
const rec = await app.ctx.nocodb.findByCode(code);
if (!rec) return reply.code(404).send({ error: "not_found" });
const email = String(rec[COL.email] ?? "");
const name = String(rec[COL.name] ?? "");
if (app.ctx.mailer.isBlockedRecipient(email)) {
return reply.code(422).send({ error: "trial_restriction", detail: "recipient not in MAIL_TEST_RECIPIENTS" });
}
try {
const qr = await renderQrPng(code);
const { computeTotal } = await import("../fields.js");
await app.ctx.mailer.sendTicket({
toEmail: email,
toName: name,
code,
quantity: computeTotal(rec),
qrPng: qr,
});
return { ok: true };
} catch (e: any) {
return reply.code(502).send({ error: "email_failed", detail: e?.message });
}
},
);
// Manual ticket creation for gate walk-ups / comps (admin). Not idempotent by
// submission; generates a fresh code and can email if an address is given.
app.post(
"/api/tickets",
{
preHandler: requireStaff,
schema: {
body: {
type: "object",
required: ["name", "ages"],
properties: {
name: { type: "string", minLength: 1 },
email: { type: "string" },
ages: { type: "object" },
sendEmail: { type: "boolean" },
},
},
},
},
async (req) => {
const b = req.body as any;
const submissionKey = `manual:${Date.now()}:${Math.trunc(Math.random() * 1e9)}`;
const result = await createTicket(app.ctx, {
name: b.name,
email: b.email ?? "",
ages: b.ages,
submissionKey,
});
if (b.sendEmail && b.email && !app.ctx.mailer.isBlockedRecipient(b.email)) {
try {
const qr = await renderQrPng(result.code);
const { computeTotal } = await import("../fields.js");
await app.ctx.mailer.sendTicket({
toEmail: b.email,
toName: b.name,
code: result.code,
quantity: computeTotal(result.record),
qrPng: qr,
});
} catch (e: any) {
req.log.error({ err: e }, "manual ticket email failed");
}
}
return { code: result.code };
},
);
}

View file

@ -0,0 +1,117 @@
import { createHash, timingSafeEqual } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { AGE_COLUMNS, toBool, toNumber } from "../fields.js";
import { createTicket } from "../ticketService.js";
import { renderQrPng } from "../services/qrcode.js";
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);
}
// Map webhook payload keys -> NocoDB age-column titles. Keys are what you map
// the FluentForms fields to in the webhook feed.
const AGE_KEY_TO_COL: Record<string, string> = {
ages_0_3: "Ages 0-3",
ages_4_7: "Ages 4-7",
ages_8_12: "Ages 8-12",
ages_13_17: "Ages 13-17",
ages_18_25: "Ages 18-25",
ages_26_45: "Ages 26-45",
ages_46_64: "Ages 46-64",
ages_65: "Ages 65+",
};
export async function webhookRoutes(app: FastifyInstance): Promise<void> {
const handler = async (req: any, reply: any) => {
const secret = req.headers["x-webhook-secret"];
if (typeof secret !== "string" || !safeEqual(secret, app.ctx.config.WEBHOOK_SECRET)) {
return reply.code(401).send({ error: "unauthorized" });
}
const body = (req.body ?? {}) as Record<string, unknown>;
const name = String(body.name ?? "").trim();
const email = String(body.email ?? "").trim();
if (!name || !email) {
return reply.code(400).send({ error: "missing_fields", detail: "name and email are required" });
}
// Build age-bracket counts from whichever keys were provided.
const ages: Record<string, number> = {};
for (const [key, col] of Object.entries(AGE_KEY_TO_COL)) {
if (body[key] !== undefined && body[key] !== null && body[key] !== "") {
ages[col] = toNumber(body[key]);
}
}
const anyAge = AGE_COLUMNS.some((c) => (ages[c] ?? 0) > 0);
if (!anyAge) {
return reply.code(400).send({ error: "no_tickets", detail: "no age-bracket counts provided" });
}
// Idempotency key: prefer a stable submission id, else hash the content.
const submissionId = body.submission_id ?? body.submissionId ?? body.entry_id;
const submissionKey = submissionId
? `sub:${String(submissionId)}`
: "hash:" +
createHash("sha256")
.update(`${email}|${name}|${JSON.stringify(ages)}`)
.digest("hex")
.slice(0, 32);
let result: Awaited<ReturnType<typeof createTicket>>;
try {
result = await createTicket(app.ctx, {
name,
email,
address: body.address !== undefined ? String(body.address) : undefined,
isDonor: body.is_donor !== undefined ? toBool(body.is_donor) : undefined,
carParking: body.car_parking !== undefined ? toBool(body.car_parking) : undefined,
rvParking: body.rv_parking !== undefined ? toBool(body.rv_parking) : undefined,
iceAccess: body.ice_access !== undefined ? toBool(body.ice_access) : undefined,
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
ages,
submissionKey,
});
} catch (e: any) {
req.log.error({ err: e }, "webhook: failed to create ticket");
return reply.code(502).send({ error: "db_error", detail: e?.message });
}
if (result.status === "duplicate") {
return { status: "duplicate", code: result.code };
}
// Send the ticket email. If it fails, the row already exists — report 502
// so the failure is visible in FluentForms' delivery log; the ticket can be
// re-sent later via POST /api/tickets/:code/resend-email.
if (app.ctx.mailer.isBlockedRecipient(email)) {
req.log.warn({ email }, "webhook: recipient blocked by MAIL_TEST_RECIPIENTS; skipping send");
return { status: "created", code: result.code, emailSent: false, emailSkipped: "trial_restriction" };
}
try {
const qr = await renderQrPng(result.code);
const quantity = // redeemable total for the email copy
AGE_COLUMNS.filter((c) => c !== "Ages 0-3").reduce((s, c) => s + (ages[c] ?? 0), 0);
await app.ctx.mailer.sendTicket({
toEmail: email,
toName: name,
code: result.code,
quantity,
qrPng: qr,
});
} catch (e: any) {
req.log.error({ err: e, code: result.code }, "webhook: ticket created but email failed");
return reply.code(502).send({ status: "created", code: result.code, emailSent: false, error: e?.message });
}
return { status: "created", code: result.code, emailSent: true };
};
// Public path (configure this in FluentForms): https://scan.beartariacampgrounds.com/webhook
app.post("/webhook", handler);
// Explicit alias.
app.post("/api/webhook/fluentforms", handler);
}