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>
180 lines
6.4 KiB
TypeScript
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 };
|
|
}
|