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
50
backend/src/config.ts
Normal file
50
backend/src/config.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { z } from "zod";
|
||||
|
||||
const schema = z.object({
|
||||
PORT: z.coerce.number().default(8080),
|
||||
HOST: z.string().default("0.0.0.0"),
|
||||
|
||||
NOCODB_BASE_URL: z.string().url(),
|
||||
NOCODB_API_TOKEN: z.string().min(1),
|
||||
NOCODB_TABLE_ID: z.string().min(1),
|
||||
|
||||
MAILERSEND_API_TOKEN: z.string().min(1),
|
||||
MAIL_FROM_EMAIL: z.string().email(),
|
||||
MAIL_FROM_NAME: z.string().default("Beartaria Campgrounds"),
|
||||
|
||||
WEBHOOK_SECRET: z.string().min(1),
|
||||
EVENT_PIN: z.string().min(1),
|
||||
TOKEN_SECRET: z.string().min(16),
|
||||
TOKEN_TTL: z.string().default("30d"),
|
||||
|
||||
LOG_LEVEL: z.string().default("info"),
|
||||
|
||||
// Directory of the exported Expo web build to serve statically. Optional in dev.
|
||||
WEB_DIR: z.string().optional(),
|
||||
|
||||
// Comma-separated email addresses allowed to receive mail while MailerSend is
|
||||
// still in trial mode. Leave empty in production once the domain is verified.
|
||||
MAIL_TEST_RECIPIENTS: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof schema>;
|
||||
|
||||
let cached: Config | null = null;
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
if (cached) return cached;
|
||||
const parsed = schema.safeParse(env);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues
|
||||
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
|
||||
.join("\n");
|
||||
throw new Error(`Invalid environment configuration:\n${issues}`);
|
||||
}
|
||||
cached = parsed.data;
|
||||
return cached;
|
||||
}
|
||||
|
||||
// For tests: reset the memoized config.
|
||||
export function resetConfig(): void {
|
||||
cached = null;
|
||||
}
|
||||
35
backend/src/context.ts
Normal file
35
backend/src/context.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { Config } from "./config.js";
|
||||
import { NocoDBClient } from "./services/nocodb.js";
|
||||
import { Mailer } from "./services/mailer.js";
|
||||
import { RedeemQueue } from "./services/redeemQueue.js";
|
||||
|
||||
/** Shared services wired once at startup and hung off the Fastify instance. */
|
||||
export interface AppContext {
|
||||
config: Config;
|
||||
nocodb: NocoDBClient;
|
||||
mailer: Mailer;
|
||||
queue: RedeemQueue;
|
||||
}
|
||||
|
||||
export function buildContext(config: Config): AppContext {
|
||||
return {
|
||||
config,
|
||||
nocodb: new NocoDBClient(config),
|
||||
mailer: new Mailer(config),
|
||||
queue: new RedeemQueue(),
|
||||
};
|
||||
}
|
||||
|
||||
// Ambient module augmentation so `fastify.ctx` and `request.user` are typed.
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
ctx: AppContext;
|
||||
}
|
||||
}
|
||||
|
||||
declare module "@fastify/jwt" {
|
||||
interface FastifyJWT {
|
||||
payload: { role: "staff" };
|
||||
user: { role: "staff" };
|
||||
}
|
||||
}
|
||||
115
backend/src/fields.ts
Normal file
115
backend/src/fields.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* Mapping between the NocoDB "2026 Campground Tickets" table columns, the
|
||||
* webhook payload keys, and the view we return to the app.
|
||||
*
|
||||
* The 2026 table is a clone of the 2025 submission table (per-purchase record
|
||||
* with age-bracket headcounts, parking/ice flags, donor flag) PLUS four columns
|
||||
* this system adds: Ticket Code, Redeemed, SubmissionKey, LastScanAt.
|
||||
*
|
||||
* If the real column titles differ, change them here in one place.
|
||||
*/
|
||||
export const COL = {
|
||||
id: "Id",
|
||||
name: "Title", // first column in the 2025 table holds the purchaser name
|
||||
email: "Email Address",
|
||||
address: "Address",
|
||||
isDonor: "Is Donor",
|
||||
carParking: "Car Parking",
|
||||
rvParking: "RV Parking",
|
||||
iceAccess: "Ice Access",
|
||||
paymentMethod: "Payment Method",
|
||||
|
||||
// Columns this system adds to the table:
|
||||
code: "Ticket Code",
|
||||
redeemed: "Redeemed",
|
||||
submissionKey: "SubmissionKey",
|
||||
lastScanAt: "LastScanAt",
|
||||
} as const;
|
||||
|
||||
/** Age-bracket columns, in order. */
|
||||
export const AGE_COLUMNS = [
|
||||
"Ages 0-3",
|
||||
"Ages 4-7",
|
||||
"Ages 8-12",
|
||||
"Ages 13-17",
|
||||
"Ages 18-25",
|
||||
"Ages 26-45",
|
||||
"Ages 46-64",
|
||||
"Ages 65+",
|
||||
] as const;
|
||||
|
||||
/** Age brackets admitted free and NOT counted as redeemable tickets. */
|
||||
export const FREE_AGE_COLUMNS: readonly string[] = ["Ages 0-3"];
|
||||
|
||||
export type NocoRecord = Record<string, unknown> & { Id: number };
|
||||
|
||||
function num(v: unknown): number {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function bool(v: unknown): boolean {
|
||||
if (typeof v === "boolean") return v;
|
||||
if (typeof v === "number") return v !== 0;
|
||||
if (typeof v === "string") return /^(1|true|yes|y|on)$/i.test(v.trim());
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Total redeemable tickets = sum of age brackets minus the free ones. */
|
||||
export function computeTotal(rec: NocoRecord): number {
|
||||
let total = 0;
|
||||
for (const col of AGE_COLUMNS) {
|
||||
if (FREE_AGE_COLUMNS.includes(col)) continue;
|
||||
total += num(rec[col]);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** Per-bracket breakdown for display. */
|
||||
export function ageBreakdown(rec: NocoRecord): { bracket: string; count: number; free: boolean }[] {
|
||||
return AGE_COLUMNS.map((col) => ({
|
||||
bracket: col.replace(/^Ages /, ""),
|
||||
count: num(rec[col]),
|
||||
free: FREE_AGE_COLUMNS.includes(col),
|
||||
})).filter((b) => b.count > 0);
|
||||
}
|
||||
|
||||
export interface TicketView {
|
||||
code: string;
|
||||
name: string;
|
||||
email: string;
|
||||
total: number;
|
||||
redeemed: number;
|
||||
remaining: number;
|
||||
extras: {
|
||||
carParking: boolean;
|
||||
rvParking: boolean;
|
||||
iceAccess: boolean;
|
||||
isDonor: boolean;
|
||||
freeUnder4: number;
|
||||
};
|
||||
ages: { bracket: string; count: number; free: boolean }[];
|
||||
}
|
||||
|
||||
export function toView(rec: NocoRecord): TicketView {
|
||||
const total = computeTotal(rec);
|
||||
const redeemed = num(rec[COL.redeemed]);
|
||||
return {
|
||||
code: String(rec[COL.code] ?? ""),
|
||||
name: String(rec[COL.name] ?? ""),
|
||||
email: String(rec[COL.email] ?? ""),
|
||||
total,
|
||||
redeemed,
|
||||
remaining: Math.max(0, total - redeemed),
|
||||
extras: {
|
||||
carParking: bool(rec[COL.carParking]),
|
||||
rvParking: bool(rec[COL.rvParking]),
|
||||
iceAccess: bool(rec[COL.iceAccess]),
|
||||
isDonor: bool(rec[COL.isDonor]),
|
||||
freeUnder4: num(rec["Ages 0-3"]),
|
||||
},
|
||||
ages: ageBreakdown(rec),
|
||||
};
|
||||
}
|
||||
|
||||
export { num as toNumber, bool as toBool };
|
||||
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);
|
||||
}
|
||||
66
backend/src/server.ts
Normal file
66
backend/src/server.ts
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import Fastify from "fastify";
|
||||
import jwt from "@fastify/jwt";
|
||||
import rateLimit from "@fastify/rate-limit";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import formbody from "@fastify/formbody";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { buildContext } from "./context.js";
|
||||
import { authRoutes } from "./routes/auth.js";
|
||||
import { webhookRoutes } from "./routes/webhook.js";
|
||||
import { ticketRoutes } from "./routes/tickets.js";
|
||||
|
||||
export async function build() {
|
||||
const config = loadConfig();
|
||||
const app = Fastify({
|
||||
logger: { level: config.LOG_LEVEL },
|
||||
trustProxy: true, // behind nginx
|
||||
bodyLimit: 1_000_000,
|
||||
});
|
||||
|
||||
app.decorate("ctx", buildContext(config));
|
||||
|
||||
await app.register(jwt, { secret: config.TOKEN_SECRET });
|
||||
await app.register(rateLimit, { global: false });
|
||||
await app.register(formbody); // accept application/x-www-form-urlencoded webhooks too
|
||||
|
||||
await app.register(authRoutes);
|
||||
await app.register(webhookRoutes);
|
||||
await app.register(ticketRoutes);
|
||||
|
||||
// Serve the exported Expo web build (if present) with SPA fallback.
|
||||
const webDir = config.WEB_DIR ?? join(process.cwd(), "web");
|
||||
if (existsSync(webDir)) {
|
||||
await app.register(fastifyStatic, { root: webDir, wildcard: false });
|
||||
app.setNotFoundHandler((req, reply) => {
|
||||
// Let unknown /api routes 404 as JSON; everything else -> SPA index.
|
||||
if (req.raw.url && req.raw.url.startsWith("/api")) {
|
||||
return reply.code(404).send({ error: "not_found" });
|
||||
}
|
||||
return reply.sendFile("index.html");
|
||||
});
|
||||
app.log.info({ webDir }, "serving static web build");
|
||||
} else {
|
||||
app.log.warn({ webDir }, "no web build found; serving API only");
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
// Entry point (skipped when imported by tests).
|
||||
const isMain = process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
|
||||
if (isMain) {
|
||||
const config = loadConfig();
|
||||
build()
|
||||
.then((app) => app.listen({ port: config.PORT, host: config.HOST }))
|
||||
.then((addr) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`camptickets backend listening on ${addr}`);
|
||||
})
|
||||
.catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
34
backend/src/services/code.ts
Normal file
34
backend/src/services/code.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { randomInt } from "node:crypto";
|
||||
|
||||
// Crockford-ish alphabet: no 0/O/1/I/L/U to avoid human/QR ambiguity.
|
||||
const ALPHABET = "23456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
const PREFIX = "BC26";
|
||||
|
||||
/** Generate a ticket code like BC26-XXXX-XXXX. */
|
||||
export function generateCode(): string {
|
||||
let body = "";
|
||||
for (let i = 0; i < 8; i++) {
|
||||
body += ALPHABET[randomInt(ALPHABET.length)];
|
||||
if (i === 3) body += "-";
|
||||
}
|
||||
return `${PREFIX}-${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a scanned/typed code for lookup: uppercase, strip everything that
|
||||
* isn't in the alphabet or the prefix, then re-hyphenate to canonical form.
|
||||
* Accepts input with or without hyphens, with surrounding whitespace, etc.
|
||||
*/
|
||||
export function normalizeCode(raw: string): string {
|
||||
const cleaned = (raw || "").toUpperCase().replace(/[^0-9A-Z]/g, "");
|
||||
// Expected canonical: BC26 + 8 body chars = 12 chars total.
|
||||
if (!cleaned.startsWith("BC26")) return cleaned;
|
||||
const body = cleaned.slice(4);
|
||||
if (body.length !== 8) return cleaned;
|
||||
return `${PREFIX}-${body.slice(0, 4)}-${body.slice(4)}`;
|
||||
}
|
||||
|
||||
/** True if a string looks like a ticket code (vs. a name search query). */
|
||||
export function looksLikeCode(raw: string): boolean {
|
||||
return /^BC26/i.test((raw || "").trim().replace(/[\s-]/g, ""));
|
||||
}
|
||||
152
backend/src/services/mailer.ts
Normal file
152
backend/src/services/mailer.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import type { Config } from "../config.js";
|
||||
|
||||
const MAILERSEND_URL = "https://api.mailersend.com/v1/email";
|
||||
const SUBJECT = "2026 Beartaria Campgrounds Tickets";
|
||||
|
||||
export interface TicketEmail {
|
||||
toEmail: string;
|
||||
toName: string;
|
||||
code: string;
|
||||
quantity: number;
|
||||
qrPng: Buffer;
|
||||
}
|
||||
|
||||
export class MailerSendError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "MailerSendError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export class Mailer {
|
||||
private readonly token: string;
|
||||
private readonly fromEmail: string;
|
||||
private readonly fromName: string;
|
||||
private readonly testRecipients: Set<string> | null;
|
||||
|
||||
constructor(
|
||||
cfg: Pick<
|
||||
Config,
|
||||
"MAILERSEND_API_TOKEN" | "MAIL_FROM_EMAIL" | "MAIL_FROM_NAME" | "MAIL_TEST_RECIPIENTS"
|
||||
>,
|
||||
private readonly fetchImpl: typeof fetch = fetch,
|
||||
) {
|
||||
this.token = cfg.MAILERSEND_API_TOKEN;
|
||||
this.fromEmail = cfg.MAIL_FROM_EMAIL;
|
||||
this.fromName = cfg.MAIL_FROM_NAME;
|
||||
const list = (cfg.MAIL_TEST_RECIPIENTS || "")
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
this.testRecipients = list.length ? new Set(list) : null;
|
||||
}
|
||||
|
||||
/** True if MailerSend trial restrictions would block this recipient. */
|
||||
isBlockedRecipient(email: string): boolean {
|
||||
if (!this.testRecipients) return false;
|
||||
return !this.testRecipients.has(email.toLowerCase());
|
||||
}
|
||||
|
||||
async sendTicket(mail: TicketEmail): Promise<void> {
|
||||
const payload = {
|
||||
from: { email: this.fromEmail, name: this.fromName },
|
||||
to: [{ email: mail.toEmail, name: mail.toName || mail.toEmail }],
|
||||
subject: SUBJECT,
|
||||
html: renderHtml(mail),
|
||||
text: renderText(mail),
|
||||
attachments: [
|
||||
{
|
||||
content: mail.qrPng.toString("base64"),
|
||||
filename: "ticket-qr.png",
|
||||
disposition: "inline",
|
||||
id: "qrcode",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const res = await this.fetchImpl(MAILERSEND_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
"Content-Type": "application/json",
|
||||
"X-Requested-With": "XMLHttpRequest",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new MailerSendError(
|
||||
`MailerSend ${res.status}: ${body || res.statusText}`,
|
||||
res.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
return String(s).replace(/[&<>"]/g, (c) =>
|
||||
c === "&" ? "&" : c === "<" ? "<" : c === ">" ? ">" : """,
|
||||
);
|
||||
}
|
||||
|
||||
function renderHtml(mail: TicketEmail): string {
|
||||
const name = esc(mail.toName || "");
|
||||
const qty = mail.quantity;
|
||||
const ticketWord = qty === 1 ? "ticket" : "tickets";
|
||||
return `<!doctype html>
|
||||
<html>
|
||||
<body style="margin:0;padding:0;background:#0f1a12;font-family:Arial,Helvetica,sans-serif;color:#0f1a12;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#0f1a12;padding:24px 0;">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" width="480" cellpadding="0" cellspacing="0" style="max-width:480px;background:#ffffff;border-radius:12px;overflow:hidden;">
|
||||
<tr><td style="background:#1b5e20;padding:20px 24px;color:#ffffff;font-size:18px;font-weight:bold;">
|
||||
🐻 Beartaria Campgrounds 2026
|
||||
</td></tr>
|
||||
<tr><td style="padding:24px;">
|
||||
<p style="margin:0 0 12px;font-size:16px;">Hi ${name || "there"},</p>
|
||||
<p style="margin:0 0 16px;font-size:15px;line-height:1.5;">
|
||||
Thank you for your purchase! This email is your ticket for
|
||||
<strong>${qty} ${ticketWord}</strong> to the 2026 Beartaria Campgrounds event.
|
||||
Show the QR code below at the gate.
|
||||
</p>
|
||||
<div style="text-align:center;margin:20px 0;">
|
||||
<img src="cid:qrcode" alt="Ticket QR code" width="280" height="280"
|
||||
style="width:280px;height:280px;border:8px solid #ffffff;border-radius:8px;" />
|
||||
</div>
|
||||
<p style="margin:0 0 4px;font-size:13px;color:#555;text-align:center;">If the code above doesn't load, show this at the gate:</p>
|
||||
<p style="margin:0 0 20px;font-size:22px;font-weight:bold;letter-spacing:2px;text-align:center;color:#1b5e20;">
|
||||
${esc(mail.code)}
|
||||
</p>
|
||||
<p style="margin:0;font-size:13px;color:#777;line-height:1.5;">
|
||||
Each ticket admits one entry. This code is good for all ${qty} ${ticketWord} on one purchase —
|
||||
gate staff will check people in against it. See you there!
|
||||
</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
<p style="color:#6c8f74;font-size:11px;margin:16px 0 0;">Beartaria Campgrounds · beartariacampgrounds.com</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function renderText(mail: TicketEmail): string {
|
||||
const qty = mail.quantity;
|
||||
const ticketWord = qty === 1 ? "ticket" : "tickets";
|
||||
return [
|
||||
`Hi ${mail.toName || "there"},`,
|
||||
"",
|
||||
`Thank you for your purchase! This is your ticket for ${qty} ${ticketWord} to the 2026 Beartaria Campgrounds event.`,
|
||||
"",
|
||||
`Your ticket code: ${mail.code}`,
|
||||
"",
|
||||
"Show this code (or the QR code in the HTML version of this email) at the gate.",
|
||||
`It is good for all ${qty} ${ticketWord} on this purchase.`,
|
||||
"",
|
||||
"See you there!",
|
||||
"Beartaria Campgrounds · beartariacampgrounds.com",
|
||||
].join("\n");
|
||||
}
|
||||
121
backend/src/services/nocodb.ts
Normal file
121
backend/src/services/nocodb.ts
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
import type { Config } from "../config.js";
|
||||
import { COL, type NocoRecord } from "../fields.js";
|
||||
|
||||
/**
|
||||
* Thin client over the NocoDB v2 records REST API.
|
||||
* Docs: {baseUrl}/api/v2/tables/{tableId}/records (auth header: xc-token)
|
||||
*/
|
||||
export class NocoDBClient {
|
||||
private readonly base: string;
|
||||
private readonly token: string;
|
||||
private readonly tableId: string;
|
||||
|
||||
constructor(cfg: Pick<Config, "NOCODB_BASE_URL" | "NOCODB_API_TOKEN" | "NOCODB_TABLE_ID">) {
|
||||
this.base = cfg.NOCODB_BASE_URL.replace(/\/+$/, "");
|
||||
this.token = cfg.NOCODB_API_TOKEN;
|
||||
this.tableId = cfg.NOCODB_TABLE_ID;
|
||||
}
|
||||
|
||||
private get recordsUrl(): string {
|
||||
return `${this.base}/api/v2/tables/${this.tableId}/records`;
|
||||
}
|
||||
|
||||
private async request(url: string, init: RequestInit = {}): Promise<any> {
|
||||
const res = await fetch(url, {
|
||||
...init,
|
||||
headers: {
|
||||
"xc-token": this.token,
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers || {}),
|
||||
},
|
||||
});
|
||||
const text = await res.text();
|
||||
let body: any = undefined;
|
||||
if (text) {
|
||||
try {
|
||||
body = JSON.parse(text);
|
||||
} catch {
|
||||
body = text;
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const detail =
|
||||
body && typeof body === "object" && body.msg
|
||||
? body.msg
|
||||
: typeof body === "string"
|
||||
? body
|
||||
: res.statusText;
|
||||
throw new NocoDBError(`NocoDB ${res.status}: ${detail}`, res.status);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
private async list(where: string, limit = 25): Promise<NocoRecord[]> {
|
||||
const url = new URL(this.recordsUrl);
|
||||
if (where) url.searchParams.set("where", where);
|
||||
url.searchParams.set("limit", String(limit));
|
||||
const body = await this.request(url.toString());
|
||||
return (body?.list ?? []) as NocoRecord[];
|
||||
}
|
||||
|
||||
/** Exact lookup by ticket code. Returns null if not found. */
|
||||
async findByCode(code: string): Promise<NocoRecord | null> {
|
||||
const rows = await this.list(`(${COL.code},eq,${escapeValue(code)})`, 1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** Lookup by idempotency key. Returns null if not found. */
|
||||
async findBySubmissionKey(key: string): Promise<NocoRecord | null> {
|
||||
const rows = await this.list(`(${COL.submissionKey},eq,${escapeValue(key)})`, 1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** Substring search across name and email. */
|
||||
async search(query: string, limit = 25): Promise<NocoRecord[]> {
|
||||
const q = escapeValue(query);
|
||||
return this.list(`(${COL.name},like,%${q}%)~or(${COL.email},like,%${q}%)`, limit);
|
||||
}
|
||||
|
||||
async create(fields: Record<string, unknown>): Promise<NocoRecord> {
|
||||
const body = await this.request(this.recordsUrl, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(fields),
|
||||
});
|
||||
return (Array.isArray(body) ? body[0] : body) as NocoRecord;
|
||||
}
|
||||
|
||||
/** Patch fields on a record identified by its NocoDB Id. */
|
||||
async update(id: number, fields: Record<string, unknown>): Promise<NocoRecord> {
|
||||
const body = await this.request(this.recordsUrl, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ Id: id, ...fields }),
|
||||
});
|
||||
return (Array.isArray(body) ? body[0] : body) as NocoRecord;
|
||||
}
|
||||
|
||||
/** Cheap connectivity probe for healthchecks. */
|
||||
async ping(): Promise<boolean> {
|
||||
const url = new URL(this.recordsUrl);
|
||||
url.searchParams.set("limit", "1");
|
||||
await this.request(url.toString());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export class NocoDBError extends Error {
|
||||
status: number;
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = "NocoDBError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape a value for use inside a NocoDB `where=(Field,op,VALUE)` clause.
|
||||
* Parentheses and commas are structural in the filter grammar; strip them.
|
||||
* Ticket codes/emails/names never legitimately contain them for filtering.
|
||||
*/
|
||||
function escapeValue(v: string): string {
|
||||
return String(v).replace(/[(),]/g, " ").trim();
|
||||
}
|
||||
11
backend/src/services/qrcode.ts
Normal file
11
backend/src/services/qrcode.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import QRCode from "qrcode";
|
||||
|
||||
/** Render a ticket code to a PNG buffer suitable for inline email embedding. */
|
||||
export async function renderQrPng(code: string): Promise<Buffer> {
|
||||
return QRCode.toBuffer(code, {
|
||||
type: "png",
|
||||
errorCorrectionLevel: "M",
|
||||
width: 400,
|
||||
margin: 2,
|
||||
});
|
||||
}
|
||||
48
backend/src/services/redeemQueue.ts
Normal file
48
backend/src/services/redeemQueue.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
/**
|
||||
* Per-key serialization. NocoDB has no atomic increment, so all read-modify-write
|
||||
* operations for a given ticket code must run one at a time. Operations on
|
||||
* different codes run concurrently.
|
||||
*
|
||||
* IMPORTANT: correctness depends on a SINGLE backend instance. Never scale this
|
||||
* service to multiple replicas — the queue is in-process only.
|
||||
*/
|
||||
export class RedeemQueue {
|
||||
private chains = new Map<string, Promise<unknown>>();
|
||||
private readonly timeoutMs: number;
|
||||
|
||||
constructor(timeoutMs = 10_000) {
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
/** Run `fn` after any in-flight op for `key` completes. */
|
||||
run<T>(key: string, fn: () => Promise<T>): Promise<T> {
|
||||
const prior = this.chains.get(key) ?? Promise.resolve();
|
||||
// Chain regardless of whether the prior op resolved or rejected.
|
||||
const next = prior.catch(() => undefined).then(() => this.withTimeout(fn));
|
||||
this.chains.set(key, next);
|
||||
// Clean up the map entry once this is the tail of the chain.
|
||||
next.finally(() => {
|
||||
if (this.chains.get(key) === next) this.chains.delete(key);
|
||||
}).catch(() => undefined);
|
||||
return next;
|
||||
}
|
||||
|
||||
private withTimeout<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error("redeem operation timed out")),
|
||||
this.timeoutMs,
|
||||
);
|
||||
fn().then(
|
||||
(v) => {
|
||||
clearTimeout(timer);
|
||||
resolve(v);
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(timer);
|
||||
reject(e);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
35
backend/src/test/code.test.ts
Normal file
35
backend/src/test/code.test.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { generateCode, normalizeCode, looksLikeCode } from "../services/code.js";
|
||||
|
||||
describe("code generation", () => {
|
||||
it("produces BC26-XXXX-XXXX with unambiguous alphabet", () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
const c = generateCode();
|
||||
expect(c).toMatch(/^BC26-[23456789ABCDEFGHJKMNPQRSTVWXYZ]{4}-[23456789ABCDEFGHJKMNPQRSTVWXYZ]{4}$/);
|
||||
expect(c).not.toMatch(/[01OILU]/);
|
||||
}
|
||||
});
|
||||
|
||||
it("is effectively unique across many draws", () => {
|
||||
const seen = new Set<string>();
|
||||
for (let i = 0; i < 5000; i++) seen.add(generateCode());
|
||||
expect(seen.size).toBeGreaterThan(4990);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeCode", () => {
|
||||
it("canonicalizes case, spaces, and missing hyphens", () => {
|
||||
expect(normalizeCode("bc26abcd2345")).toBe("BC26-ABCD-2345");
|
||||
expect(normalizeCode("BC26-ABCD-2345")).toBe("BC26-ABCD-2345");
|
||||
expect(normalizeCode(" bc26 abcd 2345 ")).toBe("BC26-ABCD-2345");
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeCode", () => {
|
||||
it("distinguishes codes from name queries", () => {
|
||||
expect(looksLikeCode("BC26-ABCD-2345")).toBe(true);
|
||||
expect(looksLikeCode("bc26abcd2345")).toBe(true);
|
||||
expect(looksLikeCode("Smith")).toBe(false);
|
||||
expect(looksLikeCode("jane@example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
89
backend/src/test/fakeNocodb.ts
Normal file
89
backend/src/test/fakeNocodb.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import type { AppContext } from "../context.js";
|
||||
import { COL, type NocoRecord } from "../fields.js";
|
||||
import { RedeemQueue } from "../services/redeemQueue.js";
|
||||
|
||||
/**
|
||||
* In-memory stand-in for NocoDBClient. Adds a small async delay to each op so
|
||||
* the per-code serialization in RedeemQueue is actually exercised (a naive
|
||||
* read-modify-write without the queue would lose updates under this delay).
|
||||
*/
|
||||
export class FakeNocoDB {
|
||||
rows: NocoRecord[] = [];
|
||||
private nextId = 1;
|
||||
delayMs: number;
|
||||
failNext = false;
|
||||
|
||||
constructor(delayMs = 5) {
|
||||
this.delayMs = delayMs;
|
||||
}
|
||||
|
||||
private async delay() {
|
||||
await new Promise((r) => setTimeout(r, this.delayMs));
|
||||
}
|
||||
|
||||
async findByCode(code: string): Promise<NocoRecord | null> {
|
||||
await this.delay();
|
||||
return this.rows.find((r) => r[COL.code] === code) ?? null;
|
||||
}
|
||||
|
||||
async findBySubmissionKey(key: string): Promise<NocoRecord | null> {
|
||||
await this.delay();
|
||||
return this.rows.find((r) => r[COL.submissionKey] === key) ?? null;
|
||||
}
|
||||
|
||||
async search(query: string): Promise<NocoRecord[]> {
|
||||
await this.delay();
|
||||
const q = query.toLowerCase();
|
||||
return this.rows.filter(
|
||||
(r) =>
|
||||
String(r[COL.name] ?? "").toLowerCase().includes(q) ||
|
||||
String(r[COL.email] ?? "").toLowerCase().includes(q),
|
||||
);
|
||||
}
|
||||
|
||||
async create(fields: Record<string, unknown>): Promise<NocoRecord> {
|
||||
await this.delay();
|
||||
const rec = { Id: this.nextId++, ...fields } as NocoRecord;
|
||||
this.rows.push(rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
async update(id: number, fields: Record<string, unknown>): Promise<NocoRecord> {
|
||||
await this.delay();
|
||||
if (this.failNext) {
|
||||
this.failNext = false;
|
||||
throw new Error("simulated NocoDB failure");
|
||||
}
|
||||
const rec = this.rows.find((r) => r.Id === id);
|
||||
if (!rec) throw new Error("not found");
|
||||
Object.assign(rec, fields);
|
||||
return rec;
|
||||
}
|
||||
|
||||
async ping(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function fakeContext(db: FakeNocoDB): AppContext {
|
||||
return {
|
||||
config: {} as any,
|
||||
nocodb: db as any,
|
||||
mailer: { isBlockedRecipient: () => false, sendTicket: async () => {} } as any,
|
||||
queue: new RedeemQueue(2000),
|
||||
};
|
||||
}
|
||||
|
||||
export async function seedTicket(
|
||||
db: FakeNocoDB,
|
||||
opts: { code: string; name?: string; email?: string; ages?: Record<string, number>; redeemed?: number },
|
||||
): Promise<NocoRecord> {
|
||||
const ages = opts.ages ?? { "Ages 18-25": 2, "Ages 26-45": 3, "Ages 0-3": 1 };
|
||||
return db.create({
|
||||
[COL.code]: opts.code,
|
||||
[COL.name]: opts.name ?? "Test Bear",
|
||||
[COL.email]: opts.email ?? "test@example.com",
|
||||
[COL.redeemed]: opts.redeemed ?? 0,
|
||||
...ages,
|
||||
});
|
||||
}
|
||||
46
backend/src/test/fields.test.ts
Normal file
46
backend/src/test/fields.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { computeTotal, toView, COL } from "../fields.js";
|
||||
|
||||
describe("computeTotal", () => {
|
||||
it("sums age brackets but excludes Ages 0-3 (free)", () => {
|
||||
const rec = {
|
||||
Id: 1,
|
||||
"Ages 0-3": 2, // free, not counted
|
||||
"Ages 4-7": 1,
|
||||
"Ages 18-25": 2,
|
||||
"Ages 26-45": 1,
|
||||
};
|
||||
expect(computeTotal(rec)).toBe(4);
|
||||
});
|
||||
|
||||
it("coerces string counts and treats blanks as 0", () => {
|
||||
const rec = { Id: 1, "Ages 18-25": "3", "Ages 26-45": "" } as any;
|
||||
expect(computeTotal(rec)).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toView", () => {
|
||||
it("derives remaining and surfaces extras", () => {
|
||||
const rec = {
|
||||
Id: 7,
|
||||
[COL.code]: "BC26-ABCD-2345",
|
||||
[COL.name]: "Jane Bear",
|
||||
[COL.email]: "jane@example.com",
|
||||
[COL.redeemed]: 2,
|
||||
[COL.carParking]: true,
|
||||
[COL.iceAccess]: "yes",
|
||||
"Ages 0-3": 1,
|
||||
"Ages 18-25": 2,
|
||||
"Ages 26-45": 3,
|
||||
};
|
||||
const v = toView(rec);
|
||||
expect(v.total).toBe(5);
|
||||
expect(v.redeemed).toBe(2);
|
||||
expect(v.remaining).toBe(3);
|
||||
expect(v.extras.carParking).toBe(true);
|
||||
expect(v.extras.iceAccess).toBe(true);
|
||||
expect(v.extras.rvParking).toBe(false);
|
||||
expect(v.extras.freeUnder4).toBe(1);
|
||||
expect(v.ages.find((a) => a.bracket === "0-3")?.free).toBe(true);
|
||||
});
|
||||
});
|
||||
122
backend/src/test/redeem.test.ts
Normal file
122
backend/src/test/redeem.test.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { FakeNocoDB, fakeContext, seedTicket } from "./fakeNocodb.js";
|
||||
import { redeem, lookupByCode, createTicket } from "../ticketService.js";
|
||||
import { COL } from "../fields.js";
|
||||
|
||||
describe("redeem", () => {
|
||||
it("checks in a single walk-up (default count 1)", async () => {
|
||||
const db = new FakeNocoDB();
|
||||
await seedTicket(db, { code: "BC26-AAAA-1111", ages: { "Ages 26-45": 4 } });
|
||||
const ctx = fakeContext(db);
|
||||
const r = await redeem(ctx, "BC26-AAAA-1111", 1);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) {
|
||||
expect(r.checkedIn).toBe(1);
|
||||
expect(r.ticket.redeemed).toBe(1);
|
||||
expect(r.ticket.remaining).toBe(3);
|
||||
}
|
||||
});
|
||||
|
||||
it("supports group check-in and QR reuse across visits", async () => {
|
||||
const db = new FakeNocoDB();
|
||||
// Party of 7 (2 free under-4 not counted): total 5.
|
||||
await seedTicket(db, { code: "BC26-FAM-0001", ages: { "Ages 0-3": 2, "Ages 26-45": 2, "Ages 8-12": 3 } });
|
||||
const ctx = fakeContext(db);
|
||||
|
||||
const first = await redeem(ctx, "BC26-FAM-0001", 2); // father + son
|
||||
expect(first.ok && first.ticket.remaining).toBe(3);
|
||||
|
||||
const second = await redeem(ctx, "BC26-FAM-0001", 3); // mother + 3 daughters
|
||||
expect(second.ok && second.ticket.remaining).toBe(0);
|
||||
|
||||
const third = await redeem(ctx, "BC26-FAM-0001", 1); // nobody left
|
||||
expect(third.ok).toBe(false);
|
||||
if (!third.ok) expect(third.reason).toBe("exhausted");
|
||||
});
|
||||
|
||||
it("rejects over-redemption without mutating", async () => {
|
||||
const db = new FakeNocoDB();
|
||||
await seedTicket(db, { code: "BC26-BBBB-2222", ages: { "Ages 26-45": 2 } });
|
||||
const ctx = fakeContext(db);
|
||||
const r = await redeem(ctx, "BC26-BBBB-2222", 5);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toBe("insufficient");
|
||||
expect(db.rows[0][COL.redeemed]).toBe(0);
|
||||
});
|
||||
|
||||
it("allows negative count to undo, clamped at zero", async () => {
|
||||
const db = new FakeNocoDB();
|
||||
await seedTicket(db, { code: "BC26-CCCC-3333", ages: { "Ages 26-45": 3 }, redeemed: 2 });
|
||||
const ctx = fakeContext(db);
|
||||
const r = await redeem(ctx, "BC26-CCCC-3333", -5);
|
||||
expect(r.ok).toBe(true);
|
||||
if (r.ok) expect(r.ticket.redeemed).toBe(0);
|
||||
});
|
||||
|
||||
it("returns not_found for unknown codes", async () => {
|
||||
const ctx = fakeContext(new FakeNocoDB());
|
||||
const r = await redeem(ctx, "BC26-ZZZZ-9999", 1);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toBe("not_found");
|
||||
});
|
||||
|
||||
it("surfaces db_error when the update fails", async () => {
|
||||
const db = new FakeNocoDB();
|
||||
await seedTicket(db, { code: "BC26-DDDD-4444", ages: { "Ages 26-45": 3 } });
|
||||
db.failNext = true;
|
||||
const ctx = fakeContext(db);
|
||||
const r = await redeem(ctx, "BC26-DDDD-4444", 1);
|
||||
expect(r.ok).toBe(false);
|
||||
if (!r.ok) expect(r.reason).toBe("db_error");
|
||||
});
|
||||
|
||||
it("CONCURRENCY: 20 parallel single check-ins on a 5-ticket code yield exactly 5", async () => {
|
||||
const db = new FakeNocoDB(8);
|
||||
await seedTicket(db, { code: "BC26-RACE-0005", ages: { "Ages 26-45": 5 } });
|
||||
const ctx = fakeContext(db);
|
||||
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 20 }, () => redeem(ctx, "BC26-RACE-0005", 1)),
|
||||
);
|
||||
const successes = results.filter((r) => r.ok).length;
|
||||
expect(successes).toBe(5);
|
||||
expect(db.rows[0][COL.redeemed]).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lookupByCode", () => {
|
||||
it("returns the ticket view without mutating", async () => {
|
||||
const db = new FakeNocoDB();
|
||||
await seedTicket(db, { code: "BC26-LOOK-0001", ages: { "Ages 26-45": 3 } });
|
||||
const ctx = fakeContext(db);
|
||||
const r = await lookupByCode(ctx, "BC26-LOOK-0001");
|
||||
expect(r.ok && r.found && r.ticket.remaining).toBe(3);
|
||||
expect(db.rows[0][COL.redeemed]).toBe(0);
|
||||
});
|
||||
|
||||
it("reports not found", async () => {
|
||||
const ctx = fakeContext(new FakeNocoDB());
|
||||
const r = await lookupByCode(ctx, "BC26-NONE-0000");
|
||||
expect(r.ok && !("found" in r ? false : true));
|
||||
if (r.ok) expect(r.found).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTicket idempotency", () => {
|
||||
it("does not create a second row for the same submission key", async () => {
|
||||
const db = new FakeNocoDB();
|
||||
const ctx = fakeContext(db);
|
||||
const input = {
|
||||
name: "Jane Bear",
|
||||
email: "jane@example.com",
|
||||
ages: { "Ages 26-45": 2 },
|
||||
submissionKey: "sub:412",
|
||||
};
|
||||
const a = await createTicket(ctx, input);
|
||||
const b = await createTicket(ctx, input);
|
||||
expect(a.status).toBe("created");
|
||||
expect(b.status).toBe("duplicate");
|
||||
expect(b.code).toBe(a.code);
|
||||
expect(db.rows.length).toBe(1);
|
||||
});
|
||||
});
|
||||
139
backend/src/ticketService.ts
Normal file
139
backend/src/ticketService.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import type { AppContext } from "./context.js";
|
||||
import { generateCode } from "./services/code.js";
|
||||
import { COL, toView, computeTotal, type NocoRecord, type TicketView } from "./fields.js";
|
||||
|
||||
export type { TicketView };
|
||||
|
||||
export type LookupResult =
|
||||
| { ok: true; found: true; ticket: TicketView }
|
||||
| { ok: true; found: false }
|
||||
| { ok: false; reason: "db_error"; detail: string };
|
||||
|
||||
/** Read a ticket by code. No mutation. Used the instant a QR is scanned. */
|
||||
export async function lookupByCode(ctx: AppContext, code: string): Promise<LookupResult> {
|
||||
try {
|
||||
const rec = await ctx.nocodb.findByCode(code);
|
||||
if (!rec) return { ok: true, found: false };
|
||||
return { ok: true, found: true, ticket: toView(rec) };
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: "db_error", detail: e?.message ?? "lookup failed" };
|
||||
}
|
||||
}
|
||||
|
||||
export type RedeemResult =
|
||||
| { ok: true; ticket: TicketView; checkedIn: number }
|
||||
| {
|
||||
ok: false;
|
||||
reason: "not_found" | "exhausted" | "insufficient" | "db_error";
|
||||
ticket?: TicketView;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Redeem `count` tickets against a code. Serialized per-code so concurrent
|
||||
* scans at multiple gates can never over-redeem.
|
||||
*
|
||||
* A single QR is reusable across visits until Redeemed reaches Total (e.g. a
|
||||
* family splitting into two arrivals). `count` is how many people are entering
|
||||
* on THIS visit (default 1 for a single walk-up).
|
||||
*
|
||||
* Positive count that exceeds the remaining balance is rejected (staff can't
|
||||
* check in more people than the ticket allows). Negative count undoes a
|
||||
* mistaken check-in, clamped so Redeemed never drops below 0.
|
||||
*/
|
||||
export async function redeem(ctx: AppContext, code: string, count: number): Promise<RedeemResult> {
|
||||
const n = Math.trunc(count);
|
||||
if (!Number.isFinite(n) || n === 0) {
|
||||
return { ok: false, reason: "insufficient", detail: "count must be a non-zero integer" };
|
||||
}
|
||||
|
||||
return ctx.queue.run(code, async () => {
|
||||
let rec: NocoRecord | null;
|
||||
try {
|
||||
rec = await ctx.nocodb.findByCode(code);
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: "db_error", detail: e?.message ?? "lookup failed" };
|
||||
}
|
||||
if (!rec) return { ok: false, reason: "not_found" };
|
||||
|
||||
const total = computeTotal(rec);
|
||||
const redeemed = Number(rec[COL.redeemed]) || 0;
|
||||
const remaining = Math.max(0, total - redeemed);
|
||||
|
||||
if (n > 0 && remaining === 0) {
|
||||
return { ok: false, reason: "exhausted", ticket: toView(rec) };
|
||||
}
|
||||
if (n > 0 && n > remaining) {
|
||||
return { ok: false, reason: "insufficient", ticket: toView(rec) };
|
||||
}
|
||||
|
||||
const next = Math.min(total, Math.max(0, redeemed + n));
|
||||
try {
|
||||
const updated = await ctx.nocodb.update(rec.Id, {
|
||||
[COL.redeemed]: next,
|
||||
[COL.lastScanAt]: new Date().toISOString(),
|
||||
});
|
||||
// Trust our computed `next` but prefer the DB's echoed value if present.
|
||||
const confirmed = { ...rec, [COL.redeemed]: Number(updated?.[COL.redeemed] ?? next) };
|
||||
return { ok: true, ticket: toView(confirmed), checkedIn: next - redeemed };
|
||||
} catch (e: any) {
|
||||
return { ok: false, reason: "db_error", ticket: toView(rec), detail: e?.message ?? "update failed" };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Substring search by name/email for the admin panel. */
|
||||
export async function search(ctx: AppContext, query: string): Promise<TicketView[]> {
|
||||
const rows = await ctx.nocodb.search(query);
|
||||
return rows.map(toView);
|
||||
}
|
||||
|
||||
export interface WebhookInput {
|
||||
name: string;
|
||||
email: string;
|
||||
address?: string;
|
||||
isDonor?: boolean;
|
||||
carParking?: boolean;
|
||||
rvParking?: boolean;
|
||||
iceAccess?: boolean;
|
||||
paymentMethod?: string;
|
||||
ages: Record<string, number>; // NocoDB age-column title -> count
|
||||
submissionKey: string;
|
||||
}
|
||||
|
||||
/** Idempotent ticket creation from a purchase webhook. Returns the code. */
|
||||
export async function createTicket(
|
||||
ctx: AppContext,
|
||||
input: WebhookInput,
|
||||
): Promise<{ status: "created" | "duplicate"; code: string; record: NocoRecord }> {
|
||||
const existing = await ctx.nocodb.findBySubmissionKey(input.submissionKey);
|
||||
if (existing) {
|
||||
return { status: "duplicate", code: String(existing[COL.code] ?? ""), record: existing };
|
||||
}
|
||||
|
||||
// Generate a unique code, retrying on the rare collision.
|
||||
let code = generateCode();
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const clash = await ctx.nocodb.findByCode(code);
|
||||
if (!clash) break;
|
||||
code = generateCode();
|
||||
}
|
||||
|
||||
const fields: Record<string, unknown> = {
|
||||
[COL.name]: input.name,
|
||||
[COL.email]: input.email,
|
||||
[COL.code]: code,
|
||||
[COL.redeemed]: 0,
|
||||
[COL.submissionKey]: input.submissionKey,
|
||||
...input.ages,
|
||||
};
|
||||
if (input.address !== undefined) fields[COL.address] = input.address;
|
||||
if (input.isDonor !== undefined) fields[COL.isDonor] = input.isDonor;
|
||||
if (input.carParking !== undefined) fields[COL.carParking] = input.carParking;
|
||||
if (input.rvParking !== undefined) fields[COL.rvParking] = input.rvParking;
|
||||
if (input.iceAccess !== undefined) fields[COL.iceAccess] = input.iceAccess;
|
||||
if (input.paymentMethod !== undefined) fields[COL.paymentMethod] = input.paymentMethod;
|
||||
|
||||
const record = await ctx.nocodb.create(fields);
|
||||
return { status: "created", code, record };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue