Files
hope-events/backend/src/utils/sessions.js
T
joshuaandClaude Opus 5.5 f99b84858e Session gaps: per-session waitlist, door session picker, all-sessions choice
- 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>
2026-10-23 11:38:21 +02:00

579 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
};