Add Check-in / Ice / Banquet scan modes

- Ice mode: prepaid ice bags (Ice Total / Ice Redeemed columns) redeemed
  independently of ticket check-ins; grab all bags at once or some now.
- Banquet mode: donor total (online + offline) looked up by the ticket's
  email via the Donors Master List, with a manual email override. New
  DonorService + POST /api/banquet.
- Redeem generalized over a resource (tickets|ice); audit records ice actions.
- App gains a mode selector; webhook maps ice_bags (defaults to
  ICE_BAGS_DEFAULT when only a boolean ice option is present).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hank 2026-07-08 04:07:51 +00:00
parent b36d63a6a4
commit d293e53ee6
15 changed files with 587 additions and 125 deletions

View file

@ -76,13 +76,54 @@ export async function ticketRoutes(app: FastifyInstance): Promise<void> {
properties: {
code: { type: "string", minLength: 1, maxLength: 64 },
count: { type: "integer", minimum: -100, maximum: 100 },
resource: { type: "string", enum: ["tickets", "ice"] },
},
},
},
},
async (req) => {
const { code, count } = req.body as { code: string; count?: number };
return redeem(app.ctx, normalizeCode(code), count ?? 1);
const { code, count, resource } = req.body as {
code: string;
count?: number;
resource?: "tickets" | "ice";
};
return redeem(app.ctx, normalizeCode(code), count ?? 1, resource ?? "tickets");
},
);
// Banquet mode: total donations for the email on a ticket (or a manual email).
app.post(
"/api/banquet",
{
preHandler: requireStaff,
schema: {
body: {
type: "object",
properties: {
code: { type: "string", maxLength: 64 },
email: { type: "string", maxLength: 200 },
},
},
},
},
async (req, reply) => {
if (!app.ctx.donors.enabled) {
return reply.code(422).send({ ok: false, reason: "banquet_disabled" });
}
const { code, email } = req.body as { code?: string; email?: string };
let lookupEmail = (email ?? "").trim();
let ticketName = "";
// If a ticket code was scanned, resolve its email.
if (!lookupEmail && code) {
const res = await lookupByCode(app.ctx, normalizeCode(code));
if (!res.ok) return { ok: false, reason: "db_error", detail: res.detail };
if (!res.found) return { ok: false, reason: "not_found" };
lookupEmail = res.ticket.email;
ticketName = res.ticket.name;
}
if (!lookupEmail) return { ok: false, reason: "no_email" };
const donor = await app.ctx.donors.lookup(lookupEmail);
return { ok: true, ticketName, donor };
},
);

View file

@ -60,6 +60,15 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
.digest("hex")
.slice(0, 32);
// Ice: prefer an explicit bag count; else grant the default when a boolean
// ice option is truthy; else 0.
let iceBags = 0;
if (body.ice_bags !== undefined && body.ice_bags !== null && body.ice_bags !== "") {
iceBags = toNumber(body.ice_bags);
} else if (body.ice_access !== undefined && toBool(body.ice_access)) {
iceBags = app.ctx.config.ICE_BAGS_DEFAULT;
}
let result: Awaited<ReturnType<typeof createTicket>>;
try {
result = await createTicket(app.ctx, {
@ -70,6 +79,7 @@ export async function webhookRoutes(app: FastifyInstance): Promise<void> {
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,
iceBags,
paymentMethod: body.payment_method !== undefined ? String(body.payment_method) : undefined,
ages,
submissionKey,