CampgroundTickets/backend/src/ticketService.ts
Hank 0e8fe3bb9a 4-digit auto-submit PIN + operator name in audit logs
Login: fixed-length 4-digit PIN that auto-submits on the 4th digit (no submit
button to scroll to on small iPhone screens) and clears on a wrong PIN.
Compact, vertically-centered keypad so it fits without scrolling.

Operator tracking: after PIN auth, staff enter their name (new /operator
screen, persisted per device). The name is sent as X-Operator on every authed
request and recorded on each check-in/undo/ice audit entry (new Operator
column), so logs show who did what. Shown in the scanner header and the admin
audit view.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 16:45:23 +00:00

180 lines
6.4 KiB
TypeScript

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<Resource, ResourceConfig> = {
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<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,
resource: Resource = "tickets",
operator = "",
): 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" };
}
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<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;
iceBags?: number; // prepaid ice bags
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.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 };
}