- Waitlist per session: entries for pick-sessions tickets name a session; offers use that session's free places, never run past its start, and claim links tick it. Held offers count towards session capacity. - At the door: the Select items pop-up has the session picker, with today's session preselected for new attendees. - On pick-sessions events every ticket shows the session list straight away. "All sessions" tickets record the ticked sessions (RegistrationOption.sessionIds) at one price; scanning, attendance, certificates, reminders and capacity follow them. - Fix: per-session stock limits weren't enforced (takenCount filtered WaitlistEntry by a sessionId column that didn't exist, and the error was swallowed), and the public event page lost its stock info. - Fix: editing a door registration with picked sessions couldn't be saved. - Fix: the offline-scanning service worker served /_next/static cache-first forever, so dev browsers that had opened the scanner ran stale code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
579 lines
29 KiB
JavaScript
579 lines
29 KiB
JavaScript
/**
|
||
* Multi-day events — one event running on several dates ("sessions"), e.g. every
|
||
* Wednesday for 5 weeks.
|
||
*
|
||
* Rules:
|
||
* - Event.startDate/endDate always mirror the first active session's start and the last
|
||
* active session's end, so everything that only reads those keeps working.
|
||
* - A cancelled session is kept (it may have check-ins) but is hidden from the schedule
|
||
* and left out of totals. A session with check-ins can't be deleted, only cancelled.
|
||
* - Check-in is once per session. "Whole day" accepts a scan any time on the session's
|
||
* date (in the church's timezone); "session only" from 2 h before the start until the end.
|
||
* - Registration closes when the first session starts, or — with late joining — when
|
||
* session N starts. A registration deadline, if set, still wins when it's earlier.
|
||
* - Late pricing applies to main tickets only, for people registering after the first
|
||
* session has started: pro-rata on the sessions left (nearest R10), or a fixed price.
|
||
* - "Pick sessions" events (attendanceMode pick): options with sessionMode `pick` are
|
||
* bought per session — one registration line (and ticket) per session, priced per
|
||
* session, bookable until that session starts. `all` options cover every session and
|
||
* follow the whole-course rules above. A session's capacity counts picked places plus
|
||
* whole-course main tickets (and places held for waitlist offers); a pick option's stock
|
||
* limit is places per session. The waitlist for a pick option is per session.
|
||
* `all` options on such an event also let people tick the sessions they'll come to
|
||
* (RegistrationOption.sessionIds): one line, one ticket and one price, but the ticket
|
||
* scans, counts towards capacity and gets reminders for those sessions only. No
|
||
* sessions ticked = every session (the whole-course rules).
|
||
* Not available on youth (parental consent) events, and picked lines take no names.
|
||
*/
|
||
|
||
const prisma = require('../config/db');
|
||
|
||
const TIMEZONE = process.env.APP_TIMEZONE || 'Africa/Johannesburg';
|
||
const EARLY_CHECK_IN_MS = 2 * 60 * 60 * 1000;
|
||
const MAX_SESSIONS = 200;
|
||
const REMINDER_OFFSETS = [60, 180, 1440]; // minutes before start: 1 h, 3 h, 1 day
|
||
const CERTIFICATE_TEMPLATES = ['classic', 'modern', 'church', 'kids'];
|
||
|
||
function sessionError(message, statusCode = 400) {
|
||
const err = new Error(message);
|
||
err.statusCode = statusCode;
|
||
return err;
|
||
}
|
||
|
||
// ─── Pure helpers ─────────────────────────────────────────────────────────────
|
||
|
||
/** 'YYYY-MM-DD' of a moment in the church's timezone. */
|
||
function dayKey(date, timeZone = TIMEZONE) {
|
||
return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(date));
|
||
}
|
||
|
||
/** Sessions that aren't cancelled, earliest first. */
|
||
function activeSessions(sessions) {
|
||
return (sessions || [])
|
||
.filter(s => !s.cancelledAt)
|
||
.sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt));
|
||
}
|
||
|
||
/** { startDate, endDate } covering the active sessions, or null when there are none. */
|
||
function scheduleBounds(sessions) {
|
||
const active = activeSessions(sessions);
|
||
if (active.length === 0) return null;
|
||
const endDate = active.reduce((max, s) => (new Date(s.endsAt) > max ? new Date(s.endsAt) : max), new Date(active[0].endsAt));
|
||
return { startDate: new Date(active[0].startsAt), endDate };
|
||
}
|
||
|
||
/**
|
||
* Checks and tidies the sessions sent by the event editor.
|
||
* Each: { id?, label?, startsAt, endsAt, location?, cancelled? }.
|
||
*/
|
||
function normalizeSessionsInput(list) {
|
||
if (!Array.isArray(list)) throw sessionError('Sessions must be a list');
|
||
if (list.length > MAX_SESSIONS) throw sessionError(`An event can have at most ${MAX_SESSIONS} sessions`);
|
||
const out = list.map((s, i) => {
|
||
const startsAt = new Date(s?.startsAt);
|
||
const endsAt = new Date(s?.endsAt);
|
||
if (isNaN(startsAt) || isNaN(endsAt)) throw sessionError(`Session ${i + 1} needs a start and end date`);
|
||
if (endsAt <= startsAt) throw sessionError(`Session ${i + 1} must end after it starts`);
|
||
return {
|
||
id: s.id ? String(s.id) : null,
|
||
label: s.label ? String(s.label).trim().slice(0, 120) || null : null,
|
||
startsAt,
|
||
endsAt,
|
||
location: s.location ? String(s.location).trim().slice(0, 300) || null : null,
|
||
cancelled: s.cancelled === true,
|
||
capacity: s.capacity === null || s.capacity === undefined || s.capacity === '' ? null : Math.max(1, parseInt(s.capacity, 10) || 1),
|
||
};
|
||
});
|
||
out.sort((a, b) => a.startsAt - b.startsAt);
|
||
if (!out.some(s => !s.cancelled)) throw sessionError('A multi-session event needs at least one session that isn\'t cancelled');
|
||
return out;
|
||
}
|
||
|
||
/** Whether a check-in for `session` is allowed at `now`. */
|
||
function sessionAcceptsCheckIn(session, now = new Date(), window = 'whole_day') {
|
||
if (!session || session.cancelledAt) return false;
|
||
const t = new Date(now);
|
||
if (window === 'session_only') {
|
||
return t >= new Date(new Date(session.startsAt).getTime() - EARLY_CHECK_IN_MS) && t <= new Date(session.endsAt);
|
||
}
|
||
const today = dayKey(t);
|
||
return today >= dayKey(session.startsAt) && today <= dayKey(session.endsAt);
|
||
}
|
||
|
||
/**
|
||
* The session a scan at `now` counts for, or null. On a day with more than one session:
|
||
* the one happening now (from 2 h before), else the next one today, else the last one.
|
||
*/
|
||
function currentSession(sessions, now = new Date(), window = 'whole_day') {
|
||
const t = new Date(now);
|
||
const open = activeSessions(sessions).filter(s => sessionAcceptsCheckIn(s, t, window));
|
||
if (open.length <= 1) return open[0] || null;
|
||
const running = open.find(s => t >= new Date(new Date(s.startsAt).getTime() - EARLY_CHECK_IN_MS) && t <= new Date(s.endsAt));
|
||
if (running) return running;
|
||
return open.find(s => new Date(s.startsAt) > t) || open[open.length - 1];
|
||
}
|
||
|
||
/** When public registration closes for an event (Date). */
|
||
/** When whole-course (all-sessions) tickets stop selling (Date). */
|
||
function wholeCourseClosesAt(event, sessions) {
|
||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||
let closes = new Date(event.endDate);
|
||
if (event.scheduleType === 'sessions') {
|
||
const active = activeSessions(sessions);
|
||
if (active.length > 0) {
|
||
const until = event.lateJoinEnabled ? active.find(s => s.id === event.lateJoinUntilSessionId) : null;
|
||
closes = new Date((until || active[0]).startsAt);
|
||
}
|
||
}
|
||
return deadline && deadline < closes ? deadline : closes;
|
||
}
|
||
|
||
/**
|
||
* When public registration closes (Date). "Pick sessions" events stay open until the
|
||
* last session starts (each session can be booked until it starts).
|
||
*/
|
||
function registrationClosesAt(event, sessions) {
|
||
const whole = wholeCourseClosesAt(event, sessions);
|
||
if (event.scheduleType !== 'sessions' || event.attendanceMode !== 'pick') return whole;
|
||
const active = activeSessions(sessions);
|
||
if (active.length === 0) return whole;
|
||
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
||
const last = new Date(active[active.length - 1].startsAt);
|
||
const closes = last > whole ? last : whole;
|
||
return deadline && deadline < closes ? deadline : closes;
|
||
}
|
||
|
||
/** Whether people choose sessions on this event (attendance mode "pick sessions"). */
|
||
function choosesSessions(event) {
|
||
return event?.scheduleType === 'sessions' && event?.attendanceMode === 'pick';
|
||
}
|
||
|
||
/** Whether an option is bought per session (only in "pick sessions" events). */
|
||
function isPickOption(event, option) {
|
||
return event?.scheduleType === 'sessions' && event?.attendanceMode === 'pick' && option?.sessionMode === 'pick';
|
||
}
|
||
|
||
/**
|
||
* Turns requested lines into registration lines: a pick option with sessionIds becomes
|
||
* one line per session (same quantity); an all-sessions option on a pick-sessions event
|
||
* keeps its sessionIds (the sessions ticked, empty = every session) on one line. Checks
|
||
* the sessions can still be booked: public — before the session starts; staff — until it ends.
|
||
* @param {object} event - with eventOptions and sessions
|
||
* @param {object[]} lines - [{ eventOptionId, variantId?, quantity, sessionIds? }]
|
||
*/
|
||
function expandPickLines(event, lines, { now = new Date(), staff = false } = {}) {
|
||
const out = [];
|
||
const bookable = (option, id) => {
|
||
const session = (event.sessions || []).find(s => s.id === id);
|
||
if (!session || session.cancelledAt) throw sessionError(`A chosen session for "${option.name}" isn't available.`);
|
||
const cutoff = staff ? new Date(session.endsAt) : new Date(session.startsAt);
|
||
if (cutoff <= now) throw sessionError(`${sessionLabel(session, event.sessions)} has ${staff ? 'ended' : 'already started'} — it can't be booked any more.`);
|
||
};
|
||
for (const line of lines || []) {
|
||
const option = (event.eventOptions || []).find(o => o.id === line.eventOptionId);
|
||
const pick = isPickOption(event, option);
|
||
const ids = Array.isArray(line.sessionIds) ? [...new Set(line.sessionIds.map(String))] : [];
|
||
const { sessionIds, ...rest } = line;
|
||
void sessionIds;
|
||
if (!pick) {
|
||
if (ids.length && !choosesSessions(event)) throw sessionError(`"${option?.name || 'This ticket'}" covers every session — no sessions to choose.`);
|
||
for (const id of ids) bookable(option, id);
|
||
out.push({ ...rest, sessionId: null, sessionIds: ids });
|
||
continue;
|
||
}
|
||
if (event.requiresParentalConsent) throw sessionError('Choosing sessions isn\'t available on youth events.');
|
||
if (ids.length === 0) throw sessionError(`Choose the sessions for "${option.name}".`);
|
||
for (const id of ids) {
|
||
bookable(option, id);
|
||
out.push({ ...rest, sessionId: id });
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* The sessions a line attends: its picked session, the sessions ticked on an
|
||
* all-sessions ticket, or null for every session.
|
||
* @param {{ sessionId?: string|null, sessionIds?: string[] }} line
|
||
* @returns {string[]|null}
|
||
*/
|
||
function lineSessions(line) {
|
||
if (line?.sessionId) return [line.sessionId];
|
||
return line?.sessionIds?.length ? line.sessionIds : null;
|
||
}
|
||
|
||
/** Whether a ticket's line covers `sessionId` (picked session, ticked sessions, or every session). */
|
||
function lineCovers(line, sessionId) {
|
||
const ids = lineSessions(line);
|
||
return !ids || ids.includes(sessionId);
|
||
}
|
||
|
||
/** Merges the ticked sessions of two all-sessions lines (empty = every session wins). */
|
||
function mergeSessionIds(a, b) {
|
||
if (!a?.length || !b?.length) return [];
|
||
return [...new Set([...a, ...b])];
|
||
}
|
||
|
||
/**
|
||
* People booked per session: picked places on that session plus whole-course main
|
||
* tickets (which attend every session). Cancelled registrations don't count; places
|
||
* held for a live waitlist offer do (except `excludeEntryId`, the offer being claimed).
|
||
* @returns {Promise<Map<string, number>>}
|
||
*/
|
||
async function sessionBookings(eventId, db = prisma, { excludeEntryId = null } = {}) {
|
||
const [booked, held] = await Promise.all([
|
||
db.registrationOption.findMany({
|
||
where: { registration: { eventId, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } },
|
||
select: { quantity: true, sessionId: true, sessionIds: true },
|
||
}),
|
||
db.waitlistEntry.findMany({
|
||
where: {
|
||
eventId, status: 'offered', offerExpiresAt: { gt: new Date() }, eventOption: { isMainTicket: true },
|
||
...(excludeEntryId ? { id: { not: excludeEntryId } } : {}),
|
||
},
|
||
select: { offeredQuantity: true, sessionId: true },
|
||
}),
|
||
]);
|
||
const lines = [...booked, ...held.map(h => ({ quantity: h.offeredQuantity, sessionId: h.sessionId }))];
|
||
const sessions = await db.eventSession.findMany({ where: { eventId, cancelledAt: null }, select: { id: true } });
|
||
const out = new Map(sessions.map(s => [s.id, 0]));
|
||
for (const l of lines) {
|
||
for (const id of lineSessions(l) || out.keys()) if (out.has(id)) out.set(id, out.get(id) + (l.quantity || 0));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* Session places free for a main-ticket line: its own session (picked) or, for a
|
||
* whole-course ticket, the fullest session with a capacity. Pure.
|
||
* @param {object[]} sessions - the event's sessions (with capacity)
|
||
* @param {Map<string, number>} booked - from sessionBookings
|
||
* @param {string|null} sessionId - the picked session, or null for whole-course
|
||
* @returns {number|null} null when no session limits it
|
||
*/
|
||
function sessionPlacesFree(sessions, booked, sessionId = null) {
|
||
const limited = activeSessions(sessions).filter(s => s.capacity > 0 && (!sessionId || s.id === sessionId));
|
||
if (limited.length === 0) return null;
|
||
return Math.max(0, Math.min(...limited.map(s => s.capacity - (booked.get(s.id) || 0))));
|
||
}
|
||
|
||
/**
|
||
* Checks requested lines fit each session's capacity. Whole-course main tickets count
|
||
* against every session; picked main tickets against their own.
|
||
* @param {{ excludeEntryId?: string }} [opts] - the waitlist offer being claimed
|
||
*/
|
||
async function assertSessionCapacity(event, lines, db = prisma, { excludeEntryId = null } = {}) {
|
||
const limited = activeSessions(event.sessions).filter(s => s.capacity > 0);
|
||
if (limited.length === 0) return;
|
||
const mains = new Set((event.eventOptions || []).filter(o => o.isMainTicket).map(o => o.id));
|
||
const wanted = new Map();
|
||
for (const s of limited) wanted.set(s.id, 0);
|
||
for (const l of lines) {
|
||
if (!mains.has(l.eventOptionId)) continue;
|
||
for (const id of lineSessions(l) || wanted.keys()) if (wanted.has(id)) wanted.set(id, wanted.get(id) + (Number(l.quantity) || 0));
|
||
}
|
||
if ([...wanted.values()].every(n => n === 0)) return;
|
||
const booked = await sessionBookings(event.id, db, { excludeEntryId });
|
||
for (const s of limited) {
|
||
const left = s.capacity - (booked.get(s.id) || 0);
|
||
if ((wanted.get(s.id) || 0) > left) {
|
||
throw sessionError(`${sessionLabel(s, event.sessions)} is ${left <= 0 ? 'full' : `nearly full — only ${left} place${left === 1 ? '' : 's'} left`}.`);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Late-joiner pricing state at `now`: active once the first session has started (for
|
||
* events with late joining and a late price). sessionsLeft counts sessions not yet started.
|
||
*/
|
||
function lateInfo(event, sessions, now = new Date()) {
|
||
const active = activeSessions(sessions);
|
||
const base = { active: false, mode: event.latePricing || 'full', sessionsLeft: active.length, totalSessions: active.length, fixedPrice: event.lateFixedPrice ?? null };
|
||
if (event.scheduleType !== 'sessions' || !event.lateJoinEnabled || active.length === 0) return base;
|
||
const t = new Date(now);
|
||
const sessionsLeft = active.filter(s => new Date(s.startsAt) > t).length;
|
||
const started = t >= new Date(active[0].startsAt);
|
||
const priced = base.mode === 'prorata' || (base.mode === 'fixed' && base.fixedPrice !== null);
|
||
return { ...base, sessionsLeft, active: started && priced };
|
||
}
|
||
|
||
/** A main ticket's price for a late joiner. Never more than the normal price. */
|
||
function latePrice(price, info) {
|
||
if (!info || !info.active || !(price > 0)) return price;
|
||
if (info.mode === 'fixed') return Math.min(price, Math.max(0, Number(info.fixedPrice)));
|
||
if (info.mode === 'prorata') {
|
||
if (!info.totalSessions) return price;
|
||
const rounded = Math.round((price * info.sessionsLeft / info.totalSessions) / 10) * 10;
|
||
return Math.min(price, Math.max(10, rounded));
|
||
}
|
||
return price;
|
||
}
|
||
|
||
/** Whether `attended` of `total` sessions meets a certificate completion rule. */
|
||
function completionMet(rule, attended, total) {
|
||
if (total <= 0 || attended <= 0) return false;
|
||
const type = rule?.type || 'all';
|
||
const value = Number(rule?.value) || 0;
|
||
if (type === 'count') return attended >= Math.min(Math.max(1, value), total);
|
||
if (type === 'percent') return attended / total * 100 >= Math.min(Math.max(1, value), 100) - 1e-9;
|
||
return attended >= total;
|
||
}
|
||
|
||
/** Cleans the reminder settings from the editor; null = off. */
|
||
function normalizeReminders(input) {
|
||
if (!input || input.enabled === false) return null;
|
||
const offsets = [...new Set((input.offsets || []).map(Number).filter(n => REMINDER_OFFSETS.includes(n)))].sort((a, b) => b - a);
|
||
const channels = [...new Set((input.channels || []).filter(c => ['email', 'whatsapp'].includes(c)))];
|
||
if (offsets.length === 0 || channels.length === 0) return null;
|
||
return { offsets, channels };
|
||
}
|
||
|
||
/** Cleans the certificate settings from the editor. */
|
||
function normalizeCertificateConfig(input) {
|
||
const c = input && typeof input === 'object' ? input : {};
|
||
const ruleType = ['all', 'count', 'percent'].includes(c.rule?.type) ? c.rule.type : 'all';
|
||
const str = (v, max) => (v ? String(v).trim().slice(0, max) : '');
|
||
return {
|
||
template: CERTIFICATE_TEMPLATES.includes(c.template) ? c.template : 'classic',
|
||
title: str(c.title, 120) || 'Certificate of Completion',
|
||
body: str(c.body, 1000) || 'This certifies that {name} has completed {event}, attending {sessionsAttended} of {totalSessions} sessions.',
|
||
signatureName: str(c.signatureName, 120),
|
||
signatureRole: str(c.signatureRole, 120),
|
||
signatureImage: str(c.signatureImage, 500),
|
||
rule: { type: ruleType, value: ruleType === 'all' ? null : Math.max(1, parseInt(c.rule?.value, 10) || 1) },
|
||
sendOnIssue: c.sendOnIssue === true,
|
||
};
|
||
}
|
||
|
||
// ─── Database helpers ─────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Saves the editor's session list for an event: updates rows it kept, creates new ones,
|
||
* deletes rows it dropped (refused when they have check-ins — cancel those instead), and
|
||
* moves Event.startDate/endDate to match. Run inside a transaction (`tx`).
|
||
* @returns {Promise<{ sessions, changes: { added, moved, cancelled, restored, removed } }>}
|
||
*/
|
||
async function syncEventSessions(tx, eventId, input) {
|
||
const wanted = normalizeSessionsInput(input);
|
||
const existing = await tx.eventSession.findMany({ where: { eventId }, include: { _count: { select: { usages: true } } } });
|
||
const byId = new Map(existing.map(s => [s.id, s]));
|
||
const changes = { added: 0, moved: 0, cancelled: 0, restored: 0, removed: 0 };
|
||
|
||
const keep = new Set(wanted.filter(w => w.id && byId.has(w.id)).map(w => w.id));
|
||
for (const s of existing) {
|
||
if (keep.has(s.id)) continue;
|
||
if (s._count.usages > 0) {
|
||
throw sessionError(`"${s.label || dayKey(s.startsAt)}" already has check-ins, so it can't be removed. Cancel it instead.`);
|
||
}
|
||
await tx.eventSession.delete({ where: { id: s.id } });
|
||
changes.removed += 1;
|
||
}
|
||
|
||
for (let i = 0; i < wanted.length; i++) {
|
||
const w = wanted[i];
|
||
const data = { label: w.label, startsAt: w.startsAt, endsAt: w.endsAt, location: w.location, capacity: w.capacity, order: i };
|
||
const old = w.id ? byId.get(w.id) : null;
|
||
if (old) {
|
||
const cancelledAt = w.cancelled ? (old.cancelledAt || new Date()) : null;
|
||
if (+old.startsAt !== +w.startsAt || +old.endsAt !== +w.endsAt) changes.moved += 1;
|
||
if (!old.cancelledAt && cancelledAt) changes.cancelled += 1;
|
||
if (old.cancelledAt && !cancelledAt) changes.restored += 1;
|
||
await tx.eventSession.update({ where: { id: old.id }, data: { ...data, cancelledAt } });
|
||
} else {
|
||
await tx.eventSession.create({ data: { ...data, eventId, cancelledAt: w.cancelled ? new Date() : null } });
|
||
changes.added += 1;
|
||
}
|
||
}
|
||
|
||
const sessions = await tx.eventSession.findMany({ where: { eventId }, orderBy: { startsAt: 'asc' } });
|
||
const bounds = scheduleBounds(sessions);
|
||
await tx.event.update({ where: { id: eventId }, data: { startDate: bounds.startDate, endDate: bounds.endDate } });
|
||
return { sessions, changes };
|
||
}
|
||
|
||
const SCHEDULE_SELECT = {
|
||
id: true, endDate: true, registrationDeadline: true, scheduleType: true, attendanceMode: true, checkInWindow: true,
|
||
lateJoinEnabled: true, lateJoinUntilSessionId: true, latePricing: true, lateFixedPrice: true,
|
||
sessions: { orderBy: { startsAt: 'asc' } },
|
||
};
|
||
|
||
// Late pricing is looked up while pricing every option line, so keep each event's
|
||
// schedule for a few seconds rather than querying it per line.
|
||
const scheduleCache = new Map();
|
||
const SCHEDULE_TTL_MS = 5000;
|
||
|
||
async function eventSchedule(eventId) {
|
||
const hit = scheduleCache.get(eventId);
|
||
if (hit && hit.expires > Date.now()) return hit.value;
|
||
const value = await prisma.event.findUnique({ where: { id: eventId }, select: SCHEDULE_SELECT });
|
||
scheduleCache.set(eventId, { value, expires: Date.now() + SCHEDULE_TTL_MS });
|
||
if (scheduleCache.size > 500) scheduleCache.delete(scheduleCache.keys().next().value);
|
||
return value;
|
||
}
|
||
|
||
function forgetSchedule(eventId) {
|
||
scheduleCache.delete(eventId);
|
||
}
|
||
|
||
/** Label for a session in messages: its own label, else "Session N" by position. */
|
||
function sessionLabel(session, sessions) {
|
||
if (session.label) return session.label;
|
||
const idx = activeSessions(sessions).findIndex(s => s.id === session.id);
|
||
return `Session ${idx >= 0 ? idx + 1 : ''}`.trim();
|
||
}
|
||
|
||
/**
|
||
* Which session a scan on a multi-session event counts for. `requestedId` is the session
|
||
* the scanner picked (optional — defaults to today's). Returns { session, error } with
|
||
* session null and an error message when check-in isn't possible now.
|
||
*/
|
||
function scanSession(event, sessions, requestedId = null, now = new Date()) {
|
||
const window = event.checkInWindow || 'whole_day';
|
||
if (requestedId) {
|
||
const session = (sessions || []).find(s => s.id === requestedId);
|
||
if (!session) return { session: null, error: 'That session isn\'t part of this event' };
|
||
if (session.cancelledAt) return { session: null, error: `${sessionLabel(session, sessions)} was cancelled` };
|
||
if (!sessionAcceptsCheckIn(session, now, window)) {
|
||
return { session: null, error: `Check-in for ${sessionLabel(session, sessions)} is only open ${window === 'session_only' ? 'from 2 hours before it starts until it ends' : 'on the day of the session'}` };
|
||
}
|
||
return { session, error: null };
|
||
}
|
||
const session = currentSession(sessions, now, window);
|
||
if (!session) {
|
||
const next = activeSessions(sessions).find(s => new Date(s.startsAt) > new Date(now));
|
||
return { session: null, error: next ? `No session today — the next one is ${sessionLabel(next, sessions)} on ${dayKey(next.startsAt)}` : 'All sessions of this event are over' };
|
||
}
|
||
return { session, error: null };
|
||
}
|
||
|
||
/**
|
||
* How a ticket shows an event's date. Multi-day events: "5 sessions · 04 Nov – 02 Dec"
|
||
* (short) or "Wednesday 4 November 2026 (first of 5 sessions — one ticket for all)" (long).
|
||
* Returns null for single-date events (callers keep their own date format).
|
||
*/
|
||
function ticketDateText(event, style = 'short', session = null, sessions = null, chosenIds = null) {
|
||
if (event?.scheduleType !== 'sessions') return null;
|
||
const fmt = (d, opts) => new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, ...opts });
|
||
// An all-sessions ticket with sessions ticked: those sessions.
|
||
const chosen = chosenIds?.length ? activeSessions(event.sessions).filter(s => chosenIds.includes(s.id)) : [];
|
||
if (!session && chosen.length === 1) session = chosen[0];
|
||
else if (!session && chosen.length > 1) {
|
||
return style === 'long'
|
||
? `${chosen.map(s => fmt(s.startsAt, { weekday: 'short', day: 'numeric', month: 'short' })).join(', ')} (${chosen.length} sessions — one ticket for all)`
|
||
: `${chosen.length} sessions · ${chosen.map(s => fmt(s.startsAt, { day: '2-digit', month: 'short' })).join(', ')}`;
|
||
}
|
||
// A picked-session ticket is for its own session only.
|
||
if (session) {
|
||
const label = sessionLabel(session, event.sessions);
|
||
return style === 'long'
|
||
? `${fmt(session.startsAt, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })} (${label})`
|
||
: `${label} · ${fmt(session.startsAt, { day: '2-digit', month: 'short', year: 'numeric' })}`;
|
||
}
|
||
if (sessions && sessions.length > 1 && style === 'long') {
|
||
const sorted = [...sessions].sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt));
|
||
return `${sorted.map(s => fmt(s.startsAt, { day: 'numeric', month: 'short' })).join(', ')} (one ticket per session)`;
|
||
}
|
||
const active = activeSessions(event.sessions);
|
||
if (active.length === 0) return null;
|
||
if (style === 'long') {
|
||
const first = fmt(active[0].startsAt, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||
return active.length === 1 ? first : `${first} (first of ${active.length} sessions — one ticket for all)`;
|
||
}
|
||
const short = { day: '2-digit', month: 'short' };
|
||
return active.length === 1
|
||
? fmt(active[0].startsAt, { day: '2-digit', month: 'long', year: 'numeric' })
|
||
: `${active.length} sessions · ${fmt(active[0].startsAt, short)} – ${fmt(active[active.length - 1].startsAt, short)}`;
|
||
}
|
||
|
||
/**
|
||
* scanSession for a ticket: a picked-session ticket only checks in on its own session
|
||
* (whatever the scanner has selected); an all-sessions ticket with sessions ticked
|
||
* (`chosenIds`) only on those.
|
||
*/
|
||
function ticketScanSession(event, sessions, pinnedSessionId, requestedId = null, now = new Date(), chosenIds = null) {
|
||
if (!pinnedSessionId && chosenIds?.length) {
|
||
const found = scanSession(event, sessions, requestedId, now);
|
||
if (!found.session || chosenIds.includes(found.session.id)) return found;
|
||
// Another of their sessions may be open today (a day with more than one session).
|
||
const own = requestedId ? null : currentSession(activeSessions(sessions).filter(s => chosenIds.includes(s.id)), now, event.checkInWindow || 'whole_day');
|
||
if (own) return { session: own, error: null };
|
||
return { session: null, error: `This ticket isn't booked for ${sessionLabel(found.session, sessions)} (${dayKey(found.session.startsAt)})` };
|
||
}
|
||
if (!pinnedSessionId) return scanSession(event, sessions, requestedId, now);
|
||
const pinned = (sessions || []).find(s => s.id === pinnedSessionId);
|
||
if (!pinned) return { session: null, error: 'This ticket\'s session no longer exists' };
|
||
const label = sessionLabel(pinned, sessions);
|
||
if (requestedId && requestedId !== pinnedSessionId) {
|
||
return { session: null, error: `This ticket is only for ${label} (${dayKey(pinned.startsAt)}), not the selected session` };
|
||
}
|
||
const found = scanSession(event, sessions, pinnedSessionId, now);
|
||
return found.session ? found : { session: null, error: `This ticket is for ${label} (${dayKey(pinned.startsAt)}) — ${found.error.charAt(0).toLowerCase()}${found.error.slice(1)}` };
|
||
}
|
||
|
||
/**
|
||
* Groups an event's tickets into people for the register and certificates: a
|
||
* picked-session booking has one ticket per session, so its tickets (same registration,
|
||
* option and variant) form one group. Every other ticket is its own group.
|
||
* Each ticket needs registrationOption: { registrationId, eventOptionId, variantId, sessionId, sessionIds }.
|
||
* An all-sessions ticket with sessions ticked is its own group, limited to those sessions.
|
||
* @returns {{ key: string, tickets: object[], sessionIds: string[]|null }[]} sessionIds null = all sessions
|
||
*/
|
||
function groupTickets(tickets) {
|
||
const groups = new Map();
|
||
for (const t of tickets) {
|
||
const ro = t.registrationOption || {};
|
||
if (!ro.sessionId) {
|
||
groups.set(t.id, { key: t.id, tickets: [t], sessionIds: ro.sessionIds?.length ? [...ro.sessionIds] : null });
|
||
continue;
|
||
}
|
||
const key = `pick:${ro.registrationId}:${ro.eventOptionId}:${ro.variantId || ''}`;
|
||
const g = groups.get(key) || { key, tickets: [], sessionIds: [] };
|
||
g.tickets.push(t);
|
||
g.sessionIds.push(ro.sessionId);
|
||
groups.set(key, g);
|
||
}
|
||
return [...groups.values()];
|
||
}
|
||
|
||
/** Places already checked in for one session, from a ticket's usages. */
|
||
function redeemedForSession(usages, sessionId) {
|
||
return (usages || []).filter(u => u.sessionId === sessionId).reduce((sum, u) => sum + (u.quantityRedeemed || 1), 0);
|
||
}
|
||
|
||
/** Applies late pricing to a resolved option price ({ price, tierId }). */
|
||
async function withLatePrice(option, resolved) {
|
||
// Picked sessions are priced per session, so late joining doesn't change them.
|
||
if (!option?.isMainTicket || option.sessionMode === 'pick' || !option.eventId || !(resolved.price > 0)) return resolved;
|
||
const event = await eventSchedule(option.eventId);
|
||
if (!event || event.scheduleType !== 'sessions') return resolved;
|
||
const price = latePrice(resolved.price, lateInfo(event, event.sessions));
|
||
return price === resolved.price ? resolved : { ...resolved, price };
|
||
}
|
||
|
||
/**
|
||
* Check-ins per ticket per session for an event: Map ticketId → Map sessionId → qty.
|
||
* Cancelled sessions are included; callers drop them when counting.
|
||
*/
|
||
async function attendanceBySession(eventId) {
|
||
const usages = await prisma.ticketUsage.findMany({
|
||
where: { ticket: { eventId }, sessionId: { not: null } },
|
||
select: { ticketId: true, sessionId: true, quantityRedeemed: true },
|
||
});
|
||
const out = new Map();
|
||
for (const u of usages) {
|
||
if (!out.has(u.ticketId)) out.set(u.ticketId, new Map());
|
||
const m = out.get(u.ticketId);
|
||
m.set(u.sessionId, (m.get(u.sessionId) || 0) + (u.quantityRedeemed || 1));
|
||
}
|
||
return out;
|
||
}
|
||
|
||
module.exports = {
|
||
TIMEZONE, MAX_SESSIONS, REMINDER_OFFSETS, CERTIFICATE_TEMPLATES, sessionError,
|
||
dayKey, activeSessions, scheduleBounds, normalizeSessionsInput, sessionAcceptsCheckIn, currentSession,
|
||
sessionLabel, scanSession, ticketScanSession, redeemedForSession, ticketDateText, groupTickets,
|
||
registrationClosesAt, wholeCourseClosesAt, choosesSessions, isPickOption, expandPickLines, lineSessions, lineCovers, mergeSessionIds, sessionBookings, sessionPlacesFree, assertSessionCapacity,
|
||
lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig,
|
||
syncEventSessions, eventSchedule, forgetSchedule, withLatePrice, attendanceBySession,
|
||
};
|