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