Supervisors can split an event's named attendees into allocation sets (rooms, small groups, buses) with capacity, gender and leader per group, using a drag-and-drop board or auto-allocate (keep bookings together or split them). Adds printable group and master lists (medical view audited), the group on tickets and in My Events, messaging by group, a "My groups" page for leaders, and an optional per-event gender question for named attendees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
324 lines
14 KiB
JavaScript
324 lines
14 KiB
JavaScript
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 };
|