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>
152 lines
6.5 KiB
JavaScript
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 };
|