const prisma = require('../config/db'); const { safeErrorMessage } = require('../utils/errorUtils'); const { logAdminAction } = require('../utils/adminAudit'); const { getClientIp } = require('../utils/requestUtils'); const { allocationError, cleanGender, joinBlocker, autoAllocate } = require('../utils/allocation'); const SUPERVISORS = ['admin', 'supervisor']; const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) }); const cleanName = (v, what) => { const name = String(v || '').trim(); if (!name) throw allocationError(`Enter a name for the ${what}.`); return name.slice(0, 100); }; const cleanCapacity = (v) => { if (v === undefined || v === null || v === '') return null; const n = parseInt(v, 10); if (!Number.isFinite(n) || n < 1 || n > 10000) throw allocationError('Capacity must be a whole number of at least 1.'); return n; }; /** Named attendees on the event's live registrations. */ async function eventAttendees(eventId) { return prisma.attendee.findMany({ where: { registration: { eventId, status: { not: 'cancelled' } } }, select: { id: true, name: true, gender: true, dateOfBirth: true, registrationId: true, email: true, phone: true, registration: { select: { user: { select: { name: true } } } }, }, orderBy: [{ registrationId: 'asc' }, { order: 'asc' }], }); } async function loadSet(id) { const set = await prisma.allocationSet.findUnique({ where: { id }, include: { groups: { orderBy: { order: 'asc' } } } }); if (!set) throw allocationError('Allocation set not found', 404); return set; } // @desc Everything the allocation board needs for an event // @route GET /api/allocations/event/:eventId // @access Private/Supervisor const getBoard = async (req, res) => { try { const event = await prisma.event.findUnique({ where: { id: req.params.eventId }, select: { id: true, title: true, startDate: true, collectGender: true, allowNamedAttendees: true, requiresParentalConsent: true }, }); if (!event) throw allocationError('Event not found', 404); const [sets, attendees, leaders, places] = await Promise.all([ prisma.allocationSet.findMany({ where: { eventId: event.id }, include: { groups: { orderBy: { order: 'asc' }, include: { leader: { select: { id: true, name: true } } } }, assignments: { select: { attendeeId: true, groupId: true } } }, orderBy: [{ order: 'asc' }, { createdAt: 'asc' }], }), eventAttendees(event.id), prisma.user.findMany({ where: { role: { in: ['staff', 'supervisor', 'admin'] }, isActive: true }, select: { id: true, name: true }, orderBy: { name: 'asc' } }), prisma.registrationOption.aggregate({ where: { registration: { eventId: event.id, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } }, _sum: { quantity: true }, }), ]); res.json({ event, sets, attendees: attendees.map(a => ({ id: a.id, name: a.name, gender: a.gender, dateOfBirth: a.dateOfBirth, registrationId: a.registrationId, bookedBy: a.registration.user?.name || '' })), leaders, unnamedPlaces: Math.max(0, (places._sum.quantity || 0) - attendees.length), }); } catch (error) { fail(res, error); } }; // @route POST /api/allocations/sets { eventId, name, showOnTicket? } const createSet = async (req, res) => { try { const { eventId, name, showOnTicket } = req.body || {}; if (!(await prisma.event.findUnique({ where: { id: String(eventId || '') }, select: { id: true } }))) throw allocationError('Event not found', 404); const order = await prisma.allocationSet.count({ where: { eventId } }); res.status(201).json(await prisma.allocationSet.create({ data: { eventId, name: cleanName(name, 'set'), showOnTicket: showOnTicket === true, order } })); } catch (error) { fail(res, error); } }; // @route PUT /api/allocations/sets/:id { name?, showOnTicket? } const updateSet = async (req, res) => { try { await loadSet(req.params.id); const { name, showOnTicket } = req.body || {}; res.json(await prisma.allocationSet.update({ where: { id: req.params.id }, data: { ...(name !== undefined ? { name: cleanName(name, 'set') } : {}), ...(showOnTicket !== undefined ? { showOnTicket: showOnTicket === true } : {}) }, })); } catch (error) { fail(res, error); } }; // @route DELETE /api/allocations/sets/:id const deleteSet = async (req, res) => { try { await loadSet(req.params.id); await prisma.allocationSet.delete({ where: { id: req.params.id } }); res.json({ message: 'Deleted' }); } catch (error) { fail(res, error); } }; async function groupData(body, setId, existing = null) { const { name, capacity, gender, leaderUserId } = body || {}; const data = {}; if (name !== undefined || !existing) data.name = cleanName(name, 'group'); if (capacity !== undefined) data.capacity = cleanCapacity(capacity); if (gender !== undefined) data.gender = cleanGender(gender); if (leaderUserId !== undefined) { if (leaderUserId) { const leader = await prisma.user.findUnique({ where: { id: String(leaderUserId) }, select: { role: true } }); if (!leader || !['staff', 'supervisor', 'admin'].includes(leader.role)) throw allocationError('A group leader must be a staff member.'); } data.leaderUserId = leaderUserId || null; } // Changing capacity or gender mustn't leave the group over capacity or mixed. if (existing && (data.capacity !== undefined || data.gender !== undefined)) { const members = await prisma.allocationAssignment.findMany({ where: { groupId: existing.id }, include: { attendee: { select: { name: true, gender: true } } } }); const next = { ...existing, ...data }; const blocker = joinBlocker(next, 0, members.map(m => m.attendee)); if (blocker) throw allocationError(`${blocker} Move people out first.`); } return data; } // @route POST /api/allocations/sets/:id/groups { name, capacity?, gender?, leaderUserId? } const createGroup = async (req, res) => { try { const set = await loadSet(req.params.id); const data = await groupData(req.body, set.id); res.status(201).json(await prisma.allocationGroup.create({ data: { ...data, setId: set.id, order: set.groups.length } })); } catch (error) { fail(res, error); } }; // @route PUT /api/allocations/groups/:id const updateGroup = async (req, res) => { try { const group = await prisma.allocationGroup.findUnique({ where: { id: req.params.id } }); if (!group) throw allocationError('Group not found', 404); const data = await groupData(req.body, group.setId, group); res.json(await prisma.allocationGroup.update({ where: { id: group.id }, data })); } catch (error) { fail(res, error); } }; // @route DELETE /api/allocations/groups/:id (its people go back to unassigned) const deleteGroup = async (req, res) => { try { await prisma.allocationGroup.delete({ where: { id: req.params.id } }); res.json({ message: 'Deleted' }); } catch (error) { if (error.code === 'P2025') return res.status(404).json({ message: 'Group not found' }); fail(res, error); } }; // @desc Put an attendee in a group (groupId null = unassign) // @route PUT /api/allocations/sets/:id/assignments { attendeeId, groupId } const assign = async (req, res) => { try { const set = await loadSet(req.params.id); const { attendeeId, groupId } = req.body || {}; const attendee = await prisma.attendee.findFirst({ where: { id: String(attendeeId || ''), registration: { eventId: set.eventId, status: { not: 'cancelled' } } }, select: { id: true, name: true, gender: true }, }); if (!attendee) throw allocationError('Attendee not found for this event', 404); if (!groupId) { await prisma.allocationAssignment.deleteMany({ where: { setId: set.id, attendeeId: attendee.id } }); return res.json({ message: 'Unassigned' }); } const group = set.groups.find(g => g.id === groupId); if (!group) throw allocationError('Group not found in this set', 404); await prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'alloc:' + group.id}))`; const count = await tx.allocationAssignment.count({ where: { groupId: group.id, NOT: { attendeeId: attendee.id } } }); const blocker = joinBlocker(group, count, [attendee]); if (blocker) throw allocationError(blocker); await tx.allocationAssignment.upsert({ where: { setId_attendeeId: { setId: set.id, attendeeId: attendee.id } }, update: { groupId: group.id }, create: { setId: set.id, groupId: group.id, attendeeId: attendee.id }, }); }); res.json({ message: `${attendee.name} → ${group.name}` }); } catch (error) { fail(res, error); } }; // @desc Place everyone not yet in a group // @route POST /api/allocations/sets/:id/auto { mode: 'together' | 'split' } const autoAssign = async (req, res) => { try { const set = await loadSet(req.params.id); if (set.groups.length === 0) throw allocationError('Add some groups first.'); const mode = req.body?.mode === 'split' ? 'split' : 'together'; const result = await prisma.$transaction(async (tx) => { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'allocset:' + set.id}))`; const [attendees, existing] = await Promise.all([ eventAttendees(set.eventId), tx.allocationAssignment.findMany({ where: { setId: set.id } }), ]); const r = autoAllocate({ groups: set.groups, attendees, assigned: new Map(existing.map(a => [a.attendeeId, a.groupId])), mode }); if (r.placements.length) { await tx.allocationAssignment.createMany({ data: r.placements.map(p => ({ setId: set.id, groupId: p.groupId, attendeeId: p.attendeeId })), skipDuplicates: true }); } return r; }); res.json({ placed: result.placements.length, unplaced: result.unplaced.length, message: `${result.placements.length} placed${result.unplaced.length ? `; ${result.unplaced.length} couldn't be placed (groups full, or gender doesn't match)` : ''}.`, }); } catch (error) { fail(res, error); } }; // @route DELETE /api/allocations/sets/:id/assignments (clear the whole set) const clearAssignments = async (req, res) => { try { const set = await loadSet(req.params.id); const { count } = await prisma.allocationAssignment.deleteMany({ where: { setId: set.id } }); res.json({ message: `${count} removed from groups.` }); } catch (error) { fail(res, error); } }; /** Members of groups, with contact and (for supervisors) medical details. */ async function groupMembers(groupIds, { withMedical }) { const assignments = await prisma.allocationAssignment.findMany({ where: { groupId: { in: groupIds } }, include: { attendee: { select: { id: true, name: true, gender: true, dateOfBirth: true, phone: true, parentName: true, parentPhone: true, registration: { select: { user: { select: { name: true, phoneNumber: true } } } }, }, }, }, orderBy: { attendee: { name: 'asc' } }, }); let consent = new Map(); if (withMedical && assignments.length) { const { latestRequestsFor, openJson } = require('../utils/consent'); const latest = await latestRequestsFor(assignments.map(a => a.attendeeId)); consent = new Map([...latest].map(([id, r]) => [id, r.status === 'given' ? { medical: openJson(r.medical), emergency: openJson(r.emergency) } : null])); } return assignments.map(a => ({ groupId: a.groupId, id: a.attendee.id, name: a.attendee.name, gender: a.attendee.gender, dateOfBirth: a.attendee.dateOfBirth, phone: a.attendee.phone, parentName: a.attendee.parentName, parentPhone: a.attendee.parentPhone, bookedBy: a.attendee.registration.user?.name || '', bookedByPhone: a.attendee.registration.user?.phoneNumber || null, ...(withMedical ? consent.get(a.attendee.id) || {} : {}), })); } // @desc Lists for printing: every group of a set, with members // @route GET /api/allocations/sets/:id/lists?medical=true // @access Private/Supervisor const getSetLists = async (req, res) => { try { const set = await prisma.allocationSet.findUnique({ where: { id: req.params.id }, include: { event: { select: { id: true, title: true, startDate: true } }, groups: { orderBy: { order: 'asc' }, include: { leader: { select: { name: true } } } } }, }); if (!set) throw allocationError('Allocation set not found', 404); const withMedical = req.query.medical === 'true'; const members = await groupMembers(set.groups.map(g => g.id), { withMedical }); if (withMedical) { logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed', targetType: 'AllocationSet', targetId: set.id, metadata: { eventId: set.eventId, attendees: members.length }, ip: getClientIp(req) }); } res.json({ set: { id: set.id, name: set.name }, event: set.event, groups: set.groups.map(g => ({ id: g.id, name: g.name, capacity: g.capacity, gender: g.gender, leader: g.leader?.name || null, members: members.filter(m => m.groupId === g.id) })) }); } catch (error) { fail(res, error); } }; // @desc Groups the signed-in staff member leads, with their people // @route GET /api/allocations/my-groups // @access Private/Staff const getMyGroups = async (req, res) => { try { const groups = await prisma.allocationGroup.findMany({ where: { leaderUserId: req.user.id, set: { event: { endDate: { gte: new Date(Date.now() - 7 * 864e5) } } } }, include: { set: { include: { event: { select: { id: true, title: true, startDate: true } } } } }, orderBy: [{ set: { event: { startDate: 'asc' } } }, { order: 'asc' }], }); const withMedical = SUPERVISORS.includes(req.user.role); const members = await groupMembers(groups.map(g => g.id), { withMedical }); if (withMedical && members.length) { logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'medical_list_viewed', targetType: 'AllocationGroup', targetId: null, metadata: { groups: groups.length, attendees: members.length }, ip: getClientIp(req) }); } res.json({ medicalShown: withMedical, groups: groups.map(g => ({ id: g.id, name: g.name, capacity: g.capacity, gender: g.gender, setName: g.set.name, event: g.set.event, members: members.filter(m => m.groupId === g.id) })), }); } catch (error) { fail(res, error); } }; module.exports = { getBoard, createSet, updateSet, deleteSet, createGroup, updateGroup, deleteGroup, assign, autoAssign, clearAssignments, getSetLists, getMyGroups };