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>
218 lines
9.9 KiB
JavaScript
218 lines
9.9 KiB
JavaScript
/**
|
|
* Group tickets — named attendees on a registration.
|
|
*
|
|
* Rules:
|
|
* - Only main-ticket places can be named, and only on events with
|
|
* allowNamedAttendees. Each named attendee gets their own quantity-1 ticket; any
|
|
* places left unnamed share one quantity ticket, as before.
|
|
* - The payer stays the registration owner (billing, invoices, payments). An attendee
|
|
* whose email/phone matches an existing account is linked to it, so the ticket also
|
|
* shows in that person's dashboard — unless email and phone point at two different
|
|
* accounts, in which case nothing is linked.
|
|
* - Registration.ticketDelivery = 'each' sends each attendee their own ticket by
|
|
* email/WhatsApp; attendees with no contact details fall back to the payer.
|
|
* - Youth events (requiresParentalConsent): every main place must be named with a date
|
|
* of birth, and anyone under 18 on the start date needs a parent/guardian name, email
|
|
* and phone (see utils/consent.js).
|
|
*/
|
|
|
|
const prisma = require('../config/db');
|
|
const { findAccountByContact } = require('./accountMatch');
|
|
const { isMinor } = require('./consent');
|
|
|
|
const EMAIL_RE = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
|
|
|
|
const lineKey = (eventOptionId, variantId) => `${eventOptionId}::${variantId || ''}`;
|
|
|
|
function attendeeError(message) {
|
|
const err = new Error(message);
|
|
err.statusCode = 400;
|
|
return err;
|
|
}
|
|
|
|
/**
|
|
* Checks attendees against the event and the number of places on each line. Pure.
|
|
* Returns the cleaned list (trimmed strings, nulls for blanks).
|
|
*
|
|
* @param {object} event - with allowNamedAttendees and eventOptions[{ id, isMainTicket, name }]
|
|
* @param {object} placesByKey - total places each line will have, keyed by lineKey()
|
|
* @param {Array} attendees - [{ eventOptionId, variantId?, name, email?, phone? }]
|
|
* @param {object} [namedByKey] - places on those lines already named (and being kept)
|
|
*/
|
|
function validateAttendees(event, placesByKey, attendees, namedByKey = {}) {
|
|
const consent = !!event.requiresParentalConsent;
|
|
if ((!attendees || attendees.length === 0) && !consent) return [];
|
|
if (!event.allowNamedAttendees && !consent) throw attendeeError('This event doesn\'t take attendee names.');
|
|
if (!Array.isArray(attendees || [])) throw attendeeError('attendees must be a list');
|
|
|
|
const countByKey = {};
|
|
const cleaned = (attendees || []).map((a, i) => {
|
|
const option = (event.eventOptions || []).find(o => o.id === a?.eventOptionId);
|
|
if (!option) throw attendeeError(`Attendee ${i + 1}: unknown ticket option.`);
|
|
if (!option.isMainTicket) throw attendeeError(`Attendee ${i + 1}: names can only be given for "${option.name}" main tickets.`);
|
|
const name = String(a.name || '').trim();
|
|
if (!name) throw attendeeError(`Attendee ${i + 1}: enter a name.`);
|
|
const email = String(a.email || '').trim() || null;
|
|
if (email && !EMAIL_RE.test(email)) throw attendeeError(`Attendee ${i + 1}: "${email}" isn't a valid email address.`);
|
|
const phone = String(a.phone || '').trim() || null;
|
|
const key = lineKey(a.eventOptionId, a.variantId);
|
|
countByKey[key] = (countByKey[key] || 0) + 1;
|
|
if (countByKey[key] + (namedByKey[key] || 0) > (placesByKey[key] || 0)) {
|
|
throw attendeeError(`More names than "${option.name}" tickets.`);
|
|
}
|
|
|
|
// Date of birth (always kept if given) and, for youth events, parent details.
|
|
let dateOfBirth = null;
|
|
if (a.dateOfBirth) {
|
|
dateOfBirth = new Date(a.dateOfBirth);
|
|
if (isNaN(dateOfBirth.getTime()) || dateOfBirth > new Date()) throw attendeeError(`${name}: enter a valid date of birth.`);
|
|
} else if (consent) {
|
|
throw attendeeError(`${name}: enter a date of birth.`);
|
|
}
|
|
let gender = null;
|
|
if (a.gender) {
|
|
gender = String(a.gender).trim().toLowerCase();
|
|
if (!['male', 'female'].includes(gender)) throw attendeeError(`${name}: gender must be male or female.`);
|
|
} else if (event.collectGender) {
|
|
throw attendeeError(`${name}: choose a gender.`);
|
|
}
|
|
const parentName = String(a.parentName || '').trim() || null;
|
|
const parentEmail = String(a.parentEmail || '').trim() || null;
|
|
const parentPhone = String(a.parentPhone || '').trim() || null;
|
|
if (consent && isMinor(dateOfBirth, event.startDate)) {
|
|
if (!parentName || !parentEmail || !parentPhone) {
|
|
throw attendeeError(`${name} is under 18, so a parent or guardian's name, email and phone are needed for consent.`);
|
|
}
|
|
if (!EMAIL_RE.test(parentEmail)) throw attendeeError(`${name}: "${parentEmail}" isn't a valid parent email address.`);
|
|
}
|
|
return { eventOptionId: a.eventOptionId, variantId: a.variantId || null, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone };
|
|
});
|
|
|
|
// Youth events need every main place named (with a date of birth).
|
|
if (consent) {
|
|
for (const o of event.eventOptions || []) {
|
|
if (!o.isMainTicket) continue;
|
|
for (const [key, places] of Object.entries(placesByKey)) {
|
|
if (!key.startsWith(`${o.id}::`)) continue;
|
|
if ((countByKey[key] || 0) + (namedByKey[key] || 0) < places) {
|
|
throw attendeeError(`This is a youth event — add a name and date of birth for every "${o.name}" ticket.`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return cleaned;
|
|
}
|
|
|
|
/**
|
|
* Works out the tickets one registration line should have, given its named attendees.
|
|
* Pure — returns operations for the caller to run.
|
|
*
|
|
* - Every attendee gets a quantity-1 ticket (holderUserId follows the attendee's account).
|
|
* - Unnamed places share one quantity ticket; a scanned one is never deleted or shrunk
|
|
* below what's been redeemed.
|
|
* - Tickets for attendees that no longer exist are removed unless already scanned.
|
|
*
|
|
* @param {object} line - { quantity }
|
|
* @param {Array} attendees - [{ id, userId }]
|
|
* @param {Array} tickets - existing [{ id, attendeeId, quantity, holderUserId, usages: [] }]
|
|
* @returns {{ create: Array<{attendeeId, holderUserId, quantity}>, update: Array<{id, data}>, remove: string[] }}
|
|
*/
|
|
function planLineTickets(line, attendees, tickets) {
|
|
const scanned = (t) => (t.usages || []).length > 0;
|
|
const redeemed = (t) => (t.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 0), 0);
|
|
const ops = { create: [], update: [], remove: [] };
|
|
const attendeeIds = new Set(attendees.map(a => a.id));
|
|
|
|
const byAttendee = new Map();
|
|
const orphanNamed = [];
|
|
const shared = [];
|
|
for (const t of tickets) {
|
|
if (t.attendeeId && attendeeIds.has(t.attendeeId)) byAttendee.set(t.attendeeId, t);
|
|
else if (t.attendeeId) orphanNamed.push(t);
|
|
else shared.push(t);
|
|
}
|
|
|
|
// Tickets whose attendee was removed: drop unless already used (then keep, as shared).
|
|
let keptScannedOrphans = 0;
|
|
for (const t of orphanNamed) {
|
|
if (scanned(t)) keptScannedOrphans += t.quantity || 1;
|
|
else ops.remove.push(t.id);
|
|
}
|
|
|
|
for (const a of attendees) {
|
|
const t = byAttendee.get(a.id);
|
|
if (!t) {
|
|
ops.create.push({ attendeeId: a.id, holderUserId: a.userId || null, quantity: 1 });
|
|
} else if ((t.holderUserId || null) !== (a.userId || null) || (t.quantity || 1) !== 1) {
|
|
ops.update.push({ id: t.id, data: { holderUserId: a.userId || null, ...(scanned(t) ? {} : { quantity: 1 }) } });
|
|
}
|
|
}
|
|
|
|
// Unnamed places share one ticket.
|
|
const remainder = Math.max(0, (line.quantity || 0) - attendees.length - keptScannedOrphans);
|
|
const sorted = [...shared].sort((a, b) => (scanned(b) ? 1 : 0) - (scanned(a) ? 1 : 0));
|
|
const [primary, ...extras] = sorted;
|
|
if (primary) {
|
|
const floor = scanned(primary) ? redeemed(primary) : 0;
|
|
if (remainder === 0 && !scanned(primary)) ops.remove.push(primary.id);
|
|
else {
|
|
const qty = Math.max(remainder, floor);
|
|
if (qty !== primary.quantity) ops.update.push({ id: primary.id, data: { quantity: qty } });
|
|
}
|
|
} else if (remainder > 0) {
|
|
ops.create.push({ attendeeId: null, holderUserId: null, quantity: remainder });
|
|
}
|
|
for (const t of extras) if (!scanned(t)) ops.remove.push(t.id);
|
|
|
|
return ops;
|
|
}
|
|
|
|
/**
|
|
* Saves new attendees on a registration, linking each to its registration line and to
|
|
* a matching account when there is exactly one.
|
|
*/
|
|
async function createAttendees(registrationId, attendees, db = prisma) {
|
|
if (!attendees || attendees.length === 0) return [];
|
|
const lines = await db.registrationOption.findMany({ where: { registrationId } });
|
|
const existing = await db.attendee.findMany({ where: { registrationId }, select: { registrationOptionId: true } });
|
|
const orderByLine = {};
|
|
existing.forEach(a => { orderByLine[a.registrationOptionId] = (orderByLine[a.registrationOptionId] || 0) + 1; });
|
|
|
|
const created = [];
|
|
for (const a of attendees) {
|
|
const line = lines.find(l => l.eventOptionId === a.eventOptionId && (l.variantId || null) === (a.variantId || null));
|
|
const match = (a.email || a.phone) ? await findAccountByContact({ email: a.email, phone: a.phone }, db) : { user: null };
|
|
const order = orderByLine[line?.id] || 0;
|
|
orderByLine[line?.id] = order + 1;
|
|
created.push(await db.attendee.create({
|
|
data: {
|
|
registrationId,
|
|
registrationOptionId: line?.id || null,
|
|
eventOptionId: a.eventOptionId,
|
|
variantId: a.variantId || null,
|
|
name: a.name,
|
|
email: a.email,
|
|
phone: a.phone,
|
|
dateOfBirth: a.dateOfBirth || null,
|
|
gender: a.gender || null,
|
|
parentName: a.parentName || null,
|
|
parentEmail: a.parentEmail || null,
|
|
parentPhone: a.parentPhone || null,
|
|
userId: match.user?.id || null,
|
|
order,
|
|
},
|
|
}));
|
|
}
|
|
return created;
|
|
}
|
|
|
|
/** Named places already on a registration, per line key — for validating additions. */
|
|
async function namedPlacesByKey(registrationId, db = prisma) {
|
|
const rows = await db.attendee.findMany({ where: { registrationId }, select: { eventOptionId: true, variantId: true } });
|
|
const out = {};
|
|
rows.forEach(r => { const k = lineKey(r.eventOptionId, r.variantId); out[k] = (out[k] || 0) + 1; });
|
|
return out;
|
|
}
|
|
|
|
module.exports = { lineKey, validateAttendees, planLineTickets, createAttendees, namedPlacesByKey, attendeeError };
|