- 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
287 lines
16 KiB
TypeScript
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 };
|
|
}
|
|
}
|