import type { AppContext } from "./context.js"; import { generateCode } from "./services/code.js"; import { COL, toView, computeTotal, computeIceTotal, type NocoRecord, type TicketView } from "./fields.js"; export type { TicketView }; export type Resource = "tickets" | "ice"; interface ResourceConfig { totalFn: (rec: NocoRecord) => number; redeemedCol: string; auditIn: "check-in" | "ice"; auditUndo: "undo" | "ice-undo"; } const RESOURCES: Record = { tickets: { totalFn: computeTotal, redeemedCol: COL.redeemed, auditIn: "check-in", auditUndo: "undo" }, ice: { totalFn: computeIceTotal, redeemedCol: COL.iceRedeemed, auditIn: "ice", auditUndo: "ice-undo" }, }; 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 { 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, resource: Resource = "tickets", operator = "", ): Promise { const n = Math.trunc(count); if (!Number.isFinite(n) || n === 0) { return { ok: false, reason: "insufficient", detail: "count must be a non-zero integer" }; } const cfg = RESOURCES[resource]; // Serialize per (code, resource) so ice and ticket redemptions don't block // each other but same-resource scans still can't double-spend. return ctx.queue.run(`${resource}:${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 = cfg.totalFn(rec); const redeemed = Number(rec[cfg.redeemedCol]) || 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, { [cfg.redeemedCol]: next, [COL.lastScanAt]: new Date().toISOString(), }); // Trust our computed `next` but prefer the DB's echoed value if present. const confirmed = { ...rec, [cfg.redeemedCol]: Number(updated?.[cfg.redeemedCol] ?? next) }; const view = toView(confirmed); const delta = next - redeemed; const remainingAfter = resource === "ice" ? view.ice.remaining : view.remaining; if (delta !== 0) { // Non-fatal: audit failures never block a check-in. await ctx.audit.log({ code: view.code, people: delta, name: view.name, operator, remainingAfter, at: new Date().toISOString(), action: delta >= 0 ? cfg.auditIn : cfg.auditUndo, }); } return { ok: true, ticket: view, checkedIn: delta }; } 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 { 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; iceBags?: number; // prepaid ice bags paymentMethod?: string; ages: Record; // 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 = { [COL.name]: input.name, [COL.email]: input.email, [COL.code]: code, [COL.redeemed]: 0, [COL.iceTotal]: input.iceBags ?? 0, [COL.iceRedeemed]: 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 }; }