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