Payers and holders can hand a ticket to someone else before the event. The new holder gets a new QR code (and an inactive account with an activation link if they're new); the old code stops working and scans of it, online or in offline sync, say it was transferred. One place can be split off a shared group ticket. Transfers can be turned off per event (supervisors can always transfer from At the door), youth events need the new holder's own consent, and every transfer is audited. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
219 lines
11 KiB
JavaScript
219 lines
11 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.
|
|
*/
|
|
|
|
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.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',
|
|
};
|
|
|
|
// ─── 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 } },
|
|
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 } });
|
|
return {
|
|
snapshotAt,
|
|
tickets: tickets.map(t => {
|
|
const redeemed = 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 = [];
|
|
|
|
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 redeemed = ticket.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
|
|
const consent = (await consentStatusForTickets([ticket.id])).get(ticket.id);
|
|
state = {
|
|
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() },
|
|
});
|
|
await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: 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 };
|