Pick sessions (backend): book individual sessions with per-session capacity

Options on a multi-day event can be bought per session (one line and
ticket per session, priced per session, bookable until the session
starts). Sessions can have a capacity covering picked places and
whole-course tickets. Picked tickets only scan on their own session; the
register, certificates and reminders follow the sessions booked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 16:05:48 +02:00
co-authored by Claude Opus 5.5
parent cc44e62379
commit e338768f6e
14 changed files with 403 additions and 68 deletions
@@ -0,0 +1,17 @@
-- CreateEnum
CREATE TYPE "OptionSessionMode" AS ENUM ('all', 'pick');
-- AlterTable
ALTER TABLE "EventOption" ADD COLUMN "sessionMode" "OptionSessionMode" NOT NULL DEFAULT 'all';
-- AlterTable
ALTER TABLE "EventSession" ADD COLUMN "capacity" INTEGER;
-- AlterTable
ALTER TABLE "RegistrationOption" ADD COLUMN "sessionId" TEXT;
-- CreateIndex
CREATE INDEX "RegistrationOption_sessionId_idx" ON "RegistrationOption"("sessionId");
-- AddForeignKey
ALTER TABLE "RegistrationOption" ADD CONSTRAINT "RegistrationOption_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+16
View File
@@ -237,6 +237,9 @@ model EventOption {
stockLimit Int @default(0)
// Applied automatically for Staff and above instead of `price` (when lower). Null = no staff price.
staffPrice Float?
// Multi-day events in "pick sessions" mode: `pick` options are bought per session
// (price is per session, stockLimit is places per session); `all` options cover every session.
sessionMode OptionSessionMode @default(all)
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
registrationOptions RegistrationOption[]
earlyBirdTiers EarlyBirdTier[]
@@ -337,11 +340,16 @@ model RegistrationOption {
tickets Ticket[]
tranches RegistrationOptionTranche[]
attendees Attendee[]
// A "pick sessions" option: one line (and ticket) per session booked. Null for
// everything else.
sessionId String?
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
@@index([registrationId])
@@index([eventOptionId])
@@index([variantId])
@@index([appliedTierId])
@@index([sessionId])
}
// One row per purchase-at-a-price for a RegistrationOption. Never mutated after creation
@@ -1445,6 +1453,11 @@ enum LatePricing {
fixed
}
enum OptionSessionMode {
all
pick
}
enum CheckInWindow {
whole_day
session_only
@@ -1462,11 +1475,14 @@ model EventSession {
// and left out of attendance totals.
cancelledAt DateTime?
order Int @default(0)
// Optional limit on people at this session (picked places + whole-course tickets).
capacity Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
usages TicketUsage[]
reminders SessionReminderLog[]
registrationOptions RegistrationOption[]
@@index([eventId, startsAt])
}
+22 -11
View File
@@ -42,7 +42,7 @@ async function buildRegister(eventId) {
holder: { select: { name: true } },
user: { select: { name: true, email: true, phoneNumber: true } },
usages: { select: { quantityRedeemed: true, sessionId: true, manual: true } },
registrationOption: { select: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } },
registrationOption: { select: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true, eventOption: { select: { name: true } }, variant: { select: { name: true } } } },
certificates: { select: { id: true, code: true, issuedAt: true, revokedAt: true, manual: true, emailedAt: true } },
},
orderBy: { createdAt: 'asc' },
@@ -50,18 +50,27 @@ async function buildRegister(eventId) {
const activeIds = new Set(active.map(s => s.id));
const config = sessionsUtil.normalizeCertificateConfig(event.certificateConfig);
const rows = tickets.map(t => {
// A picked-session booking (one ticket per session) shows as one row.
const rows = sessionsUtil.groupTickets(tickets).map(g => {
const t = g.tickets[0];
const bySession = {};
for (const u of t.usages) {
const key = multi ? u.sessionId : WHOLE_EVENT;
if (!key) continue;
bySession[key] = (bySession[key] || 0) + (u.quantityRedeemed || 1);
for (const tk of g.tickets) {
for (const u of tk.usages) {
const key = multi ? u.sessionId : WHOLE_EVENT;
if (!key) continue;
bySession[key] = (bySession[key] || 0) + (u.quantityRedeemed || 1);
}
}
const attended = multi ? Object.keys(bySession).filter(k => activeIds.has(k)).length : (bySession[WHOLE_EVENT] ? 1 : 0);
const total = multi ? active.length : 1;
const booked = g.sessionIds ? g.sessionIds.filter(id => activeIds.has(id)) : null;
const counted = booked ? new Set(booked) : activeIds;
const attended = multi ? Object.keys(bySession).filter(k => counted.has(k)).length : (bySession[WHOLE_EVENT] ? 1 : 0);
const total = multi ? counted.size : 1;
const cert = t.certificates[0] || null;
return {
ticketId: t.id,
// Picked sessions: which sessions this person booked, and the ticket for each.
bookedSessions: g.sessionIds,
sessionTickets: g.sessionIds ? Object.fromEntries(g.tickets.map(tk => [tk.registrationOption.sessionId, tk.id])) : null,
name: t.attendee?.name || t.holder?.name || t.user?.name || '',
bookedBy: t.attendee || t.holder ? t.user?.name || '' : '',
email: t.user?.email || '',
@@ -108,7 +117,7 @@ const markAttendance = async (req, res) => {
const { eventId } = req.params;
const { ticketId, present } = req.body || {};
const sessionKey = String(req.body?.sessionId || '');
const ticket = await prisma.ticket.findUnique({ where: { id: String(ticketId || '') }, include: { usages: true, event: { select: { scheduleType: true, cashupStatus: true } } } });
const ticket = await prisma.ticket.findUnique({ where: { id: String(ticketId || '') }, include: { usages: true, event: { select: { scheduleType: true, cashupStatus: true } }, registrationOption: { select: { sessionId: true } } } });
if (!ticket || ticket.eventId !== eventId) throw notFound('Ticket not found for this event');
if (ticket.event.cashupStatus === 'closed') throw badRequest('This event is closed — reopen it to change attendance.');
const multi = ticket.event.scheduleType === 'sessions';
@@ -117,6 +126,7 @@ const markAttendance = async (req, res) => {
const session = await prisma.eventSession.findUnique({ where: { id: sessionKey } });
if (!session || session.eventId !== eventId) throw notFound('Session not found for this event');
if (session.cancelledAt) throw badRequest('That session was cancelled');
if (ticket.registrationOption?.sessionId && ticket.registrationOption.sessionId !== session.id) throw badRequest('That ticket is for a different session');
sessionId = session.id;
} else if (sessionKey !== WHOLE_EVENT) {
throw badRequest('This event has no sessions');
@@ -202,8 +212,9 @@ const exportRegister = async (req, res) => {
cols.forEach((c, i) => {
const qty = r.checkIns[c.id] || 0;
const x = 36 + nameW + i * colW;
const notBooked = r.bookedSessions && !r.bookedSessions.includes(c.id);
if (qty && r.quantity <= 1) tick(x + (colW - 2) / 2, y + 4);
else doc.fillColor(qty ? '#15803d' : '#cbd5e1').text(qty ? String(qty) : '·', x, y, { width: colW - 2, align: 'center', lineBreak: false });
else doc.fillColor(qty ? '#15803d' : '#cbd5e1').text(qty ? String(qty) : notBooked ? '-' : '·', x, y, { width: colW - 2, align: 'center', lineBreak: false });
});
doc.fillColor('#1e293b').text(`${r.attended}/${r.total}`, 36 + nameW + cols.length * colW, y, { width: totalW, align: 'center', lineBreak: false });
doc.y = y + 14;
@@ -223,7 +234,7 @@ const exportRegister = async (req, res) => {
for (const r of reg.rows) {
ws.addRow([
r.name, r.bookedBy, r.variant ? `${r.option} — ${r.variant}` : r.option, r.email, r.phone, r.quantity,
...cols.map(c => r.checkIns[c.id] ? (r.quantity > 1 ? r.checkIns[c.id] : '✓') : ''),
...cols.map(c => r.checkIns[c.id] ? (r.quantity > 1 ? r.checkIns[c.id] : '✓') : (r.bookedSessions && !r.bookedSessions.includes(c.id) ? 'not booked' : '')),
`${r.attended}/${r.total}`, r.completed ? 'Yes' : 'No',
r.certificate && !r.certificate.revokedAt ? r.certificate.code : '',
]);
+3 -1
View File
@@ -250,7 +250,9 @@ async function basketBuyer(req, forUserId) {
/** Prices a basket the same way registration will, as coupon/bundle lines. */
async function priceBasket(event, options, buyer) {
const registrationOptions = [];
for (const o of options || []) {
// A "pick sessions" line counts once per chosen session.
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
for (const o of expanded) {
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
const qty = Number(o.quantity || 0);
if (!eo || qty < 1) continue;
+32 -6
View File
@@ -13,14 +13,21 @@ const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { se
// Adds the multi-day schedule to an event for API responses: sessions (cancelled ones
// flagged), when registration closes, the next session, and late-joiner pricing.
function withSchedule(ev) {
const sessions = (ev.sessions || []).map(({ _count, ...s }) => ({ ...s, cancelled: !!s.cancelledAt, hasCheckIns: (_count?.usages || 0) > 0 }));
// `bookings` (Map sessionId → people booked) adds `booked` and `placesLeft` per session.
function withSchedule(ev, bookings = null) {
const sessions = (ev.sessions || []).map(({ _count, ...s }) => ({
...s,
cancelled: !!s.cancelledAt,
hasCheckIns: (_count?.usages || 0) > 0,
...(bookings ? { booked: bookings.get(s.id) || 0, placesLeft: s.capacity > 0 ? Math.max(0, s.capacity - (bookings.get(s.id) || 0)) : null } : {}),
}));
const active = sessionsUtil.activeSessions(sessions);
const now = new Date();
return {
...ev,
sessions,
registrationClosesAt: sessionsUtil.registrationClosesAt(ev, sessions),
wholeCourseClosesAt: ev.scheduleType === 'sessions' ? sessionsUtil.wholeCourseClosesAt(ev, sessions) : null,
nextSession: ev.scheduleType === 'sessions' ? active.find(s => new Date(s.endsAt) > now) || null : null,
lateJoin: ev.scheduleType === 'sessions' ? sessionsUtil.lateInfo(ev, sessions, now) : null,
};
@@ -32,6 +39,10 @@ function scheduleData(body, current = {}) {
const has = (k) => body[k] !== undefined;
const data = {};
if (has('scheduleType')) data.scheduleType = body.scheduleType === 'sessions' ? 'sessions' : 'single';
if (has('attendanceMode')) data.attendanceMode = body.attendanceMode === 'pick' ? 'pick' : 'whole';
if ((data.attendanceMode ?? current.attendanceMode) === 'pick' && (body.requiresParentalConsent ?? current.requiresParentalConsent) === true) {
throw sessionsUtil.sessionError("Choosing sessions isn't available on youth events (parental consent).");
}
if (has('checkInWindow')) data.checkInWindow = body.checkInWindow === 'session_only' ? 'session_only' : 'whole_day';
if (has('lateJoinEnabled')) data.lateJoinEnabled = body.lateJoinEnabled === true;
if (has('latePricing')) data.latePricing = ['prorata', 'fixed'].includes(body.latePricing) ? body.latePricing : 'full';
@@ -342,7 +353,8 @@ const getEvents = async (req, res) => {
const withStock = await Promise.all(events.map(async ev => {
const earlyBirdEndsAt = earlyBirdEnds.get(ev.id) || null;
try {
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0);
// Picked-session options have per-session limits, so they never make the event sold out.
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0 && !sessionsUtil.isPickOption(ev, o));
let isSoldOut = false;
if (limitedOpts.length > 0) {
const { takenCount } = require('../utils/stock');
@@ -551,6 +563,16 @@ const getEventById = async (req, res) => {
eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => {
// Taken = non-cancelled registrations + places held for live waitlist offers
const { takenCount } = require('../utils/stock');
// Picked-session options: the stock limit is places per session.
if (sessionsUtil.isPickOption(event, opt)) {
const sessionAvailability = {};
if (opt.stockLimit > 0) {
for (const s of sessionsUtil.activeSessions(event.sessions)) {
sessionAvailability[s.id] = Math.max(0, opt.stockLimit - await takenCount({ eventOptionId: opt.id, sessionId: s.id }));
}
}
return { ...opt, soldCount: 0, availableCount: null, sessionAvailability };
}
const soldCount = await takenCount({ eventOptionId: opt.id });
const availableCount = opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null;
@@ -569,6 +591,7 @@ const getEventById = async (req, res) => {
}
if (!event.sessions) event.sessions = await prisma.eventSession.findMany({ where: { eventId }, ...SESSIONS_INCLUDE }).catch(() => []);
const bookings = event.scheduleType === 'sessions' ? await sessionsUtil.sessionBookings(eventId).catch(() => null) : null;
const mapped = withSchedule({
...event,
eventOptions: eventOptionsWithStock,
@@ -579,7 +602,7 @@ const getEventById = async (req, res) => {
})),
form,
bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []),
});
}, bookings);
res.json(mapped);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
@@ -915,7 +938,7 @@ const createEventOption = async (req, res) => {
await assertEventOpen(req.params.id, res);
const { name, price, isMainTicket, stockLimit, staffPrice } = req.body;
const { name, price, isMainTicket, stockLimit, staffPrice, sessionMode } = req.body;
const eventOption = await prisma.eventOption.create({
data: {
@@ -926,8 +949,10 @@ const createEventOption = async (req, res) => {
isMainTicket: isMainTicket || false,
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
staffPrice: parseStaffPrice(staffPrice),
sessionMode: sessionMode === 'pick' ? 'pick' : 'all',
}
});
sessionsUtil.forgetSchedule(req.params.id);
res.status(201).json(eventOption);
} catch (error) {
@@ -952,7 +977,7 @@ const updateEventOption = async (req, res) => {
await assertEventOpen(eventOption.eventId, res);
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice } = req.body;
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice, sessionMode } = req.body;
const updatedEventOption = await prisma.eventOption.update({
where: { id: req.params.id },
@@ -962,6 +987,7 @@ const updateEventOption = async (req, res) => {
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
staffPrice: parseStaffPrice(staffPrice, eventOption.staffPrice),
sessionMode: sessionMode !== undefined ? (sessionMode === 'pick' ? 'pick' : 'all') : eventOption.sessionMode,
}
});
@@ -38,7 +38,8 @@ const TICKET_DELIVERY = ['payer', 'each'];
*/
function placesAfter(existingLines, requested) {
const out = {};
for (const l of [...(existingLines || []), ...(requested || [])]) {
// Picked-session lines take no attendee names.
for (const l of [...(existingLines || []), ...(requested || [])].filter(x => !x.sessionId)) {
const k = lineKey(l.eventOptionId, l.variantId);
out[k] = (out[k] || 0) + Number(l.quantity || 0);
}
@@ -52,7 +53,7 @@ const ACTIVE_COUPONS_INCLUDE = {
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
};
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles');
const { registrationClosesAt } = require('../utils/sessions');
const { registrationClosesAt, wholeCourseClosesAt, expandPickLines, assertSessionCapacity, isPickOption } = require('../utils/sessions');
/**
* Coupon lines for options that haven't been saved yet, so a code can be checked
@@ -92,18 +93,18 @@ async function priceRequestedOption(eventOption, variantId, qty, buyer) {
* offers count as taken, except the offer being claimed (`excludeEntryId`).
* Returns { available: boolean, remaining: number|null }
*/
async function checkOptionStock(option, requestedQty, excludeEntryId = null) {
async function checkOptionStock(option, requestedQty, excludeEntryId = null, sessionId = null) {
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null };
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id }, { excludeEntryId });
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, { excludeEntryId });
return { available: remaining >= requestedQty, remaining };
}
/**
* Check stock availability for a specific variant (same rules as checkOptionStock).
*/
async function checkVariantStock(variant, requestedQty, excludeEntryId = null) {
async function checkVariantStock(variant, requestedQty, excludeEntryId = null, sessionId = null) {
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null };
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id }, { excludeEntryId });
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id, sessionId }, { excludeEntryId });
return { available: remaining >= requestedQty, remaining };
}
@@ -222,11 +223,20 @@ const createRegistration = async (req, res) => {
throw new Error('At least one option must be selected');
}
// "Pick sessions" options become one line per chosen session; whole-course tickets
// on such an event still close when the whole course does.
const lines = expandPickLines(event, options, { now });
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId) && now >= wholeCourseClosesAt(event, event.sessions)) {
res.status(400);
throw new Error('Whole-course tickets are no longer on sale — choose the sessions you want instead.');
}
await assertSessionCapacity(event, lines);
// Validate each option and resolve prices / check stock.
// Options are independent of each other, so resolve them concurrently. Errors are marked
// with `.isStockError` rather than relying on shared `res.statusCode` (which would race
// across concurrent iterations) — the outer catch always responds 400 regardless.
const resolvedOptions = await Promise.all(options.map(async (option) => {
const resolvedOptions = await Promise.all(lines.map(async (option) => {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
if (!eventOption) {
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
@@ -239,7 +249,7 @@ const createRegistration = async (req, res) => {
// Check overall option stock
try {
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id);
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id, option.sessionId);
if (!stockCheck.available) {
const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
err.isStockError = true;
@@ -259,7 +269,7 @@ const createRegistration = async (req, res) => {
throw new Error(`Variant not found for option "${eventOption.name}"`);
}
try {
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id);
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id, option.sessionId);
if (!vStock.available) {
const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
err.isStockError = true;
@@ -320,9 +330,9 @@ const createRegistration = async (req, res) => {
// tickets bought at different early-bird prices into a single (wrong) price. Each
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
for (const opt of resolvedOptions) {
// Match on eventOptionId + variantId for correct row
// Match on eventOptionId + variantId (+ session, for picked sessions) for correct row
const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null)
);
if (existing) {
await prisma.$transaction([
@@ -348,6 +358,7 @@ const createRegistration = async (req, res) => {
eventOptionId: opt.eventOptionId,
quantity: opt.quantity,
variantId: opt.variantId || null,
sessionId: opt.sessionId || null,
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: {
@@ -395,6 +406,7 @@ const createRegistration = async (req, res) => {
eventOptionId: option.eventOptionId,
quantity: option.quantity,
variantId: option.variantId || null,
sessionId: option.sessionId || null,
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
@@ -941,7 +953,7 @@ const createManualRegistration = async (req, res) => {
//Check event
const event = await prisma.event.findUnique({
where: {id: eventId},
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } }, sessions: true },
});
if (!event) {
@@ -962,9 +974,13 @@ const createManualRegistration = async (req, res) => {
throw new Error('At least one option must be selected');
}
// Picked sessions become one line each; staff can still book a session until it ends.
const lines = expandPickLines(event, options, { staff: true });
await assertSessionCapacity(event, lines);
// Validate each option up front, before any account is created or changed —
// including stock, which staff registrations previously skipped.
for (const option of options) {
for (const option of lines) {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
if (!eventOption) {
res.status(400);
@@ -975,8 +991,8 @@ const createManualRegistration = async (req, res) => {
throw new Error('Quantity must be at least 1');
}
const variant = option.variantId ? (eventOption.variants || []).find(v => v.id === option.variantId) : null;
const oStock = await checkOptionStock(eventOption, option.quantity);
const vStock = variant ? await checkVariantStock(variant, option.quantity) : { available: true };
const oStock = await checkOptionStock(eventOption, option.quantity, null, option.sessionId);
const vStock = variant ? await checkVariantStock(variant, option.quantity, null, option.sessionId) : { available: true };
if (!oStock.available || !vStock.available) {
res.status(400);
const left = Math.max(0, Math.min(oStock.remaining ?? Infinity, vStock.remaining ?? Infinity));
@@ -986,7 +1002,7 @@ const createManualRegistration = async (req, res) => {
// Pricing (appliedTierId + priceSnapshot) depends on who the attendee is — Staff and
// above get the staff price — so it runs once the account is resolved below.
const priceManualOptions = (attendee) => Promise.all(options.map(async (option) => {
const priceManualOptions = (attendee) => Promise.all(lines.map(async (option) => {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
const variantId = option.variantId || null;
const priced = await priceRequestedOption(eventOption, variantId, option.quantity, attendee);
@@ -1158,7 +1174,7 @@ const createManualRegistration = async (req, res) => {
// tickets bought at different early-bird prices would blend into a single wrong price.
await Promise.all(resolvedManualOptions.map(opt => {
const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null)
);
if (existing) {
return prisma.$transaction([
@@ -1188,6 +1204,7 @@ const createManualRegistration = async (req, res) => {
eventOptionId: opt.eventOptionId,
quantity: opt.quantity,
variantId: opt.variantId || null,
sessionId: opt.sessionId || null,
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: {
@@ -1256,6 +1273,7 @@ const createManualRegistration = async (req, res) => {
eventOptionId: option.eventOptionId,
quantity: option.quantity,
variantId: option.variantId || null,
sessionId: option.sessionId || null,
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
@@ -1465,6 +1483,7 @@ const updateRegistrationOptions = async (req, res) => {
}
const validOptionIds = new Set((registration.event?.eventOptions || []).map(eo => eo.id));
const pickOptionIds = new Set((registration.event?.eventOptions || []).filter(eo => isPickOption(registration.event, eo)).map(eo => eo.id));
for (const opt of options) {
if (!opt || !opt.eventOptionId || !Number.isInteger(opt.quantity) || opt.quantity < 1) {
res.status(400);
@@ -1474,8 +1493,17 @@ const updateRegistrationOptions = async (req, res) => {
res.status(400);
throw new Error('One or more options do not belong to this event');
}
if (pickOptionIds.has(opt.eventOptionId)) {
res.status(400);
throw new Error("Sessions are booked by registering for them — they can't be changed here.");
}
}
// Picked-session lines aren't edited here: leave them exactly as they are (they still
// count towards the total due).
const pickedLines = registration.registrationOptions.filter(ro => ro.sessionId);
registration.registrationOptions = registration.registrationOptions.filter(ro => !ro.sessionId);
// Compute totalPaid
const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
@@ -1576,6 +1604,7 @@ const updateRegistrationOptions = async (req, res) => {
resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId, existingRows, existingQty, delta, removalPlan });
}
newTotalDue += rowsTotal(pickedLines);
if (newTotalDue < totalPaid) {
res.status(400);
throw new Error('Cannot reduce items below the amount already paid');
+11 -7
View File
@@ -92,6 +92,7 @@ const getUserTickets = async (req, res) => {
include: {
eventOption: true,
variant: true,
session: true,
registration: true
}
},
@@ -262,6 +263,7 @@ const getScanPreview = async (req, res) => {
t."isUsed",
t."eventId",
ro.id AS "registrationOptionId",
ro."sessionId" AS "pinnedSessionId",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
ov.id AS "variantId",
@@ -314,7 +316,7 @@ const getScanPreview = async (req, res) => {
if (r.scheduleType === 'sessions') {
const sessionsUtil = require('../utils/sessions');
const schedule = await sessionsUtil.eventSchedule(r.eventId);
const found = sessionsUtil.scanSession(schedule, schedule.sessions, req.query.sessionId || null);
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, req.query.sessionId || null);
sessionError = found.error;
if (found.session) {
session = { id: found.session.id, label: sessionsUtil.sessionLabel(found.session, schedule.sessions) };
@@ -361,6 +363,7 @@ const scanTicket = async (req, res) => {
t."eventId",
e.title AS "eventTitle",
e."scheduleType" AS "scheduleType",
ro."sessionId" AS "pinnedSessionId",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
eo."isMainTicket" AS "isMainTicket",
@@ -381,7 +384,7 @@ const scanTicket = async (req, res) => {
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${req.params.qrCode}
GROUP BY t.id, e.id, eo.id
GROUP BY t.id, e.id, eo.id, ro.id
`;
if (!rows || rows.length === 0) {
@@ -438,7 +441,7 @@ const scanTicket = async (req, res) => {
if (r.scheduleType === 'sessions') {
sessionsUtil = require('../utils/sessions');
schedule = await sessionsUtil.eventSchedule(ticket.eventId);
const found = sessionsUtil.scanSession(schedule, schedule.sessions, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null);
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null);
if (!found.session) return res.status(400).json({ message: found.error, code: 'NO_SESSION', ticket });
session = found.session;
}
@@ -544,7 +547,7 @@ const getTicketsByEvent = async (req, res) => {
const tickets = await prisma.ticket.findMany({
where: { eventId: req.params.eventId },
include: {
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
registrationOption: { include: { eventOption: true, variant: true, session: true, registration: { select: { id: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
attendee: { select: { id: true, name: true, email: true, phone: true } },
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true, collectGender: true, scheduleType: true, sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } } } },
@@ -914,7 +917,7 @@ function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) {
// Event title, with the date on the right of the same line
let cy = y + pad;
const eventDate = ticket.event.startDate ? new Date(ticket.event.startDate) : null;
const dateStr = require('../utils/sessions').ticketDateText(ticket.event)
const dateStr = require('../utils/sessions').ticketDateText(ticket.event, 'short', ticket.registrationOption.session)
|| (eventDate ? new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate) : '');
doc.font('Helvetica').fontSize(9);
const dateW = dateStr ? doc.widthOfString(dateStr) : 0;
@@ -980,7 +983,7 @@ async function emailTicketsInternal(req, res, userOverride) {
const ticketInclude = {
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
registrationOption: { include: { eventOption: true, variant: true, session: true, registration: { select: { ticketDelivery: true } } } },
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
user: { select: { name: true } },
usages: true,
@@ -1088,7 +1091,8 @@ async function emailTicketsInternal(req, res, userOverride) {
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
const eventTitle = tickets[0].event.title;
const eventDate = require('../utils/sessions').ticketDateText(tickets[0].event, 'long') || (tickets[0].event.startDate
const pinnedSessions = tickets.map(t => t.registrationOption?.session).filter(Boolean);
const eventDate = require('../utils/sessions').ticketDateText(tickets[0].event, 'long', pinnedSessions.length === tickets.length && pinnedSessions.length === 1 ? pinnedSessions[0] : null, pinnedSessions.length === tickets.length ? pinnedSessions : null) || (tickets[0].event.startDate
? new Date(tickets[0].event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
: '');
const pdfFilename = `tickets-${eventTitle.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
+12 -5
View File
@@ -17,7 +17,7 @@ const path = require('path');
const PDFDocument = require('pdfkit');
const QRCode = require('qrcode');
const prisma = require('../config/db');
const { activeSessions, completionMet, normalizeCertificateConfig, attendanceBySession, TIMEZONE } = require('./sessions');
const { activeSessions, completionMet, normalizeCertificateConfig, attendanceBySession, groupTickets, TIMEZONE } = require('./sessions');
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const AUTO_ISSUE_DAYS = 14;
@@ -57,18 +57,25 @@ async function attendanceForCertificates(eventId) {
holder: { select: { id: true, name: true } },
user: { select: { id: true, name: true } },
usages: { select: { quantityRedeemed: true, sessionId: true } },
registrationOption: { select: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true } },
},
orderBy: { createdAt: 'asc' },
});
if (event.scheduleType === 'sessions') {
const active = activeSessions(event.sessions);
const activeIds = new Set(active.map(s => s.id));
const bySession = await attendanceBySession(eventId);
// A picked-session booking (one ticket per session) is one person: its first ticket
// carries the certificate, and it's measured against the sessions it booked.
return {
event,
rows: tickets.map(t => {
const m = bySession.get(t.id) || new Map();
const attended = [...m.entries()].filter(([sid, qty]) => activeIds.has(sid) && qty > 0).length;
return { ticket: t, attended, total: active.length };
rows: groupTickets(tickets).map(g => {
const booked = g.sessionIds ? new Set(g.sessionIds.filter(id => activeIds.has(id))) : activeIds;
const attendedIds = new Set();
for (const t of g.tickets) {
for (const [sid, qty] of (bySession.get(t.id) || new Map())) if (booked.has(sid) && qty > 0) attendedIds.add(sid);
}
return { ticket: g.tickets[0], attended: attendedIds.size, total: booked.size };
}),
};
}
+6 -1
View File
@@ -56,6 +56,7 @@ function judgeSyncedScan(ticket, qty) {
if (!ticket) return { redeem: 0, conflict: 'not_found' };
if (ticket.transferred) return { redeem: 0, conflict: 'transferred' };
if (ticket.noSession) return { redeem: 0, conflict: 'no_session' };
if (ticket.wrongSession) return { redeem: 0, conflict: 'wrong_session' };
if (ticket.cancelled) return { redeem: 0, conflict: 'cancelled' };
if (!ticket.inScope) return { redeem: 0, conflict: 'wrong_scope' };
if (ticket.remaining <= 0) return { redeem: 0, conflict: 'already_used' };
@@ -73,6 +74,7 @@ const CONFLICT_TEXT = {
partly_used: 'Fewer places were left than were checked in offline',
consent: 'Checked in without parental consent',
no_session: 'Scanned on a day (or at a time) with no session open for check-in',
wrong_session: 'Ticket is for a different session',
};
// ─── Database helpers ─────────────────────────────────────────────────────────
@@ -145,8 +147,10 @@ async function snapshotFor(lock) {
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
noSessionToday: schedule?.scheduleType === 'sessions' && !session,
tickets: tickets.map(t => {
// A picked-session ticket for another session has nothing left today.
const otherSession = !!(t.registrationOption.sessionId && t.registrationOption.sessionId !== session?.id);
const redeemed = schedule?.scheduleType === 'sessions'
? (session ? sessionsUtil.redeemedForSession(t.usages, session.id) : t.quantity || 1)
? (session && !otherSession ? sessionsUtil.redeemedForSession(t.usages, session.id) : t.quantity || 1)
: t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
return {
id: t.id,
@@ -200,6 +204,7 @@ async function syncScans(lock, scans) {
state = {
sessionId: session?.id || null,
noSession: hasSessions && !session,
wrongSession: !!(session && ticket.registrationOption.sessionId && ticket.registrationOption.sessionId !== session.id),
remaining: (ticket.quantity || 1) - redeemed,
cancelled: ticket.registrationOption.registration.status === 'cancelled',
inScope: !optionIds || optionIds.has(ticket.registrationOption.eventOptionId),
+5 -6
View File
@@ -3,7 +3,7 @@
* before" and "1 day before" each session, by email and/or WhatsApp.
*
* Rules:
* - Goes to everyone with a ticket for the event (the booker, and named attendees with
* - Goes to everyone with a ticket for the session (the booker, and named attendees with
* an account), skipping cancelled registrations. One message per person per session.
* - Each (session, offset) is sent once (SessionReminderLog). When several offsets are
* already due — e.g. reminders were switched on 2 h before a session — only the
@@ -21,10 +21,10 @@ function fmtWhen(d) {
return new Date(d).toLocaleString('en-GB', { timeZone: TIMEZONE, weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
}
/** People to remind for an event: Map userId → user. */
async function recipientsFor(eventId) {
/** People to remind about a session: whole-course tickets plus picked places on it. Map userId → user. */
async function recipientsFor(eventId, sessionId) {
const tickets = await prisma.ticket.findMany({
where: { eventId, registrationOption: { registration: { status: { not: 'cancelled' } } } },
where: { eventId, registrationOption: { registration: { status: { not: 'cancelled' } }, OR: [{ sessionId: null }, { sessionId }] } },
select: {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
holder: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
@@ -84,7 +84,6 @@ async function runSessionReminderJob(now = new Date()) {
const offsets = (config.offsets || []).map(Number).filter(n => n > 0).sort((a, b) => a - b);
if (offsets.length === 0) continue;
const allSessions = await prisma.eventSession.findMany({ where: { eventId: event.id } });
let recipients = null;
for (const session of event.sessions) {
const minutesLeft = (new Date(session.startsAt) - now) / 60000;
const loggedOffsets = new Set(session.reminders.map(r => r.offset));
@@ -102,7 +101,7 @@ async function runSessionReminderJob(now = new Date()) {
}
}
if (!claimed.includes(due[0])) continue; // the closest one was claimed elsewhere
recipients = recipients || await recipientsFor(event.id);
const recipients = await recipientsFor(event.id, session.id);
let count = 0;
for (const user of recipients.values()) {
try {
+161 -8
View File
@@ -13,6 +13,12 @@
* 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; a pick option's stock limit is places per session.
* Not available on youth (parental consent) events, and picked lines take no names.
*/
const prisma = require('../config/db');
@@ -70,6 +76,7 @@ function normalizeSessionsInput(list) {
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);
@@ -102,7 +109,8 @@ function currentSession(sessions, now = new Date(), window = 'whole_day') {
}
/** When public registration closes for an event (Date). */
function registrationClosesAt(event, sessions) {
/** 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') {
@@ -115,6 +123,98 @@ function registrationClosesAt(event, sessions) {
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 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); other options stay as they are. 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 = [];
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))] : [];
if (!pick) {
if (ids.length) throw sessionError(`"${option?.name || 'This ticket'}" covers every session — no sessions to choose.`);
out.push({ ...line, sessionId: null });
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) {
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.`);
const { sessionIds, ...rest } = line;
void sessionIds;
out.push({ ...rest, sessionId: id });
}
}
return out;
}
/**
* People booked per session: picked places on that session plus whole-course main
* tickets (which attend every session). Cancelled registrations don't count.
* @returns {Promise<Map<string, number>>}
*/
async function sessionBookings(eventId, db = prisma) {
const lines = await db.registrationOption.findMany({
where: { registration: { eventId, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } },
select: { quantity: true, sessionId: true },
});
const sessions = await db.eventSession.findMany({ where: { eventId, cancelledAt: null }, select: { id: true } });
const whole = lines.filter(l => !l.sessionId).reduce((s, l) => s + (l.quantity || 0), 0);
const out = new Map(sessions.map(s => [s.id, whole]));
for (const l of lines) if (l.sessionId && out.has(l.sessionId)) out.set(l.sessionId, out.get(l.sessionId) + (l.quantity || 0));
return out;
}
/**
* Checks requested lines fit each session's capacity. Whole-course main tickets count
* against every session; picked main tickets against their own.
*/
async function assertSessionCapacity(event, lines, db = prisma) {
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();
const whole = lines.filter(l => !l.sessionId && mains.has(l.eventOptionId)).reduce((s, l) => s + (Number(l.quantity) || 0), 0);
for (const s of limited) wanted.set(s.id, whole);
for (const l of lines) if (l.sessionId && mains.has(l.eventOptionId) && wanted.has(l.sessionId)) wanted.set(l.sessionId, wanted.get(l.sessionId) + (Number(l.quantity) || 0));
if ([...wanted.values()].every(n => n === 0)) return;
const booked = await sessionBookings(event.id, db);
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.
@@ -204,7 +304,7 @@ async function syncEventSessions(tx, eventId, input) {
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, order: 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;
@@ -225,7 +325,7 @@ async function syncEventSessions(tx, eventId, input) {
}
const SCHEDULE_SELECT = {
id: true, endDate: true, registrationDeadline: true, scheduleType: true, checkInWindow: true,
id: true, endDate: true, registrationDeadline: true, scheduleType: true, attendanceMode: true, checkInWindow: true,
lateJoinEnabled: true, lateJoinUntilSessionId: true, latePricing: true, lateFixedPrice: true,
sessions: { orderBy: { startsAt: 'asc' } },
};
@@ -284,11 +384,22 @@ function scanSession(event, sessions, requestedId = null, now = new Date()) {
* (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') {
function ticketDateText(event, style = 'short', session = null, sessions = null) {
if (event?.scheduleType !== 'sessions') return null;
const fmt = (d, opts) => new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, ...opts });
// 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;
const fmt = (d, opts) => new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, ...opts });
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)`;
@@ -299,6 +410,46 @@ function ticketDateText(event, style = 'short') {
: `${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).
*/
function ticketScanSession(event, sessions, pinnedSessionId, requestedId = null, now = new Date()) {
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 }.
* @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: 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);
@@ -306,7 +457,8 @@ function redeemedForSession(usages, sessionId) {
/** Applies late pricing to a resolved option price ({ price, tierId }). */
async function withLatePrice(option, resolved) {
if (!option?.isMainTicket || !option.eventId || !(resolved.price > 0)) return 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));
@@ -334,7 +486,8 @@ async function attendanceBySession(eventId) {
module.exports = {
TIMEZONE, MAX_SESSIONS, REMINDER_OFFSETS, CERTIFICATE_TEMPLATES, sessionError,
dayKey, activeSessions, scheduleBounds, normalizeSessionsInput, sessionAcceptsCheckIn, currentSession,
sessionLabel, scanSession, redeemedForSession, ticketDateText,
registrationClosesAt, lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig,
sessionLabel, scanSession, ticketScanSession, redeemedForSession, ticketDateText, groupTickets,
registrationClosesAt, wholeCourseClosesAt, isPickOption, expandPickLines, sessionBookings, assertSessionCapacity,
lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig,
syncEventSessions, eventSchedule, forgetSchedule, withLatePrice, attendanceBySession,
};
+6 -4
View File
@@ -3,18 +3,20 @@
*
* Taken = places on registrations that aren't cancelled + places held for a live
* waitlist offer (until it expires). An option's stock limit covers all its variants;
* a variant can also have its own limit. 0 means unlimited.
* a variant can also have its own limit. 0 means unlimited. For a "pick sessions"
* option the limit is per session (pass sessionId).
*/
const prisma = require('../config/db');
/**
* @param {{ eventOptionId: string, variantId?: string|null }} line - variantId given → that variant only
* @param {{ eventOptionId: string, variantId?: string|null, sessionId?: string|null }} line -
* variantId given → that variant only; sessionId given → that session's picked places only
* @param {{ excludeEntryId?: string, db?: object }} [opts] - excludeEntryId: ignore this
* waitlist offer's hold (the person claiming it)
*/
async function takenCount({ eventOptionId, variantId = null }, { excludeEntryId = null, db = prisma } = {}) {
const scope = variantId ? { variantId } : { eventOptionId };
async function takenCount({ eventOptionId, variantId = null, sessionId = null }, { excludeEntryId = null, db = prisma } = {}) {
const scope = { ...(variantId ? { variantId } : { eventOptionId }), ...(sessionId ? { sessionId } : {}) };
const [registered, held] = await Promise.all([
db.registrationOption.aggregate({
where: { ...scope, registration: { status: { not: 'cancelled' } } },
+2 -2
View File
@@ -48,11 +48,11 @@ const generateTicketsForRegistration = async (registrationId) => {
}
} catch (_) { /* form models unavailable — don't block */ }
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId) ──
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId, session) ──
// Key on both fields so that different variants of the same option are never merged
const byOption = new Map();
for (const ro of registration.registrationOptions) {
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
const key = `${ro.eventOptionId}::${ro.variantId || ''}::${ro.sessionId || ''}`;
if (!byOption.has(key)) byOption.set(key, []);
byOption.get(key).push(ro);
}
+64
View File
@@ -14,6 +14,11 @@ const {
scanSession,
redeemedForSession,
sessionLabel,
isPickOption,
expandPickLines,
ticketScanSession,
groupTickets,
wholeCourseClosesAt,
} = require('../src/utils/sessions');
// Africa/Johannesburg is UTC+2 all year, so 16:30Z is 18:30 local.
@@ -268,3 +273,62 @@ describe('redeemedForSession / sessionLabel', () => {
expect(sessionLabel(list[2], list)).toBe('Session 2');
});
});
describe('pick sessions', () => {
const pickEvent = {
scheduleType: 'sessions', attendanceMode: 'pick', endDate: wed(32, 18, 30), registrationDeadline: null,
lateJoinEnabled: false, sessions: COURSE,
eventOptions: [{ id: 'drop', name: 'Drop-in', sessionMode: 'pick' }, { id: 'full', name: 'Full course', sessionMode: 'all' }],
};
test('isPickOption needs a pick-mode multi-day event and a pick option', () => {
expect(isPickOption(pickEvent, pickEvent.eventOptions[0])).toBe(true);
expect(isPickOption(pickEvent, pickEvent.eventOptions[1])).toBe(false);
expect(isPickOption({ ...pickEvent, attendanceMode: 'whole' }, pickEvent.eventOptions[0])).toBe(false);
});
test('a pick line becomes one line per session; others stay as they are', () => {
const out = expandPickLines(pickEvent, [
{ eventOptionId: 'drop', quantity: 2, sessionIds: ['s3', 's4', 's3'] },
{ eventOptionId: 'full', quantity: 1 },
], { now: wed(5) });
expect(out).toEqual([
{ eventOptionId: 'drop', quantity: 2, sessionId: 's3' },
{ eventOptionId: 'drop', quantity: 2, sessionId: 's4' },
{ eventOptionId: 'full', quantity: 1, sessionId: null },
]);
});
test('sessions must be chosen, upcoming, and not on whole-course tickets', () => {
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1 }])).toThrow(/Choose the sessions/);
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: wed(5) })).toThrow(/already started/);
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'full', quantity: 1, sessionIds: ['s3'] }])).toThrow(/covers every session/);
expect(() => expandPickLines({ ...pickEvent, requiresParentalConsent: true }, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s3'] }], { now: wed(5) })).toThrow(/youth/);
});
test('staff can still book a session that is in progress', () => {
const during = new Date(wed(4).getTime() + 30 * 60000);
expect(expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: during, staff: true })).toHaveLength(1);
});
test('registration stays open until the last session; whole-course tickets close at the first', () => {
expect(registrationClosesAt(pickEvent, COURSE)).toEqual(wed(32));
expect(wholeCourseClosesAt(pickEvent, COURSE)).toEqual(wed(4));
});
test('a picked ticket only checks in on its own session', () => {
const event = { checkInWindow: 'whole_day' };
expect(ticketScanSession(event, COURSE, 's3', null, new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3');
expect(ticketScanSession(event, COURSE, 's3', null, new Date('2026-11-11T09:00:00Z')).error).toMatch(/This ticket is for Session 3 \(2026-11-18\)/);
expect(ticketScanSession(event, COURSE, 's3', 's2', new Date('2026-11-18T09:00:00Z')).error).toMatch(/only for Session 3/);
expect(ticketScanSession(event, COURSE, null, null, new Date('2026-11-11T09:00:00Z')).session.id).toBe('s2');
});
test('groupTickets puts one booking\'s picked tickets together', () => {
const t = (id, sessionId, registrationId = 'r1', eventOptionId = 'drop') => ({ id, registrationOption: { registrationId, eventOptionId, variantId: null, sessionId } });
const groups = groupTickets([t('a', 's1'), t('b', null, 'r1', 'full'), t('c', 's2'), t('d', 's1', 'r2')]);
expect(groups.map(g => g.tickets.map(x => x.id))).toEqual([['a', 'c'], ['b'], ['d']]);
expect(groups[0].sessionIds).toEqual(['s1', 's2']);
expect(groups[1].sessionIds).toBeNull();
});
});