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

50
backend/src/config.ts Normal file
View 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
View 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
View 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 };

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);
}

66
backend/src/server.ts Normal file
View 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);
});
}

View 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, ""));
}

View 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 === "&" ? "&amp;" : c === "<" ? "&lt;" : c === ">" ? "&gt;" : "&quot;",
);
}
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");
}

View 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();
}

View 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,
});
}

View 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);
},
);
});
}
}

View 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);
});
});

View 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,
});
}

View 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);
});
});

View 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);
});
});

View 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 };
}