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:
commit
3397e3e3ec
60 changed files with 14703 additions and 0 deletions
40
backend/src/routes/auth.ts
Normal file
40
backend/src/routes/auth.ts
Normal 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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
158
backend/src/routes/tickets.ts
Normal file
158
backend/src/routes/tickets.ts
Normal 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 };
|
||||
},
|
||||
);
|
||||
}
|
||||
117
backend/src/routes/webhook.ts
Normal file
117
backend/src/routes/webhook.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue