Files
hope-events/backend/src/controllers/scanLockController.js
T
joshuaandClaude Opus 5.5 be621c49a2 Add offline ticket scanning
Staff can lock a section (or the whole event) to their device, download
its tickets and keep scanning with no connection. Scans are queued in
IndexedDB and synced idempotently when back online; conflicts go into a
sync report for supervisors, who can also force-release a lost device.
The server now validates scan sections and refuses scans in a scope
locked to another device. Adds a web app manifest and a service worker
for the scanner page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:06:22 +02:00

152 lines
6.5 KiB
JavaScript

const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
const { LOCK_INCLUDE, activeLocks, acquireLock, snapshotFor, syncScans } = require('../utils/scanLocks');
const SUPERVISORS = ['admin', 'supervisor'];
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
const audit = (req, action, lock, metadata = {}) => logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action, targetType: 'ScanLock', targetId: lock.id,
metadata: { eventId: lock.eventId, sectionId: lock.sectionId, deviceName: lock.deviceName, ...metadata },
ip: getClientIp(req),
});
const lockView = (l) => ({
id: l.id, eventId: l.eventId, sectionId: l.sectionId, sectionName: l.section?.name || null,
deviceId: l.deviceId, deviceName: l.deviceName, user: l.user, lockedAt: l.lockedAt,
snapshotAt: l.snapshotAt, lastSyncAt: l.lastSyncAt, releasedAt: l.releasedAt,
});
/** The lock, if the caller is the person holding it on this device. */
async function ownLock(req, res) {
const lock = await prisma.scanLock.findUnique({ where: { id: req.params.id }, include: LOCK_INCLUDE });
if (!lock) { res.status(404); throw new Error('Offline lock not found'); }
const deviceId = req.body?.deviceId || req.query?.deviceId;
if (lock.userId !== req.user.id || lock.deviceId !== deviceId) { res.status(403); throw new Error('This offline lock belongs to another device.'); }
return lock;
}
// @desc Active offline locks for an event
// @route GET /api/scan-locks?eventId=
// @access Private/Staff
const listLocks = async (req, res) => {
try {
if (!req.query.eventId) { res.status(400); throw new Error('eventId is required'); }
res.json((await activeLocks(String(req.query.eventId))).map(lockView));
} catch (error) {
fail(res, error);
}
};
// @desc Lock a section (or the whole event) to this device for offline scanning
// @route POST /api/scan-locks { eventId, sectionId?, deviceId, deviceName? }
// @access Private/Staff
const createLock = async (req, res) => {
try {
const { eventId, sectionId, deviceId, deviceName } = req.body || {};
if (!eventId) { res.status(400); throw new Error('eventId is required'); }
const lock = await acquireLock({ eventId, sectionId: sectionId || null, deviceId, deviceName, userId: req.user.id });
audit(req, 'scan_lock_acquired', lock);
res.status(201).json(lockView(lock));
} catch (error) {
fail(res, error);
}
};
// @desc Download the tickets valid for this lock, for offline scanning
// @route GET /api/scan-locks/:id/snapshot?deviceId=
// @access Private/Staff (lock holder)
const getSnapshot = async (req, res) => {
try {
const lock = await ownLock(req, res);
if (lock.releasedAt) { res.status(410); throw new Error('This offline lock has been released.'); }
res.json({ lock: lockView(lock), ...(await snapshotFor(lock)) });
} catch (error) {
fail(res, error);
}
};
// @desc Upload scans made offline
// @route POST /api/scan-locks/:id/sync { deviceId, scans: [{ clientScanId, qrCode, qty, scannedAt }] }
// @access Private/Staff (lock holder — also after a release, so nothing made offline is lost)
const syncLock = async (req, res) => {
try {
const lock = await ownLock(req, res);
const result = await syncScans(lock, req.body?.scans);
if (result.accepted || result.conflicts.length) {
audit(req, 'offline_sync', lock, { accepted: result.accepted, conflicts: result.conflicts.length, duplicates: result.duplicates, afterForceRelease: !!lock.forceReleasedById });
}
res.json({ accepted: result.accepted, duplicates: result.duplicates, conflicts: result.conflicts, reportId: result.report.id, released: !!lock.releasedAt });
} catch (error) {
fail(res, error);
}
};
// @desc End offline mode (the device should sync first)
// @route DELETE /api/scan-locks/:id { deviceId }
// @access Private/Staff (lock holder)
const releaseLock = async (req, res) => {
try {
const lock = await ownLock(req, res);
if (!lock.releasedAt) {
await prisma.scanLock.update({ where: { id: lock.id }, data: { releasedAt: new Date() } });
audit(req, 'scan_lock_released', lock);
}
res.json({ message: 'Offline mode ended.' });
} catch (error) {
fail(res, error);
}
};
// @desc Release someone else's lock (e.g. a lost or flat phone)
// @route POST /api/scan-locks/:id/force-release
// @access Private/Supervisor
const forceRelease = async (req, res) => {
try {
const lock = await prisma.scanLock.findUnique({ where: { id: req.params.id }, include: LOCK_INCLUDE });
if (!lock) { res.status(404); throw new Error('Offline lock not found'); }
if (lock.releasedAt) return res.json({ message: 'Already released.' });
await prisma.scanLock.update({ where: { id: lock.id }, data: { releasedAt: new Date(), forceReleasedById: req.user.id } });
audit(req, 'scan_lock_force_released', lock, { heldBy: lock.user?.name });
res.json({ message: `Released. If ${lock.user?.name || 'that device'} reconnects, its offline scans will still sync and be flagged for review.` });
} catch (error) {
fail(res, error);
}
};
// @desc Offline sync reports for an event (with anything that needs review)
// @route GET /api/scan-locks/reports?eventId=&unreviewed=true
// @access Private/Supervisor
const listReports = async (req, res) => {
try {
const where = {
scanLock: { eventId: String(req.query.eventId || '') },
...(req.query.unreviewed === 'true' ? { reviewedAt: null } : {}),
};
const reports = await prisma.offlineSyncReport.findMany({
where,
include: { scanLock: { include: LOCK_INCLUDE }, reviewedBy: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
take: 200,
});
res.json(reports.map(r => ({ ...r, scanLock: lockView(r.scanLock) })));
} catch (error) {
fail(res, error);
}
};
// @desc Mark a sync report as reviewed
// @route POST /api/scan-locks/reports/:id/review
// @access Private/Supervisor
const reviewReport = async (req, res) => {
try {
const report = await prisma.offlineSyncReport.update({ where: { id: req.params.id }, data: { reviewedAt: new Date(), reviewedById: req.user.id } });
res.json(report);
} catch (error) {
fail(res, error);
}
};
module.exports = { SUPERVISORS, listLocks, createLock, getSnapshot, syncLock, releaseLock, forceRelease, listReports, reviewReport };