Files
hope-events/backend/src/utils/scanLocks.js
T
joshuaandClaude Opus 5.5 f99b84858e Session gaps: per-session waitlist, door session picker, all-sessions choice
- 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>
2026-10-23 11:38:21 +02:00

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 };