- Waitlist per session: entries for pick-sessions tickets name a session; offers use that session's free places, never run past its start, and claim links tick it. Held offers count towards session capacity. - At the door: the Select items pop-up has the session picker, with today's session preselected for new attendees. - On pick-sessions events every ticket shows the session list straight away. "All sessions" tickets record the ticked sessions (RegistrationOption.sessionIds) at one price; scanning, attendance, certificates, reminders and capacity follow them. - Fix: per-session stock limits weren't enforced (takenCount filtered WaitlistEntry by a sessionId column that didn't exist, and the error was swallowed), and the public event page lost its stock info. - Fix: editing a door registration with picked sessions couldn't be saved. - Fix: the offline-scanning service worker served /_next/static cache-first forever, so dev browsers that had opened the scanner ran stale code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
243 lines
13 KiB
JavaScript
243 lines
13 KiB
JavaScript
/**
|
|
* Offline scanning — locks, snapshots and syncing.
|
|
*
|
|
* Rules:
|
|
* - Offline mode is per section (or the whole event), and only one device may hold a
|
|
* scope at a time: while offline, devices can't see each other's scans, so two
|
|
* devices on the same scope could redeem one ticket twice. A whole-event lock clashes
|
|
* with every section of that event, and a section lock with the whole event.
|
|
* - While a scope is locked, other devices can't scan it online either.
|
|
* - The lock holder downloads a snapshot of valid tickets; scans made offline are
|
|
* queued on the device and synced later. Syncing is idempotent (each scan carries a
|
|
* clientScanId) and never redeems more than is left on a ticket.
|
|
* - Anything that needs a person to look — unknown ticket, cancelled registration, used
|
|
* up, outside the locked scope, consent not given — goes into an OfflineSyncReport.
|
|
* - A supervisor can force-release a lock (lost phone). If that device syncs later its
|
|
* scans are still recorded, and the report is flagged.
|
|
* - Multi-day events: the snapshot counts what's left for the session running when it was
|
|
* downloaded, and each synced scan counts for the session at its scan time.
|
|
*/
|
|
|
|
const prisma = require('../config/db');
|
|
|
|
function lockError(message, statusCode = 400) {
|
|
const err = new Error(message);
|
|
err.statusCode = statusCode;
|
|
return err;
|
|
}
|
|
|
|
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
|
|
|
/** Whether a new lock on `sectionId` (null = whole event) clashes with `existing`. */
|
|
function locksClash(existing, sectionId) {
|
|
if (!existing.sectionId || !sectionId) return true;
|
|
return existing.sectionId === sectionId;
|
|
}
|
|
|
|
/**
|
|
* Whether `lock` covers a scan of a ticket with option `optionId`. When the scanner has
|
|
* picked a section, only a lock on that section (or the whole event) covers it; with no
|
|
* section picked, any lock on a section that admits the ticket does.
|
|
* @param {object} lock - { sectionId, section?: { allowedOptions: [{ eventOptionId }] } }
|
|
*/
|
|
function lockCovers(lock, { sectionId = null, optionId }) {
|
|
if (!lock.sectionId) return true;
|
|
if (sectionId) return sectionId === lock.sectionId;
|
|
return (lock.section?.allowedOptions || []).some(o => o.eventOptionId === optionId);
|
|
}
|
|
|
|
/**
|
|
* What to do with one synced scan, given the ticket as the server sees it now. Pure.
|
|
* @param {object|null} ticket - { remaining, cancelled, inScope, consentOk }
|
|
* @param {number} qty - quantity the device redeemed
|
|
* @returns {{ redeem: number, conflict: string|null }}
|
|
*/
|
|
function judgeSyncedScan(ticket, qty) {
|
|
if (!ticket) return { redeem: 0, conflict: 'not_found' };
|
|
if (ticket.transferred) return { redeem: 0, conflict: 'transferred' };
|
|
if (ticket.noSession) return { redeem: 0, conflict: 'no_session' };
|
|
if (ticket.wrongSession) return { redeem: 0, conflict: 'wrong_session' };
|
|
if (ticket.cancelled) return { redeem: 0, conflict: 'cancelled' };
|
|
if (!ticket.inScope) return { redeem: 0, conflict: 'wrong_scope' };
|
|
if (ticket.remaining <= 0) return { redeem: 0, conflict: 'already_used' };
|
|
const redeem = Math.min(Math.max(1, qty || 1), ticket.remaining);
|
|
if (!ticket.consentOk) return { redeem, conflict: 'consent' };
|
|
return { redeem, conflict: redeem < (qty || 1) ? 'partly_used' : null };
|
|
}
|
|
|
|
const CONFLICT_TEXT = {
|
|
not_found: 'Ticket not found',
|
|
transferred: 'Old QR code of a ticket that was transferred to someone else',
|
|
cancelled: 'Registration was cancelled',
|
|
wrong_scope: 'Ticket is not valid for this section',
|
|
already_used: 'Ticket had already been fully used',
|
|
partly_used: 'Fewer places were left than were checked in offline',
|
|
consent: 'Checked in without parental consent',
|
|
no_session: 'Scanned on a day (or at a time) with no session open for check-in',
|
|
wrong_session: 'Ticket is for a different session',
|
|
};
|
|
|
|
// ─── Database helpers ─────────────────────────────────────────────────────────
|
|
|
|
const LOCK_INCLUDE = {
|
|
section: { include: { allowedOptions: true } },
|
|
user: { select: { id: true, name: true } },
|
|
};
|
|
|
|
async function activeLocks(eventId, db = prisma) {
|
|
return db.scanLock.findMany({ where: { eventId, releasedAt: null }, include: LOCK_INCLUDE, orderBy: { lockedAt: 'asc' } });
|
|
}
|
|
|
|
/** Locks a scope to this device. Re-locking by the same device and person returns the existing lock. */
|
|
async function acquireLock({ eventId, sectionId = null, deviceId, deviceName, userId }) {
|
|
if (!deviceId || typeof deviceId !== 'string' || deviceId.length > 100) throw lockError('A device id is required');
|
|
return prisma.$transaction(async (tx) => {
|
|
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'scanlock:' + eventId}))`;
|
|
const event = await tx.event.findUnique({ where: { id: eventId } });
|
|
if (!event) throw lockError('Event not found', 404);
|
|
if (sectionId) {
|
|
const section = await tx.section.findUnique({ where: { id: sectionId } });
|
|
if (!section || section.eventId !== eventId) throw lockError('Section not found for this event', 404);
|
|
}
|
|
const existing = await activeLocks(eventId, tx);
|
|
const same = existing.find(l => (l.sectionId || null) === (sectionId || null) && l.deviceId === deviceId && l.userId === userId);
|
|
if (same) return same;
|
|
const clash = existing.find(l => locksClash(l, sectionId));
|
|
if (clash) {
|
|
const scope = clash.section ? `"${clash.section.name}"` : 'the whole event';
|
|
throw lockError(`${scope} is offline-locked by ${clash.user.name}${clash.deviceName ? ` on ${clash.deviceName}` : ''}.`, 409);
|
|
}
|
|
return tx.scanLock.create({ data: { eventId, sectionId: sectionId || null, deviceId, deviceName: deviceName ? String(deviceName).slice(0, 100) : null, userId }, include: LOCK_INCLUDE });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The lock (other than the scanner's own device) that stops this online scan, or null.
|
|
* @param {object} ticket - { eventId, optionId }
|
|
*/
|
|
async function blockingLock(ticket, { sectionId = null, deviceId = null } = {}) {
|
|
const locks = await activeLocks(ticket.eventId);
|
|
return locks.find(l => l.deviceId !== deviceId && lockCovers(l, { sectionId, optionId: ticket.optionId })) || null;
|
|
}
|
|
|
|
/** Tickets valid in a lock's scope, for the device to keep offline. */
|
|
async function snapshotFor(lock) {
|
|
const optionIds = lock.section ? lock.section.allowedOptions.map(o => o.eventOptionId) : null;
|
|
const tickets = await prisma.ticket.findMany({
|
|
where: {
|
|
eventId: lock.eventId,
|
|
registrationOption: { registration: { status: { not: 'cancelled' } }, ...(optionIds ? { eventOptionId: { in: optionIds } } : {}) },
|
|
},
|
|
include: {
|
|
usages: { select: { quantityRedeemed: true, sessionId: true } },
|
|
attendee: { select: { name: true } },
|
|
user: { select: { name: true } },
|
|
registrationOption: { include: { eventOption: { select: { id: true, name: true, isMainTicket: true } }, variant: { select: { name: true } } } },
|
|
},
|
|
});
|
|
const { consentStatusForTickets } = require('./consent');
|
|
const consent = await consentStatusForTickets(tickets.map(t => t.id));
|
|
const snapshotAt = new Date();
|
|
await prisma.scanLock.update({ where: { id: lock.id }, data: { snapshotAt } });
|
|
const sessionsUtil = require('./sessions');
|
|
const schedule = await sessionsUtil.eventSchedule(lock.eventId);
|
|
const session = schedule?.scheduleType === 'sessions' ? sessionsUtil.currentSession(schedule.sessions, snapshotAt, schedule.checkInWindow) : null;
|
|
return {
|
|
snapshotAt,
|
|
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
|
|
noSessionToday: schedule?.scheduleType === 'sessions' && !session,
|
|
tickets: tickets.map(t => {
|
|
// A ticket for other sessions (picked, or ticked on an all-sessions ticket) has nothing left today.
|
|
const otherSession = !!(session ? !sessionsUtil.lineCovers(t.registrationOption, session.id) : t.registrationOption.sessionId);
|
|
const redeemed = schedule?.scheduleType === 'sessions'
|
|
? (session && !otherSession ? sessionsUtil.redeemedForSession(t.usages, session.id) : t.quantity || 1)
|
|
: t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
|
return {
|
|
id: t.id,
|
|
qrCode: t.qrCode,
|
|
holder: t.attendee?.name || t.user?.name || '',
|
|
bookedBy: t.attendee ? t.user?.name || '' : '',
|
|
option: t.registrationOption.eventOption.name,
|
|
variant: t.registrationOption.variant?.name || null,
|
|
quantity: t.quantity || 1,
|
|
remaining: Math.max(0, (t.quantity || 1) - redeemed),
|
|
consentStatus: consent.get(t.id) || 'not_required',
|
|
};
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Records queued offline scans. Each scan: { clientScanId, qrCode, qty, scannedAt }.
|
|
* @returns {Promise<{ accepted, duplicates, conflicts, report }>}
|
|
*/
|
|
async function syncScans(lock, scans) {
|
|
const results = { accepted: 0, duplicates: 0, conflicts: [] };
|
|
const optionIds = lock.section ? new Set(lock.section.allowedOptions.map(o => o.eventOptionId)) : null;
|
|
const { consentStatusForTickets } = require('./consent');
|
|
const checkIns = [];
|
|
const sessionsUtil = require('./sessions');
|
|
const schedule = await sessionsUtil.eventSchedule(lock.eventId);
|
|
const hasSessions = schedule?.scheduleType === 'sessions';
|
|
|
|
for (const scan of (scans || []).slice(0, 2000)) {
|
|
const clientScanId = String(scan?.clientScanId || '').slice(0, 100);
|
|
if (!clientScanId) continue;
|
|
if (await prisma.ticketUsage.findUnique({ where: { clientScanId } })) { results.duplicates += 1; continue; }
|
|
|
|
const qty = Math.max(1, parseInt(scan.qty, 10) || 1);
|
|
const scannedAt = scan.scannedAt && !isNaN(new Date(scan.scannedAt)) ? new Date(scan.scannedAt) : new Date();
|
|
const ticket = await prisma.ticket.findUnique({
|
|
where: { qrCode: String(scan.qrCode || '') },
|
|
include: { usages: true, registrationOption: { include: { registration: { select: { status: true } }, eventOption: { select: { isMainTicket: true } } } } },
|
|
});
|
|
let state = null;
|
|
if (!ticket) {
|
|
const { findTransferByOldCode } = require('./ticketTransfer');
|
|
if (await findTransferByOldCode(scan.qrCode)) state = { transferred: true };
|
|
} else if (ticket.eventId === lock.eventId) {
|
|
const session = hasSessions ? sessionsUtil.currentSession(schedule.sessions, scannedAt, schedule.checkInWindow) : null;
|
|
const redeemed = session
|
|
? sessionsUtil.redeemedForSession(ticket.usages, session.id)
|
|
: ticket.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
|
const consent = (await consentStatusForTickets([ticket.id])).get(ticket.id);
|
|
state = {
|
|
sessionId: session?.id || null,
|
|
noSession: hasSessions && !session,
|
|
wrongSession: !!(session && !sessionsUtil.lineCovers(ticket.registrationOption, session.id)),
|
|
remaining: (ticket.quantity || 1) - redeemed,
|
|
cancelled: ticket.registrationOption.registration.status === 'cancelled',
|
|
inScope: !optionIds || optionIds.has(ticket.registrationOption.eventOptionId),
|
|
consentOk: ['not_required', 'given'].includes(consent),
|
|
};
|
|
}
|
|
const verdict = judgeSyncedScan(state, qty);
|
|
if (verdict.redeem > 0) {
|
|
await prisma.ticketUsage.create({
|
|
data: { ticketId: ticket.id, scannedById: lock.userId, scannedAt, quantityRedeemed: verdict.redeem, clientScanId, offline: true, scanLockId: lock.id, syncedAt: new Date(), sessionId: state.sessionId },
|
|
});
|
|
await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: !hasSessions && state.remaining - verdict.redeem <= 0, updatedAt: new Date() } });
|
|
results.accepted += 1;
|
|
if (ticket.registrationOption.eventOption.isMainTicket) {
|
|
checkIns.push({ ticketId: ticket.id, qty: verdict.redeem, total: (ticket.quantity || 1) - state.remaining + verdict.redeem, left: state.remaining - verdict.redeem });
|
|
}
|
|
}
|
|
if (verdict.conflict) {
|
|
results.conflicts.push({ clientScanId, qrCode: scan.qrCode, ticketId: ticket?.id || null, qty, redeemed: verdict.redeem, scannedAt, reason: verdict.conflict, message: CONFLICT_TEXT[verdict.conflict] });
|
|
}
|
|
}
|
|
|
|
await prisma.scanLock.update({ where: { id: lock.id }, data: { lastSyncAt: new Date() } });
|
|
const report = await prisma.offlineSyncReport.create({
|
|
data: { scanLockId: lock.id, accepted: results.accepted, duplicates: results.duplicates, conflicts: results.conflicts, afterForceRelease: !!lock.forceReleasedById },
|
|
});
|
|
|
|
// Check-in confirmations go out after sync, not at the moment of the offline scan.
|
|
const { sendCheckInEmails } = require('./notifications');
|
|
for (const c of checkIns) sendCheckInEmails(c.ticketId, c.qty, c.total, c.left).catch(() => {});
|
|
|
|
return { ...results, report };
|
|
}
|
|
|
|
module.exports = { lockError, locksClash, lockCovers, judgeSyncedScan, CONFLICT_TEXT, LOCK_INCLUDE, activeLocks, acquireLock, blockingLock, snapshotFor, syncScans };
|