From e338768f6ee412cc4d55e908bd4434bea690aac6 Mon Sep 17 00:00:00 2001 From: joshua Date: Thu, 22 Oct 2026 16:05:48 +0200 Subject: [PATCH] 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 --- .../migration.sql | 17 ++ backend/prisma/schema.prisma | 16 ++ .../src/controllers/attendanceController.js | 33 ++-- backend/src/controllers/couponController.js | 4 +- backend/src/controllers/eventController.js | 38 +++- .../src/controllers/registrationController.js | 63 +++++-- backend/src/controllers/ticketController.js | 18 +- backend/src/utils/certificates.js | 17 +- backend/src/utils/scanLocks.js | 7 +- backend/src/utils/sessionReminders.js | 11 +- backend/src/utils/sessions.js | 169 +++++++++++++++++- backend/src/utils/stock.js | 10 +- backend/src/utils/ticketUtils.js | 4 +- backend/tests/sessions.test.js | 64 +++++++ 14 files changed, 403 insertions(+), 68 deletions(-) create mode 100644 backend/prisma/migrations/20261022135629_add_pick_sessions/migration.sql diff --git a/backend/prisma/migrations/20261022135629_add_pick_sessions/migration.sql b/backend/prisma/migrations/20261022135629_add_pick_sessions/migration.sql new file mode 100644 index 0000000..dd50069 --- /dev/null +++ b/backend/prisma/migrations/20261022135629_add_pick_sessions/migration.sql @@ -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; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e227104..f472fc1 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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]) } diff --git a/backend/src/controllers/attendanceController.js b/backend/src/controllers/attendanceController.js index 80505ae..43d26ff 100644 --- a/backend/src/controllers/attendanceController.js +++ b/backend/src/controllers/attendanceController.js @@ -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 : '', ]); diff --git a/backend/src/controllers/couponController.js b/backend/src/controllers/couponController.js index a0309c9..6c2fa3f 100644 --- a/backend/src/controllers/couponController.js +++ b/backend/src/controllers/couponController.js @@ -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; diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js index a201859..fe8f7c9 100644 --- a/backend/src/controllers/eventController.js +++ b/backend/src/controllers/eventController.js @@ -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, } }); diff --git a/backend/src/controllers/registrationController.js b/backend/src/controllers/registrationController.js index 6d8258c..7373107 100644 --- a/backend/src/controllers/registrationController.js +++ b/backend/src/controllers/registrationController.js @@ -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'); diff --git a/backend/src/controllers/ticketController.js b/backend/src/controllers/ticketController.js index dc28a3a..02cd967 100644 --- a/backend/src/controllers/ticketController.js +++ b/backend/src/controllers/ticketController.js @@ -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`; diff --git a/backend/src/utils/certificates.js b/backend/src/utils/certificates.js index 9d7b1eb..12ef4ff 100644 --- a/backend/src/utils/certificates.js +++ b/backend/src/utils/certificates.js @@ -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 }; }), }; } diff --git a/backend/src/utils/scanLocks.js b/backend/src/utils/scanLocks.js index d623be4..1e95658 100644 --- a/backend/src/utils/scanLocks.js +++ b/backend/src/utils/scanLocks.js @@ -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), diff --git a/backend/src/utils/sessionReminders.js b/backend/src/utils/sessionReminders.js index 8abfbd2..732d18c 100644 --- a/backend/src/utils/sessionReminders.js +++ b/backend/src/utils/sessionReminders.js @@ -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 { diff --git a/backend/src/utils/sessions.js b/backend/src/utils/sessions.js index 34c9034..b347606 100644 --- a/backend/src/utils/sessions.js +++ b/backend/src/utils/sessions.js @@ -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>} + */ +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, }; diff --git a/backend/src/utils/stock.js b/backend/src/utils/stock.js index f878b84..eee9191 100644 --- a/backend/src/utils/stock.js +++ b/backend/src/utils/stock.js @@ -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' } } }, diff --git a/backend/src/utils/ticketUtils.js b/backend/src/utils/ticketUtils.js index 512e25e..4242d75 100644 --- a/backend/src/utils/ticketUtils.js +++ b/backend/src/utils/ticketUtils.js @@ -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); } diff --git a/backend/tests/sessions.test.js b/backend/tests/sessions.test.js index 4f5823f..94c527a 100644 --- a/backend/tests/sessions.test.js +++ b/backend/tests/sessions.test.js @@ -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(); + }); +});