Files
hope-events/backend/src/services/checkin.ts
T
joshuaandClaude Opus 5.5 9012ec64b6 API v2 accounts and check-in (1.51.2)
- v2: auth/signup, auth/login (+ code step, rate limited), PATCH /me; check-in look-up and
  scan returning an outcome for every normal result. Campus rule as v1.
- Sign up/login/2FA/profile moved to src/services/auth.ts; scan preview and scan to
  src/services/checkin.ts. v1 answers unchanged (ServiceError v1Status).
- Login, 2FA step, sign up and profile/password pages use v2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ls1vMdg2GYqbnKZmPaX1ot
2026-10-29 09:31:00 +02:00

287 lines
16 KiB
TypeScript

/**
* Checking a ticket in by its QR code (1.51.2). Moved from ticketController.js so
* /api/tickets/scan-preview|scan (v1) and /api/v2/checkin/{qrCode} share it.
*
* Scanning has several normal answers besides "checked in" (already used, wrong event, consent
* missing…). They come back as an `outcome`, not thrown, so each caller can show them its way;
* only an unknown code is an error (404).
*/
import { v4 as uuidv4 } from 'uuid';
import { prisma } from '../v2/legacy';
import { fail } from './errors';
import type { Role } from './registrations';
/* eslint-disable @typescript-eslint/no-explicit-any -- the JS helpers below aren't typed yet */
const transfer = () => require('../utils/ticketTransfer') as { findTransferByOldCode(code: string): Promise<any>; transferredMessage(t: any): string };
const consent = () => require('../utils/consent') as { consentStatusForTickets(ids: string[]): Promise<Map<string, string>> };
type Session = { id: string; label?: string | null; startsAt: Date };
const sessions = () => require('../utils/sessions') as {
TIMEZONE: string;
eventSchedule(eventId: string): Promise<{ sessions: Session[] } & Record<string, unknown>>;
ticketScanSession(schedule: any, list: Session[], pinned: string | null, picked: string | null, now: Date, chosen: string[] | null): { session: Session | null; error: string | null };
sessionLabel(session: Session, list: Session[]): string;
redeemedForSession(usages: Usage[], sessionId: string): number;
};
type Usage = { id?: string; quantityRedeemed: number | null; scannedAt: string; sessionId: string | null };
export type ConsentStatus = 'not_required' | 'given' | 'pending' | 'declined' | 'missing' | string;
/** A ticket that was handed on: the old code tells the scanner who has it now. */
async function transferredOutcome(qrCode: string) {
const t = transfer();
const found = await t.findTransferByOldCode(qrCode);
return found ? { outcome: 'transferred' as const, message: t.transferredMessage(found) } : null;
}
export type PreviewResult =
| { outcome: 'transferred'; message: string }
| {
outcome: 'found';
ticket: {
id: string; quantity: number; isUsed: boolean; eventId: string; consentStatus: ConsentStatus; remaining: number;
session: { id: string; label: string } | null; sessionError: string | null;
registrationOption: { id: string | null; eventOption: { id: string | null; name: string | null }; variant: { id: string; name: string } | null };
event: { id: string; title: string }; user: { id: string; name: string }; attendee: { name: string } | null;
usages: Usage[];
};
};
/** What a scan would do, without checking anything in (the scanner's preview card). */
export async function previewScan(qrCode: string, opts: { sessionId?: string | null } = {}): Promise<PreviewResult> {
// Single JOIN query instead of 6 sequential Prisma round-trips (critical for remote DBs)
const rows = await prisma.$queryRaw<any[]>`
SELECT
t.id, t."qrCode", t.quantity, t."isUsed", t."eventId",
ro.id AS "registrationOptionId", ro."sessionId" AS "pinnedSessionId", ro."sessionIds" AS "chosenSessionIds",
eo.id AS "eventOptionId", eo.name AS "eventOptionName",
ov.id AS "variantId", ov.name AS "variantName",
e.id AS "evId", e.title AS "eventTitle", e."scheduleType" AS "scheduleType",
u.id AS "userId", u.name AS "userName",
a.name AS "attendeeName",
COALESCE(
json_agg(json_build_object('quantityRedeemed', tu."quantityRedeemed", 'scannedAt', tu."scannedAt", 'sessionId', tu."sessionId"))
FILTER (WHERE tu.id IS NOT NULL),
'[]'::json
) AS usages
FROM "Ticket" t
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
LEFT JOIN "Event" e ON e.id = t."eventId"
LEFT JOIN "User" u ON u.id = t."userId"
LEFT JOIN "Attendee" a ON a.id = t."attendeeId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${qrCode}
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id
`;
if (!rows || rows.length === 0) return (await transferredOutcome(qrCode)) ?? fail(404, 'Ticket not found');
const r = rows[0];
const consentStatus = (await consent().consentStatusForTickets([r.id])).get(r.id) || 'not_required';
const usages: Usage[] = r.usages || [];
// Multi-day events: check-in is per session, so what's left is counted for the
// session this scan is for (the one picked, or today's).
let remaining = Number(r.quantity) - usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
let session: { id: string; label: string } | null = null;
let sessionError: string | null = null;
if (r.scheduleType === 'sessions') {
const s = sessions();
const schedule = await s.eventSchedule(r.eventId);
const found = s.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, opts.sessionId || null, new Date(), r.chosenSessionIds);
sessionError = found.error;
if (found.session) {
session = { id: found.session.id, label: s.sessionLabel(found.session, schedule.sessions) };
remaining = Number(r.quantity) - s.redeemedForSession(usages, found.session.id);
} else {
remaining = 0;
}
}
return {
outcome: 'found',
ticket: {
id: r.id, quantity: Number(r.quantity), isUsed: r.isUsed, eventId: r.eventId, consentStatus, remaining, session, sessionError,
registrationOption: {
id: r.registrationOptionId,
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
variant: r.variantId ? { id: r.variantId, name: r.variantName } : null,
},
event: { id: r.evId, title: r.eventTitle },
user: { id: r.userId, name: r.userName },
attendee: r.attendeeName ? { name: r.attendeeName } : null,
usages,
},
};
}
export type ScanTicket = {
id: string; quantity: number; isUsed: boolean; eventId: string; event: { title: string };
registrationOption: { eventOption: { id: string | null; name: string | null; isMainTicket: boolean | null } };
usages: Usage[];
};
export type ScanOptions = {
/** Refuse a ticket for any other event (the scanner's chosen event). */
eventId?: string | null;
/** Only tickets valid for this section. */
sectionId?: string | null;
/** Multi-day events: the session (default: today's). */
sessionId?: string | null;
/** The scanning device, so its own offline lock doesn't block it. */
deviceId?: string | null;
/** How many people to check in (default: everyone left). */
qty?: number | null;
/** Supervisors and admins: check in without parental consent (logged). */
overrideConsent?: boolean;
};
export type ScanResult =
| { outcome: 'checked_in'; message: string; session: { id: string; label: string } | null; ticketUsage: { id: string; scannedAt: Date; quantityRedeemed: number; sessionId: string | null }; ticket: ScanTicket; qtyRedeemed: number; totalRedeemed: number; remaining: number }
| { outcome: 'transferred'; message: string }
| { outcome: 'wrong_event' | 'section_not_found' | 'wrong_section' | 'no_session'; message: string; ticket: ScanTicket }
| { outcome: 'locked'; message: string }
| { outcome: 'already_used'; message: string; ticket: ScanTicket }
| { outcome: 'consent_required'; message: string; consentStatus: ConsentStatus; canOverride: boolean; ticket: ScanTicket };
/** Checks a ticket in (all of it, or `qty` people), or says why it can't be. */
export async function scanTicket(qrCode: string, actor: { id: string; role: Role }, opts: ScanOptions = {}, ip?: string): Promise<ScanResult> {
// Single JOIN query — avoids multiple sequential round-trips to the remote DB
const rows = await prisma.$queryRaw<any[]>`
SELECT
t.id, t.quantity, t."isUsed", t."eventId",
e.title AS "eventTitle", e."scheduleType" AS "scheduleType",
ro."sessionId" AS "pinnedSessionId", ro."sessionIds" AS "chosenSessionIds",
eo.id AS "eventOptionId", eo.name AS "eventOptionName", eo."isMainTicket" AS "isMainTicket",
COALESCE(
json_agg(json_build_object('id', tu.id, 'quantityRedeemed', tu."quantityRedeemed", 'scannedAt', tu."scannedAt", 'sessionId', tu."sessionId"))
FILTER (WHERE tu.id IS NOT NULL),
'[]'::json
) AS usages
FROM "Ticket" t
LEFT JOIN "Event" e ON e.id = t."eventId"
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${qrCode}
GROUP BY t.id, e.id, eo.id, ro.id
`;
if (!rows || rows.length === 0) return (await transferredOutcome(qrCode)) ?? fail(404, 'Ticket not found');
const r = rows[0];
const ticket: ScanTicket = {
id: r.id,
quantity: Number(r.quantity),
isUsed: r.isUsed,
eventId: r.eventId,
event: { title: r.eventTitle },
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
usages: r.usages || [],
};
// Ensure the ticket belongs to the event being scanned, when given
const providedEventId = String(opts.eventId || '').trim();
if (providedEventId && ticket.eventId !== providedEventId) return { outcome: 'wrong_event', message: 'Ticket belongs to a different event', ticket };
// Section checks used to happen only in the browser; enforce them here too.
const sectionId = String(opts.sectionId || '').trim() || null;
if (sectionId) {
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
if (!section || section.eventId !== ticket.eventId) return { outcome: 'section_not_found', message: 'Section not found for this event', ticket };
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) return { outcome: 'wrong_section', message: 'This ticket is not valid for this section', ticket };
}
// A section (or the whole event) locked to another device for offline scanning
// can't be scanned online — those scans would be invisible to the offline device.
const { blockingLock } = require('../utils/scanLocks') as { blockingLock(target: object, opts: object): Promise<any> };
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: opts.deviceId || null });
if (lock) {
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
return { outcome: 'locked', message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.` };
}
// Multi-day events: the check-in is for one session (the one picked, or today's), and
// what's left is counted per session.
let session: Session | null = null;
let schedule: { sessions: Session[] } | null = null;
const s = sessions();
if (r.scheduleType === 'sessions') {
schedule = await s.eventSchedule(ticket.eventId);
const found = s.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, String(opts.sessionId || '').trim() || null, new Date(), r.chosenSessionIds);
if (!found.session) return { outcome: 'no_session', message: found.error || 'No session is open for check-in now', ticket };
session = found.session;
}
const label = session && schedule ? s.sessionLabel(session, schedule.sessions) : '';
const totalRedeemed = session
? s.redeemedForSession(ticket.usages, session.id)
: ticket.usages.reduce((sum, u) => sum + (u.quantityRedeemed || 1), 0);
const remaining = (ticket.quantity || 1) - totalRedeemed;
if (remaining <= 0) {
if (session) {
const last = ticket.usages.filter(u => u.sessionId === session!.id).sort((a, b) => +new Date(b.scannedAt) - +new Date(a.scannedAt))[0];
const at = last ? new Date(last.scannedAt).toLocaleTimeString('en-ZA', { timeZone: s.TIMEZONE, hour: '2-digit', minute: '2-digit' }) : '';
return { outcome: 'already_used', message: `Already checked in for ${label}${at ? ` at ${at}` : ''}`, ticket };
}
return { outcome: 'already_used', message: 'Ticket has already been fully used', ticket };
}
// Youth events: no check-in without parental consent, unless a supervisor overrides.
const consentStatus = (await consent().consentStatusForTickets([ticket.id])).get(ticket.id) || 'not_required';
if (!['not_required', 'given'].includes(consentStatus)) {
const canOverride = ['admin', 'supervisor'].includes(actor.role);
if (!(opts.overrideConsent === true && canOverride)) {
const why = consentStatus === 'declined' ? 'Parental consent was declined' : consentStatus === 'pending' ? 'Parental consent is still pending' : 'Parental consent is missing';
return { outcome: 'consent_required', message: `${why} — this ticket can't be checked in.`, consentStatus, canOverride, ticket };
}
const { logAdminAction } = require('../utils/adminAudit') as { logAdminAction(entry: object): Promise<void> };
logAdminAction({ actorId: actor.id, actorRole: actor.role, action: 'consent_override', targetType: 'Ticket', targetId: ticket.id, metadata: { consentStatus, eventId: ticket.eventId }, ip });
}
// How many to check in this scan (defaults to all remaining)
const requestedQty = Math.trunc(Number(opts.qty)) || remaining;
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
const newTotalRedeemed = totalRedeemed + qtyToRedeem;
const newRemaining = (ticket.quantity || 1) - newTotalRedeemed;
// The usage insert and the ticket's status don't depend on each other
const [ticketUsage] = await Promise.all([
prisma.ticketUsage.create({
data: { id: uuidv4(), ticketId: ticket.id, scannedById: actor.id, quantityRedeemed: qtyToRedeem, sessionId: session ? session.id : null },
}),
// A multi-session ticket is never "used up" — it's checked in again next session.
prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: session ? false : newRemaining <= 0, updatedAt: new Date() } }),
]);
// Only Main Tickets trigger a check-in notification
if (ticket.registrationOption.eventOption.isMainTicket) {
(require('../utils/notifications') as { sendCheckInEmails(id: string, qty: number, total: number, left: number): Promise<unknown> })
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
.catch(e => console.error('Failed to send check-in notification:', e));
}
return {
outcome: 'checked_in',
message: `Ticket scanned successfully${session ? ` for ${label}` : ''} (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
session: session ? { id: session.id, label } : null,
ticketUsage,
ticket,
qtyRedeemed: qtyToRedeem,
totalRedeemed: newTotalRedeemed,
remaining: newRemaining,
};
}
/** The HTTP status and body v1 (/api/tickets/scan/:qrCode) has always answered for each outcome. */
export function v1ScanResponse(result: ScanResult): { status: number; body: Record<string, unknown> } {
const { outcome, ...rest } = result;
switch (outcome) {
case 'checked_in': return { status: 200, body: rest };
case 'transferred': return { status: 410, body: { ...rest, code: 'TICKET_TRANSFERRED' } };
case 'locked': return { status: 423, body: { ...rest, code: 'SCAN_LOCKED' } };
case 'no_session': return { status: 400, body: { ...rest, code: 'NO_SESSION' } };
case 'already_used': return { status: 403, body: { ...rest, remaining: 0 } };
case 'consent_required': return { status: 403, body: { ...rest, code: 'CONSENT_REQUIRED' } };
default: return { status: 400, body: rest };
}
}