Compare commits

..
Author SHA1 Message Date
joshuaandClaude Opus 5.5 30e5db82df Bump to 1.35.1
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:38:43 +02:00
joshuaandClaude Opus 5.5 f99b84858e Session gaps: per-session waitlist, door session picker, all-sessions choice
- Waitlist per session: entries for pick-sessions tickets name a session;
  offers use that session's free places, never run past its start, and
  claim links tick it. Held offers count towards session capacity.
- At the door: the Select items pop-up has the session picker, with
  today's session preselected for new attendees.
- On pick-sessions events every ticket shows the session list straight
  away. "All sessions" tickets record the ticked sessions
  (RegistrationOption.sessionIds) at one price; scanning, attendance,
  certificates, reminders and capacity follow them.
- Fix: per-session stock limits weren't enforced (takenCount filtered
  WaitlistEntry by a sessionId column that didn't exist, and the error
  was swallowed), and the public event page lost its stock info.
- Fix: editing a door registration with picked sessions couldn't be saved.
- Fix: the offline-scanning service worker served /_next/static cache-first
  forever, so dev browsers that had opened the scanner ran stale code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 11:38:21 +02:00
joshua 7c3f4694e2 Merge branch 'feat/pick-sessions'
CI / Backend tests (push) Canceled after 0s
CI / Frontend lint, typecheck, build (push) Canceled after 0s
CI / End-to-end tests (push) Canceled after 0s
CI / Deploy tagged release (push) Canceled after 0s
2026-10-23 09:25:35 +02:00
45 changed files with 700 additions and 193 deletions
+19
View File
@@ -7,6 +7,25 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.35.1] - 2026-10-23
### Added
- **Waitlist per session.** On pick-sessions events, a full session shows "Join waitlist" in the session list, and the waitlist works per session: when a place in that session frees up, the next person waiting for it gets the offer. Offers never run past the session's start, and nobody is offered a session that has started or was cancelled. The claim link ticks the session for them. My Events, the claim page and the supervisor Waitlist page show which session an entry is for.
- **"All sessions" tickets on pick-sessions events let people choose their sessions.** When an event's "How people book" is "Pick sessions", every ticket now shows the session list. A ticket set to "All sessions" is still one ticket at one price, whatever is ticked. The ticket then only scans on the ticked sessions (online and offline), and the attendance register, certificates, session reminders and session capacity follow them. Tickets and the ticket email list the ticked dates. A ticket set to "Pick sessions" is still charged per session. Events booked as "The whole course" show no session list. Bookings made before this cover every session, as before.
- **Session picker at the door.** The At the door "Select items" pop-up now has the session picker for per-session tickets, so walk-ins can be booked straight in. For a new attendee, a place in today's session is selected by default when there's room.
### Changed
- Per-session tickets show their list of sessions straight away (on the registration page, Manual registration, At the door and the self-service kiosk). Before, the list only appeared after pressing "+", so it wasn't obvious you could choose sessions. Ticking a session sets the quantity to 1.
### Fixed
- Per-session stock limits on pick-sessions tickets weren't being enforced, and the public event page lost all stock information on events with such a ticket.
- The offline-scanning service worker served the app's script files from its cache first, for good. On a development server, where those files keep their names when they change, a browser that had opened the scanner kept running old code on every page, even after a hard reload. It now fetches them from the network and uses the cache only when offline. Its cache is renamed, so old copies are cleared.
- At the door, editing a registration that has per-session tickets could no longer be saved ("below the quantity already issued").
- Whole-course tickets: the waitlist now also counts room in sessions that have a capacity, so it no longer says "places available" when a session is full. Places held for waitlist offers now count towards session capacity.
## [1.35.0] - 2026-10-23
### Added
+8 -2
View File
@@ -104,7 +104,12 @@ Feedback → Attendance**.
whole-course tickets. Sessions can have a capacity (picked places plus whole-course
tickets); a per-session ticket's stock limit is places per session. Picked tickets
only scan on their own session; reminders, the register and certificates follow
the sessions each person booked. Not available on youth events.
the sessions each person booked. "All sessions" tickets on such an event also show
the sessions: people tick the ones they'll come to and pay the ticket's one price; the
ticket scans, reminds and counts towards capacity for those sessions only. The waitlist
works per session (a full session shows "Join waitlist"). Bookable from the registration page, Manual registration,
At the door (today's session is preselected) and the kiosk. Not available on youth
events.
- **Late joining** (multi-day) — registration closes when the first session
starts, or later (until session N) with late joining on. Late joiners pay
full price, a pro-rata price for the sessions left (rounded to the nearest
@@ -169,7 +174,8 @@ Feedback → Attendance**.
emailed/WhatsApped a claim link and the places are held for them (24 hours by
default) so nobody else can buy them. Unclaimed offers expire and move on; people
who asked for more than came free can be offered what's free or skipped. Claiming
works without logging in. People can leave from My Events.
works without logging in. People can leave from My Events. On pick-sessions events
people wait for a particular session; offers never run past the session's start.
- **Parental consent (youth events)** — on events that require it, every attendee
gives a date of birth; for anyone under 18, a parent or guardian gets an email and
a WhatsApp with a consent link. They read the event's consent text, optionally give
+8 -3
View File
@@ -465,7 +465,11 @@ In a pick-mode event, a `pick` option is bought per session: registration lines
session becomes its own registration line (`RegistrationOption.sessionId`) and ticket.
Capacity counts picked places plus whole-course main tickets; a pick option's
`stockLimit` is per session. `PUT /api/registrations/:id/options` refuses pick options and
leaves picked lines untouched. Pick-mode events stay open until the last session starts
leaves picked lines untouched. An `all` option in a pick-mode event may also send
`sessionIds`: it stays one line and one ticket at one price, with the sessions kept in
`RegistrationOption.sessionIds` (empty = every session). That ticket only scans on those
sessions, and capacity, reminders, the register and certificates follow them; adding more
of the same ticket to a registration merges the sessions. Pick-mode events stay open until the last session starts
(`wholeCourseClosesAt` gives when whole-course tickets stop). Single-event responses add
`booked` and `placesLeft` per session and `sessionAvailability` per pick option.
@@ -504,10 +508,10 @@ Events with `waitlistEnabled` (plus `waitlistOfferHours`, default 24, and `waitl
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/api/waitlist` | optional | Join: `{ eventId, eventOptionId, variantId?, quantity }` (+ `guestName`, `guestEmail`, `guestPhone` when not logged in on a guest event). Only when the line can't cover the quantity |
| POST | `/api/waitlist` | optional | Join: `{ eventId, eventOptionId, variantId?, sessionId?, quantity }` (+ `guestName`, `guestEmail`, `guestPhone` when not logged in on a guest event). Only when the line can't cover the quantity. `sessionId` is required for a pick-sessions option (one session per entry) and refused otherwise |
| GET | `/api/waitlist/mine` | user+ | My active entries |
| DELETE | `/api/waitlist/:id` | user+ | Leave (an open offer moves on) |
| GET | `/api/waitlist/claim/:token` | public | What a claim link offers |
| GET | `/api/waitlist/claim/:token` | public | What a claim link offers (with `sessionId`, `sessionName`, `lineName` for a per-session offer; `eventStart` is the session's start) |
| GET | `/api/waitlist/events/:eventId` | staff+ | The queue for an event |
| POST | `/api/waitlist/:id/offer` | supervisor+ | Offer now (`{ quantity? }`), even over the stock limit |
| POST | `/api/waitlist/:id/move` | supervisor+ | `{ direction: "up" \| "down" }` |
@@ -519,6 +523,7 @@ How offers work:
- A job every 10 minutes (`waitlist` job; `WAITLIST_JOB_ENABLED`, `JOB_SCHEDULE_WAITLIST`) expires unclaimed offers and re-offers.
- Claiming means registering with `POST /api/registrations` plus `waitlistToken`. The token stands in for logging in, and the held places are released to the claimer.
- Manual registration and adding places to an existing booking are now checked against stock too.
- Pick-sessions options are waited for per session: free places are that session's (stock per session and session capacity), an offer expires no later than the session's start, and sessions that started or were cancelled aren't offered. For main tickets, free places also take session capacity into account, and held places count towards it.
## Parental consent
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.35.0",
"version": "1.35.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.35.0",
"version": "1.35.1",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.35.0",
"version": "1.35.1",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,8 @@
-- AlterTable
ALTER TABLE "WaitlistEntry" ADD COLUMN "sessionId" TEXT;
-- CreateIndex
CREATE INDEX "WaitlistEntry_sessionId_idx" ON "WaitlistEntry"("sessionId");
-- AddForeignKey
ALTER TABLE "WaitlistEntry" ADD CONSTRAINT "WaitlistEntry_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "RegistrationOption" ADD COLUMN "sessionIds" TEXT[] DEFAULT ARRAY[]::TEXT[];
+8
View File
@@ -344,6 +344,9 @@ model RegistrationOption {
// everything else.
sessionId String?
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
// An all-sessions option on a "pick sessions" event: the sessions chosen (one line,
// one ticket, one price). Empty = every session.
sessionIds String[] @default([])
@@index([registrationId])
@@index([eventOptionId])
@@ -1364,6 +1367,8 @@ model WaitlistEntry {
eventId String
eventOptionId String
variantId String?
// "Pick sessions" options: the session they're waiting for. Null for everything else.
sessionId String?
userId String
quantity Int
position Int
@@ -1379,12 +1384,14 @@ model WaitlistEntry {
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
eventOption EventOption @relation(fields: [eventOptionId], references: [id], onDelete: Cascade)
variant OptionVariant? @relation(fields: [variantId], references: [id], onDelete: Cascade)
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
registration Registration? @relation(fields: [registrationId], references: [id], onDelete: SetNull)
@@index([eventId, status, position])
@@index([eventOptionId, status])
@@index([variantId])
@@index([sessionId])
@@index([userId])
@@index([registrationId])
}
@@ -1483,6 +1490,7 @@ model EventSession {
usages TicketUsage[]
reminders SessionReminderLog[]
registrationOptions RegistrationOption[]
waitlistEntries WaitlistEntry[]
@@index([eventId, startsAt])
}
@@ -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: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true, eventOption: { select: { name: true } }, variant: { select: { name: true } } } },
registrationOption: { select: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true, sessionIds: 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' },
@@ -68,9 +68,10 @@ async function buildRegister(eventId) {
const cert = t.certificates[0] || null;
return {
ticketId: t.id,
// Picked sessions: which sessions this person booked, and the ticket for each.
// Picked or ticked sessions: which sessions this person booked, and the ticket for each
// (an all-sessions ticket with sessions ticked is one ticket for all of them).
bookedSessions: g.sessionIds,
sessionTickets: g.sessionIds ? Object.fromEntries(g.tickets.map(tk => [tk.registrationOption.sessionId, tk.id])) : null,
sessionTickets: g.sessionIds ? Object.fromEntries(g.tickets.flatMap(tk => (tk.registrationOption.sessionId ? [[tk.registrationOption.sessionId, tk.id]] : g.sessionIds.map(id => [id, 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 || '',
@@ -117,7 +118,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 } }, registrationOption: { select: { sessionId: true } } } });
const ticket = await prisma.ticket.findUnique({ where: { id: String(ticketId || '') }, include: { usages: true, event: { select: { scheduleType: true, cashupStatus: true } }, registrationOption: { select: { sessionId: true, sessionIds: 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';
@@ -126,7 +127,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');
if (ticket.registrationOption && !require('../utils/sessions').lineCovers(ticket.registrationOption, session.id)) throw badRequest('That ticket isn\'t booked for this session');
sessionId = session.id;
} else if (sessionKey !== WHOLE_EVENT) {
throw badRequest('This event has no sessions');
+5 -2
View File
@@ -250,8 +250,11 @@ 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 = [];
// 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]));
// A "pick sessions" line counts once per chosen session (an all-sessions ticket with
// sessions ticked is still one line at one price).
const { isPickOption } = require('../utils/sessions');
const perSession = (o) => isPickOption(event, event.eventOptions.find(x => x.id === o.eventOptionId));
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length && perSession(o) ? 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);
@@ -53,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, wholeCourseClosesAt, expandPickLines, assertSessionCapacity, isPickOption } = require('../utils/sessions');
const { registrationClosesAt, wholeCourseClosesAt, expandPickLines, assertSessionCapacity, isPickOption, mergeSessionIds } = require('../utils/sessions');
/**
* Coupon lines for options that haven't been saved yet, so a code can be checked
@@ -226,11 +226,12 @@ const createRegistration = async (req, res) => {
// "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)) {
// (An all-sessions ticket with sessions ticked is checked per session instead.)
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId && !l.sessionIds?.length) && 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);
await assertSessionCapacity(event, lines, undefined, { excludeEntryId: waitlistOffer?.id });
// Validate each option and resolve prices / check stock.
// Options are independent of each other, so resolve them concurrently. Errors are marked
@@ -347,7 +348,7 @@ const createRegistration = async (req, res) => {
}),
prisma.registrationOption.update({
where: { id: existing.id },
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null }
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null, ...(existing.sessionId ? {} : { sessionIds: mergeSessionIds(existing.sessionIds, opt.sessionIds) }) }
})
]);
} else {
@@ -359,6 +360,7 @@ const createRegistration = async (req, res) => {
quantity: opt.quantity,
variantId: opt.variantId || null,
sessionId: opt.sessionId || null,
sessionIds: opt.sessionIds || [],
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: {
@@ -407,6 +409,7 @@ const createRegistration = async (req, res) => {
quantity: option.quantity,
variantId: option.variantId || null,
sessionId: option.sessionId || null,
sessionIds: option.sessionIds || [],
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
@@ -1193,6 +1196,8 @@ const createManualRegistration = async (req, res) => {
quantity: existing.quantity + opt.quantity,
priceSnapshot: opt.priceSnapshot,
appliedTierId: opt.appliedTierId || null,
// More of an all-sessions ticket: it covers the sessions of both bookings.
...(existing.sessionId ? {} : { sessionIds: mergeSessionIds(existing.sessionIds, opt.sessionIds) }),
}
})
]);
@@ -1205,6 +1210,7 @@ const createManualRegistration = async (req, res) => {
quantity: opt.quantity,
variantId: opt.variantId || null,
sessionId: opt.sessionId || null,
sessionIds: opt.sessionIds || [],
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: {
@@ -1274,6 +1280,7 @@ const createManualRegistration = async (req, res) => {
quantity: option.quantity,
variantId: option.variantId || null,
sessionId: option.sessionId || null,
sessionIds: option.sessionIds || [],
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
+9 -4
View File
@@ -264,6 +264,7 @@ const getScanPreview = async (req, res) => {
t."eventId",
ro.id AS "registrationOptionId",
ro."sessionId" AS "pinnedSessionId",
ro."sessionIds" AS "chosenSessionIds",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
ov.id AS "variantId",
@@ -316,7 +317,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.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, req.query.sessionId || null);
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, req.query.sessionId || null, new Date(), r.chosenSessionIds);
sessionError = found.error;
if (found.session) {
session = { id: found.session.id, label: sessionsUtil.sessionLabel(found.session, schedule.sessions) };
@@ -364,6 +365,7 @@ const scanTicket = async (req, res) => {
e.title AS "eventTitle",
e."scheduleType" AS "scheduleType",
ro."sessionId" AS "pinnedSessionId",
ro."sessionIds" AS "chosenSessionIds",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
eo."isMainTicket" AS "isMainTicket",
@@ -441,7 +443,7 @@ const scanTicket = async (req, res) => {
if (r.scheduleType === 'sessions') {
sessionsUtil = require('../utils/sessions');
schedule = await sessionsUtil.eventSchedule(ticket.eventId);
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, 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, new Date(), r.chosenSessionIds);
if (!found.session) return res.status(400).json({ message: found.error, code: 'NO_SESSION', ticket });
session = found.session;
}
@@ -917,7 +919,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, 'short', ticket.registrationOption.session)
const dateStr = require('../utils/sessions').ticketDateText(ticket.event, 'short', ticket.registrationOption.session, null, ticket.registrationOption.sessionIds)
|| (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;
@@ -1092,7 +1094,10 @@ async function emailTicketsInternal(req, res, userOverride) {
const eventTitle = tickets[0].event.title;
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
// All-sessions tickets with the same sessions ticked: those sessions.
const chosenKeys = new Set(tickets.map(t => (t.registrationOption?.sessionIds || []).join(',')));
const chosenIds = chosenKeys.size === 1 && !pinnedSessions.length ? tickets[0].registrationOption?.sessionIds : null;
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, chosenIds) || (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`;
+29 -11
View File
@@ -2,8 +2,8 @@ const prisma = require('../config/db');
const { v4: uuidv4 } = require('uuid');
const { safeErrorMessage } = require('../utils/errorUtils');
const { findAccountByContact } = require('../utils/accountMatch');
const { remainingFor } = require('../utils/stock');
const { ACTIVE, makeOffer, queueWaitlist, findOfferByToken } = require('../utils/waitlist');
const { ACTIVE, makeOffer, queueWaitlist, findOfferByToken, placesFree, lineName } = require('../utils/waitlist');
const { activeSessions, isPickOption, sessionLabel } = require('../utils/sessions');
const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
@@ -11,19 +11,21 @@ const ENTRY_INCLUDE = {
event: { select: { id: true, title: true, startDate: true, picture: true } },
eventOption: { select: { id: true, name: true } },
variant: { select: { id: true, name: true } },
session: { select: { id: true, label: true, startsAt: true, endsAt: true } },
};
// @desc Join the waitlist for a sold-out option
// @route POST /api/waitlist
// @access Public (login needed when the event requires it)
// Body: { eventId, eventOptionId, variantId?, quantity, guestName?, guestEmail?, guestPhone? }
// Body: { eventId, eventOptionId, variantId?, sessionId?, quantity, guestName?, guestEmail?, guestPhone? }
// sessionId: required for a "pick sessions" option (one session per entry), not allowed otherwise.
const joinWaitlist = async (req, res) => {
try {
const { eventId, eventOptionId, variantId = null, quantity, guestName, guestEmail, guestPhone } = req.body || {};
const { eventId, eventOptionId, variantId = null, sessionId = null, quantity, guestName, guestEmail, guestPhone } = req.body || {};
const qty = parseInt(quantity, 10);
if (!eventId || !eventOptionId || !(qty >= 1)) { res.status(400); throw new Error('eventId, eventOptionId and a quantity of at least 1 are required'); }
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { variants: true } } } });
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { variants: true } }, sessions: true } });
if (!event || !event.isActive) { res.status(404); throw new Error('Event not found'); }
if (!event.waitlistEnabled) { res.status(400); throw new Error('This event does not have a waitlist.'); }
if (event.cashupStatus === 'closed' || new Date(event.endDate) < new Date()) { res.status(400); throw new Error('This event is no longer taking registrations.'); }
@@ -32,7 +34,16 @@ const joinWaitlist = async (req, res) => {
const variant = variantId ? (option.variants || []).find(v => v.id === variantId) : null;
if (variantId && !variant) { res.status(400); throw new Error('Unknown variant for this option'); }
const free = await remainingFor(option, variant);
if (isPickOption(event, option)) {
if (!sessionId) { res.status(400); throw new Error(`Choose which session of "${option.name}" you want to wait for.`); }
const session = activeSessions(event.sessions).find(s => s.id === sessionId);
if (!session) { res.status(400); throw new Error('That session isn\'t available.'); }
if (new Date(session.startsAt) <= new Date()) { res.status(400); throw new Error(`${sessionLabel(session, event.sessions)} has already started.`); }
} else if (sessionId) {
res.status(400); throw new Error(`"${option.name}" covers every session — no session to choose.`);
}
const free = await placesFree(event, option, variant, sessionId);
if (free === null || free >= qty) { res.status(400); throw new Error('There are places available — you can register now.'); }
// Who's joining
@@ -47,15 +58,15 @@ const joinWaitlist = async (req, res) => {
})).id;
}
const existing = await prisma.waitlistEntry.findFirst({ where: { userId, eventOptionId, variantId, status: { in: ACTIVE } } });
const existing = await prisma.waitlistEntry.findFirst({ where: { userId, eventOptionId, variantId, sessionId, status: { in: ACTIVE } } });
if (existing) { res.status(409); throw new Error('You\'re already on the waitlist for this.'); }
const last = await prisma.waitlistEntry.aggregate({ where: { eventId }, _max: { position: true } });
const entry = await prisma.waitlistEntry.create({
data: { eventId, eventOptionId, variantId, userId, quantity: qty, position: (last._max.position || 0) + 1 },
data: { eventId, eventOptionId, variantId, sessionId, userId, quantity: qty, position: (last._max.position || 0) + 1 },
include: ENTRY_INCLUDE,
});
const ahead = await prisma.waitlistEntry.count({ where: { eventId, eventOptionId, variantId, status: 'waiting', position: { lt: entry.position } } });
const ahead = await prisma.waitlistEntry.count({ where: { eventId, eventOptionId, variantId, sessionId, status: 'waiting', position: { lt: entry.position } } });
res.status(201).json({ ...entry, ahead, message: `You're on the waitlist${ahead ? ` — ${ahead} ahead of you` : ''}. We'll email or WhatsApp you if a place opens up.` });
} catch (error) {
fail(res, error);
@@ -106,11 +117,15 @@ const claimInfo = async (req, res) => {
status: expired ? 'expired' : 'offered',
eventId: entry.eventId,
eventTitle: entry.event.title,
eventStart: entry.event.startDate,
eventStart: entry.session?.startsAt || entry.event.startDate,
eventOptionId: entry.eventOptionId,
optionName: entry.eventOption.name,
variantId: entry.variantId,
variantName: entry.variant?.name || null,
sessionId: entry.sessionId,
sessionName: entry.session ? sessionLabel(entry.session, entry.event.sessions) : null,
sessionStart: entry.session?.startsAt || null,
lineName: lineName(entry),
quantity: entry.offeredQuantity,
requested: entry.quantity,
expiresAt: entry.offerExpiresAt,
@@ -142,8 +157,11 @@ const eventWaitlist = async (req, res) => {
// @access Private/Supervisor
const offerManually = async (req, res) => {
try {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } });
const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id }, include: { session: true } });
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
if (entry.session && (entry.session.cancelledAt || new Date(entry.session.startsAt) <= new Date())) {
res.status(400); throw new Error(`The session they're waiting for has ${entry.session.cancelledAt ? 'been cancelled' : 'already started'}.`);
}
if (!['waiting', 'expired'].includes(entry.status)) { res.status(400); throw new Error(`This person is ${entry.status === 'offered' ? 'already holding an offer' : `no longer waiting (${entry.status})`}.`); }
const qty = Math.max(1, Math.min(entry.quantity, parseInt(req.body?.quantity, 10) || entry.quantity));
const updated = await makeOffer(entry.id, qty);
+1 -1
View File
@@ -57,7 +57,7 @@ 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 } },
registrationOption: { select: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true, sessionIds: true } },
},
orderBy: { createdAt: 'asc' },
});
+3 -3
View File
@@ -147,8 +147,8 @@ 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);
// A ticket for other sessions (picked, or ticked on an all-sessions ticket) has nothing left today.
const otherSession = !!(session ? !sessionsUtil.lineCovers(t.registrationOption, session.id) : t.registrationOption.sessionId);
const redeemed = schedule?.scheduleType === 'sessions'
? (session && !otherSession ? sessionsUtil.redeemedForSession(t.usages, session.id) : t.quantity || 1)
: t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
@@ -204,7 +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),
wrongSession: !!(session && !sessionsUtil.lineCovers(ticket.registrationOption, session.id)),
remaining: (ticket.quantity || 1) - redeemed,
cancelled: ticket.registrationOption.registration.status === 'cancelled',
inScope: !optionIds || optionIds.has(ticket.registrationOption.eventOptionId),
+2 -2
View File
@@ -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 about a session: whole-course tickets plus picked places on it. Map userId → user. */
/** People to remind about a session: whole-course tickets (all sessions, or this one ticked) 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' } }, OR: [{ sessionId: null }, { sessionId }] } },
where: { eventId, registrationOption: { registration: { status: { not: 'cancelled' } }, OR: [{ sessionId: null, sessionIds: { isEmpty: true } }, { sessionId: null, sessionIds: { has: sessionId } }, { 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 } },
+116 -31
View File
@@ -17,7 +17,12 @@
* 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.
* whole-course main tickets (and places held for waitlist offers); a pick option's stock
* limit is places per session. The waitlist for a pick option is per session.
* `all` options on such an event also let people tick the sessions they'll come to
* (RegistrationOption.sessionIds): one line, one ticket and one price, but the ticket
* scans, counts towards capacity and gets reminders for those sessions only. No
* sessions ticked = every session (the whole-course rules).
* Not available on youth (parental consent) events, and picked lines take no names.
*/
@@ -138,6 +143,11 @@ function registrationClosesAt(event, sessions) {
return deadline && deadline < closes ? deadline : closes;
}
/** Whether people choose sessions on this event (attendance mode "pick sessions"). */
function choosesSessions(event) {
return event?.scheduleType === 'sessions' && event?.attendanceMode === 'pick';
}
/** Whether an option is bought per session (only in "pick sessions" events). */
function isPickOption(event, option) {
return event?.scheduleType === 'sessions' && event?.attendanceMode === 'pick' && option?.sessionMode === 'pick';
@@ -145,68 +155,125 @@ function isPickOption(event, option) {
/**
* 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.
* one line per session (same quantity); an all-sessions option on a pick-sessions event
* keeps its sessionIds (the sessions ticked, empty = every session) on one line. Checks
* the sessions can still be booked: public — before the session starts; staff — until it ends.
* @param {object} event - with eventOptions and sessions
* @param {object[]} lines - [{ eventOptionId, variantId?, quantity, sessionIds? }]
*/
function expandPickLines(event, lines, { now = new Date(), staff = false } = {}) {
const out = [];
const bookable = (option, id) => {
const session = (event.sessions || []).find(s => s.id === id);
if (!session || session.cancelledAt) throw sessionError(`A chosen session for "${option.name}" isn't available.`);
const cutoff = staff ? new Date(session.endsAt) : new Date(session.startsAt);
if (cutoff <= now) throw sessionError(`${sessionLabel(session, event.sessions)} has ${staff ? 'ended' : 'already started'} — it can't be booked any more.`);
};
for (const line of lines || []) {
const option = (event.eventOptions || []).find(o => o.id === line.eventOptionId);
const pick = isPickOption(event, option);
const ids = Array.isArray(line.sessionIds) ? [...new Set(line.sessionIds.map(String))] : [];
const { sessionIds, ...rest } = line;
void sessionIds;
if (!pick) {
if (ids.length) throw sessionError(`"${option?.name || 'This ticket'}" covers every session — no sessions to choose.`);
out.push({ ...line, sessionId: null });
if (ids.length && !choosesSessions(event)) throw sessionError(`"${option?.name || 'This ticket'}" covers every session — no sessions to choose.`);
for (const id of ids) bookable(option, id);
out.push({ ...rest, sessionId: null, sessionIds: ids });
continue;
}
if (event.requiresParentalConsent) throw sessionError('Choosing sessions isn\'t available on youth events.');
if (ids.length === 0) throw sessionError(`Choose the sessions for "${option.name}".`);
for (const id of ids) {
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;
bookable(option, id);
out.push({ ...rest, sessionId: id });
}
}
return out;
}
/**
* The sessions a line attends: its picked session, the sessions ticked on an
* all-sessions ticket, or null for every session.
* @param {{ sessionId?: string|null, sessionIds?: string[] }} line
* @returns {string[]|null}
*/
function lineSessions(line) {
if (line?.sessionId) return [line.sessionId];
return line?.sessionIds?.length ? line.sessionIds : null;
}
/** Whether a ticket's line covers `sessionId` (picked session, ticked sessions, or every session). */
function lineCovers(line, sessionId) {
const ids = lineSessions(line);
return !ids || ids.includes(sessionId);
}
/** Merges the ticked sessions of two all-sessions lines (empty = every session wins). */
function mergeSessionIds(a, b) {
if (!a?.length || !b?.length) return [];
return [...new Set([...a, ...b])];
}
/**
* People booked per session: picked places on that session plus whole-course main
* tickets (which attend every session). Cancelled registrations don't count.
* tickets (which attend every session). Cancelled registrations don't count; places
* held for a live waitlist offer do (except `excludeEntryId`, the offer being claimed).
* @returns {Promise<Map<string, number>>}
*/
async function sessionBookings(eventId, db = prisma) {
const lines = await db.registrationOption.findMany({
where: { registration: { eventId, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } },
select: { quantity: true, sessionId: true },
});
async function sessionBookings(eventId, db = prisma, { excludeEntryId = null } = {}) {
const [booked, held] = await Promise.all([
db.registrationOption.findMany({
where: { registration: { eventId, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } },
select: { quantity: true, sessionId: true, sessionIds: true },
}),
db.waitlistEntry.findMany({
where: {
eventId, status: 'offered', offerExpiresAt: { gt: new Date() }, eventOption: { isMainTicket: true },
...(excludeEntryId ? { id: { not: excludeEntryId } } : {}),
},
select: { offeredQuantity: true, sessionId: true },
}),
]);
const lines = [...booked, ...held.map(h => ({ quantity: h.offeredQuantity, sessionId: h.sessionId }))];
const sessions = await db.eventSession.findMany({ where: { eventId, cancelledAt: null }, select: { id: true } });
const 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));
const out = new Map(sessions.map(s => [s.id, 0]));
for (const l of lines) {
for (const id of lineSessions(l) || out.keys()) if (out.has(id)) out.set(id, out.get(id) + (l.quantity || 0));
}
return out;
}
/**
* Session places free for a main-ticket line: its own session (picked) or, for a
* whole-course ticket, the fullest session with a capacity. Pure.
* @param {object[]} sessions - the event's sessions (with capacity)
* @param {Map<string, number>} booked - from sessionBookings
* @param {string|null} sessionId - the picked session, or null for whole-course
* @returns {number|null} null when no session limits it
*/
function sessionPlacesFree(sessions, booked, sessionId = null) {
const limited = activeSessions(sessions).filter(s => s.capacity > 0 && (!sessionId || s.id === sessionId));
if (limited.length === 0) return null;
return Math.max(0, Math.min(...limited.map(s => s.capacity - (booked.get(s.id) || 0))));
}
/**
* Checks requested lines fit each session's capacity. Whole-course main tickets count
* against every session; picked main tickets against their own.
* @param {{ excludeEntryId?: string }} [opts] - the waitlist offer being claimed
*/
async function assertSessionCapacity(event, lines, db = prisma) {
async function assertSessionCapacity(event, lines, db = prisma, { excludeEntryId = null } = {}) {
const limited = activeSessions(event.sessions).filter(s => s.capacity > 0);
if (limited.length === 0) return;
const mains = new Set((event.eventOptions || []).filter(o => o.isMainTicket).map(o => o.id));
const wanted = new Map();
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));
for (const s of limited) wanted.set(s.id, 0);
for (const l of lines) {
if (!mains.has(l.eventOptionId)) continue;
for (const id of lineSessions(l) || wanted.keys()) if (wanted.has(id)) wanted.set(id, wanted.get(id) + (Number(l.quantity) || 0));
}
if ([...wanted.values()].every(n => n === 0)) return;
const booked = await sessionBookings(event.id, db);
const booked = await sessionBookings(event.id, db, { excludeEntryId });
for (const s of limited) {
const left = s.capacity - (booked.get(s.id) || 0);
if ((wanted.get(s.id) || 0) > left) {
@@ -384,9 +451,17 @@ 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', session = null, sessions = null) {
function ticketDateText(event, style = 'short', session = null, sessions = null, chosenIds = null) {
if (event?.scheduleType !== 'sessions') return null;
const fmt = (d, opts) => new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, ...opts });
// An all-sessions ticket with sessions ticked: those sessions.
const chosen = chosenIds?.length ? activeSessions(event.sessions).filter(s => chosenIds.includes(s.id)) : [];
if (!session && chosen.length === 1) session = chosen[0];
else if (!session && chosen.length > 1) {
return style === 'long'
? `${chosen.map(s => fmt(s.startsAt, { weekday: 'short', day: 'numeric', month: 'short' })).join(', ')} (${chosen.length} sessions — one ticket for all)`
: `${chosen.length} sessions · ${chosen.map(s => fmt(s.startsAt, { day: '2-digit', month: 'short' })).join(', ')}`;
}
// A picked-session ticket is for its own session only.
if (session) {
const label = sessionLabel(session, event.sessions);
@@ -412,9 +487,18 @@ function ticketDateText(event, style = 'short', session = null, sessions = null)
/**
* scanSession for a ticket: a picked-session ticket only checks in on its own session
* (whatever the scanner has selected).
* (whatever the scanner has selected); an all-sessions ticket with sessions ticked
* (`chosenIds`) only on those.
*/
function ticketScanSession(event, sessions, pinnedSessionId, requestedId = null, now = new Date()) {
function ticketScanSession(event, sessions, pinnedSessionId, requestedId = null, now = new Date(), chosenIds = null) {
if (!pinnedSessionId && chosenIds?.length) {
const found = scanSession(event, sessions, requestedId, now);
if (!found.session || chosenIds.includes(found.session.id)) return found;
// Another of their sessions may be open today (a day with more than one session).
const own = requestedId ? null : currentSession(activeSessions(sessions).filter(s => chosenIds.includes(s.id)), now, event.checkInWindow || 'whole_day');
if (own) return { session: own, error: null };
return { session: null, error: `This ticket isn't booked for ${sessionLabel(found.session, sessions)} (${dayKey(found.session.startsAt)})` };
}
if (!pinnedSessionId) return scanSession(event, sessions, requestedId, now);
const pinned = (sessions || []).find(s => s.id === pinnedSessionId);
if (!pinned) return { session: null, error: 'This ticket\'s session no longer exists' };
@@ -430,7 +514,8 @@ function ticketScanSession(event, sessions, pinnedSessionId, requestedId = null,
* 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 }.
* Each ticket needs registrationOption: { registrationId, eventOptionId, variantId, sessionId, sessionIds }.
* An all-sessions ticket with sessions ticked is its own group, limited to those sessions.
* @returns {{ key: string, tickets: object[], sessionIds: string[]|null }[]} sessionIds null = all sessions
*/
function groupTickets(tickets) {
@@ -438,7 +523,7 @@ function groupTickets(tickets) {
for (const t of tickets) {
const ro = t.registrationOption || {};
if (!ro.sessionId) {
groups.set(t.id, { key: t.id, tickets: [t], sessionIds: null });
groups.set(t.id, { key: t.id, tickets: [t], sessionIds: ro.sessionIds?.length ? [...ro.sessionIds] : null });
continue;
}
const key = `pick:${ro.registrationId}:${ro.eventOptionId}:${ro.variantId || ''}`;
@@ -487,7 +572,7 @@ module.exports = {
TIMEZONE, MAX_SESSIONS, REMINDER_OFFSETS, CERTIFICATE_TEMPLATES, sessionError,
dayKey, activeSessions, scheduleBounds, normalizeSessionsInput, sessionAcceptsCheckIn, currentSession,
sessionLabel, scanSession, ticketScanSession, redeemedForSession, ticketDateText, groupTickets,
registrationClosesAt, wholeCourseClosesAt, isPickOption, expandPickLines, sessionBookings, assertSessionCapacity,
registrationClosesAt, wholeCourseClosesAt, choosesSessions, isPickOption, expandPickLines, lineSessions, lineCovers, mergeSessionIds, sessionBookings, sessionPlacesFree, assertSessionCapacity,
lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig,
syncEventSessions, eventSchedule, forgetSchedule, withLatePrice, attendanceBySession,
};
+5 -3
View File
@@ -37,15 +37,17 @@ async function takenCount({ eventOptionId, variantId = null, sessionId = null },
/**
* Places still free on a line: the tighter of the option and variant limits.
* @param {{ sessionId?: string|null, excludeEntryId?: string, db?: object }} [opts] -
* sessionId: a "pick sessions" line (the limits are places per session)
* @returns {Promise<number|null>} null when unlimited
*/
async function remainingFor(option, variant, opts = {}) {
async function remainingFor(option, variant, { sessionId = null, ...opts } = {}) {
const limits = [];
if (option?.stockLimit > 0) {
limits.push(option.stockLimit - await takenCount({ eventOptionId: option.id }, opts));
limits.push(option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, opts));
}
if (variant?.stockLimit > 0) {
limits.push(variant.stockLimit - await takenCount({ eventOptionId: option.id, variantId: variant.id }, opts));
limits.push(variant.stockLimit - await takenCount({ eventOptionId: option.id, variantId: variant.id, sessionId }, opts));
}
if (limits.length === 0) return null;
return Math.max(0, Math.min(...limits));
+63 -14
View File
@@ -11,11 +11,16 @@
* offered what's free; otherwise they're skipped for the next person who fits.
* - Claiming = registering with the link (createRegistration's waitlistToken). Early-bird
* and coupon rules apply as usual. An unclaimed offer expires and moves on.
* - "Pick sessions" options are waited for per session (entry.sessionId): free places are
* that session's, an offer never runs past the session's start, and nobody is offered a
* session that has started or was cancelled. Main tickets also need room in the
* session(s) they cover when sessions have a capacity (utils/sessions.js).
*/
const crypto = require('crypto');
const prisma = require('../config/db');
const { remainingFor } = require('./stock');
const { activeSessions, isPickOption, sessionBookings, sessionPlacesFree, sessionLabel, wholeCourseClosesAt } = require('./sessions');
const ACTIVE = ['waiting', 'offered'];
@@ -27,6 +32,42 @@ function waitlistError(message, statusCode = 400) {
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`waitlist:${token}`).digest('hex');
/**
* Places free on a waitlist line: option/variant stock (per session for a picked line)
* and, for main tickets on events with session capacities, room in the session(s).
* @param {object} event - with sessions
* @returns {Promise<number|null>} null when unlimited
*/
async function placesFree(event, option, variant, sessionId = null, { db = prisma, excludeEntryId = null } = {}) {
const limits = [await remainingFor(option, variant, { sessionId, db, excludeEntryId })];
if (event.scheduleType === 'sessions' && option.isMainTicket && (event.sessions || []).some(s => s.capacity > 0)) {
limits.push(sessionPlacesFree(event.sessions, await sessionBookings(event.id, db, { excludeEntryId }), sessionId));
}
const known = limits.filter(n => n !== null);
return known.length ? Math.min(...known) : null;
}
/** Whether a waitlist line can still be offered at `now` (its session hasn't started, etc.). */
function lineOpen(event, sessionId, now = new Date()) {
if (!sessionId) return event.scheduleType !== 'sessions' || now < wholeCourseClosesAt(event, event.sessions);
const session = activeSessions(event.sessions).find(s => s.id === sessionId);
return !!session && new Date(session.startsAt) > now;
}
/** When an offer made now lapses: the event's offer hours, but never after its session starts. */
function offerExpiry(hours, session = null, now = new Date()) {
const expires = new Date(now.getTime() + (hours || 24) * 60 * 60 * 1000);
return session && new Date(session.startsAt) < expires ? new Date(session.startsAt) : expires;
}
/** "Drop-in (Large)" or "Drop-in — Class 2, Wed 4 Nov" for messages. */
function lineName(entry, sessions = entry.event?.sessions) {
const base = `${entry.eventOption.name}${entry.variant ? ` (${entry.variant.name})` : ''}`;
if (!entry.session) return base;
const when = new Date(entry.session.startsAt).toLocaleDateString('en-GB', { timeZone: process.env.APP_TIMEZONE || 'Africa/Johannesburg', weekday: 'short', day: 'numeric', month: 'short' });
return `${base} — ${sessionLabel(entry.session, sessions || [entry.session])}, ${when}`;
}
function claimLink(token) {
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
return `${base}/waitlist/claim/${token}`;
@@ -63,7 +104,7 @@ async function notifyOffer(entry, token) {
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const user = entry.user;
const link = claimLink(token);
const item = `${entry.eventOption.name}${entry.variant ? ` (${entry.variant.name})` : ''}`;
const item = lineName(entry);
const until = new Date(entry.offerExpiresAt).toLocaleString('en-GB', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
const partialNote = entry.offeredQuantity < entry.quantity
? ` You asked for ${entry.quantity}; ${entry.offeredQuantity} came free, and you can take those now.` : '';
@@ -86,10 +127,10 @@ async function notifyOffer(entry, token) {
await Promise.allSettled(sends);
}
const OFFER_INCLUDE = { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } };
const OFFER_INCLUDE = { event: { include: { sessions: true } }, eventOption: true, variant: true, session: true, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } };
/** Puts an entry on offer (inside the caller's transaction); returns the raw token. */
async function writeOffer(db, entry, quantity, hours) {
/** Puts an entry on offer until `expiresAt` (inside the caller's transaction); returns the raw token. */
async function writeOffer(db, entry, quantity, expiresAt) {
const token = crypto.randomBytes(24).toString('hex');
await db.waitlistEntry.update({
where: { id: entry.id },
@@ -98,7 +139,7 @@ async function writeOffer(db, entry, quantity, hours) {
offeredQuantity: quantity,
offerTokenHash: hashToken(token),
offeredAt: new Date(),
offerExpiresAt: new Date(Date.now() + (hours || 24) * 60 * 60 * 1000),
offerExpiresAt: expiresAt,
},
});
return token;
@@ -113,8 +154,8 @@ async function notifyOffers(offers) {
/** Offers `quantity` places to one entry (e.g. a supervisor's manual offer) and notifies. */
async function makeOffer(entryId, quantity) {
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: { event: true } });
const token = await writeOffer(prisma, entry, quantity, entry.event.waitlistOfferHours);
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: { event: true, session: true } });
const token = await writeOffer(prisma, entry, quantity, offerExpiry(entry.event.waitlistOfferHours, entry.session));
await notifyOffers([{ entryId, token }]);
return prisma.waitlistEntry.findUnique({ where: { id: entryId } });
}
@@ -127,7 +168,7 @@ async function makeOffer(entryId, quantity) {
async function processWaitlist(eventId) {
const event = await prisma.event.findUnique({
where: { id: eventId },
include: { eventOptions: { include: { variants: true } } },
include: { eventOptions: { include: { variants: true } }, sessions: true },
});
if (!event || !event.waitlistEnabled || !event.isActive || event.cashupStatus === 'closed') return 0;
if (new Date(event.endDate) < new Date()) return 0;
@@ -136,16 +177,20 @@ async function processWaitlist(eventId) {
await prisma.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'waitlist:' + eventId}))`;
const waiting = await tx.waitlistEntry.findMany({ where: { eventId, status: 'waiting' }, orderBy: { position: 'asc' } });
const lines = [...new Map(waiting.map(w => [`${w.eventOptionId}::${w.variantId || ''}`, w])).values()];
const lineKey = (w) => `${w.eventOptionId}::${w.variantId || ''}::${w.sessionId || ''}`;
const lines = [...new Map(waiting.map(w => [lineKey(w), w])).values()];
for (const sample of lines) {
const option = event.eventOptions.find(o => o.id === sample.eventOptionId);
const variant = sample.variantId ? (option?.variants || []).find(v => v.id === sample.variantId) : null;
if (!option) continue;
const free = await remainingFor(option, variant, { db: tx });
if (!option || !lineOpen(event, sample.sessionId)) continue;
// An option switched between per-session and whole-course after people joined.
if (!!sample.sessionId !== isPickOption(event, option)) continue;
const free = await placesFree(event, option, variant, sample.sessionId, { db: tx });
if (free === null || free <= 0) continue;
const queue = waiting.filter(w => w.eventOptionId === sample.eventOptionId && (w.variantId || null) === (sample.variantId || null));
const session = sample.sessionId ? event.sessions.find(s => s.id === sample.sessionId) : null;
const queue = waiting.filter(w => lineKey(w) === lineKey(sample));
for (const offer of pickOffers(queue, free, event.waitlistPartialOffers)) {
const token = await writeOffer(tx, { id: offer.entryId }, offer.quantity, event.waitlistOfferHours);
const token = await writeOffer(tx, { id: offer.entryId }, offer.quantity, offerExpiry(event.waitlistOfferHours, session));
made.push({ entryId: offer.entryId, token });
}
}
@@ -183,7 +228,7 @@ async function findOfferByToken(token) {
if (!token || typeof token !== 'string' || token.length > 100) return null;
return prisma.waitlistEntry.findUnique({
where: { offerTokenHash: hashToken(token) },
include: { event: true, eventOption: true, variant: true, user: { select: { id: true, name: true } } },
include: { event: { include: { sessions: true } }, eventOption: true, variant: true, session: true, user: { select: { id: true, name: true } } },
});
}
@@ -192,6 +237,10 @@ module.exports = {
waitlistError,
hashToken,
pickOffers,
placesFree,
lineOpen,
offerExpiry,
lineName,
makeOffer,
processWaitlist,
expireOffers,
+39 -4
View File
@@ -19,6 +19,10 @@ const {
ticketScanSession,
groupTickets,
wholeCourseClosesAt,
choosesSessions,
lineSessions,
mergeSessionIds,
ticketDateText,
} = require('../src/utils/sessions');
// Africa/Johannesburg is UTC+2 all year, so 16:30Z is 18:30 local.
@@ -281,28 +285,59 @@ describe('pick sessions', () => {
eventOptions: [{ id: 'drop', name: 'Drop-in', sessionMode: 'pick' }, { id: 'full', name: 'Full course', sessionMode: 'all' }],
};
test('choosesSessions: pick-mode multi-day events only', () => {
expect(choosesSessions(pickEvent)).toBe(true);
expect(choosesSessions({ ...pickEvent, attendanceMode: 'whole' })).toBe(false);
expect(choosesSessions({ ...pickEvent, scheduleType: 'single' })).toBe(false);
});
test('lineSessions / mergeSessionIds', () => {
expect(lineSessions({ sessionId: 's2', sessionIds: [] })).toEqual(['s2']);
expect(lineSessions({ sessionId: null, sessionIds: ['s1', 's3'] })).toEqual(['s1', 's3']);
expect(lineSessions({ sessionId: null, sessionIds: [] })).toBeNull();
expect(mergeSessionIds(['s1'], ['s3', 's1'])).toEqual(['s1', 's3']);
expect(mergeSessionIds(['s1'], [])).toEqual([]);
});
test('an all-sessions ticket with sessions ticked only scans on those', () => {
const ev = { checkInWindow: 'whole_day' };
const onDay2 = new Date(wed(11).getTime() + 30 * 60000);
expect(ticketScanSession(ev, COURSE, null, null, onDay2, ['s2', 's4']).session.id).toBe('s2');
expect(ticketScanSession(ev, COURSE, null, null, onDay2, ['s1', 's4']).error).toMatch(/isn't booked for Session 2/);
expect(ticketScanSession(ev, COURSE, null, null, onDay2, []).session.id).toBe('s2');
});
test('ticket dates list the ticked sessions', () => {
const ev = { ...pickEvent, sessions: COURSE };
expect(ticketDateText(ev, 'short', null, null, ['s2', 's4'])).toBe('2 sessions · 11 Nov, 25 Nov');
expect(ticketDateText(ev, 'long', null, null, ['s2'])).toBe('Wednesday, 11 November 2026 (Session 2)');
});
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', () => {
test('a pick line becomes one line per session; an all-sessions line keeps its ticked sessions', () => {
const out = expandPickLines(pickEvent, [
{ eventOptionId: 'drop', quantity: 2, sessionIds: ['s3', 's4', 's3'] },
{ eventOptionId: 'full', quantity: 1 },
{ eventOptionId: 'full', quantity: 1, sessionIds: ['s2', 's5'] },
], { now: wed(5) });
expect(out).toEqual([
{ eventOptionId: 'drop', quantity: 2, sessionId: 's3' },
{ eventOptionId: 'drop', quantity: 2, sessionId: 's4' },
{ eventOptionId: 'full', quantity: 1, sessionId: null },
{ eventOptionId: 'full', quantity: 1, sessionId: null, sessionIds: [] },
{ eventOptionId: 'full', quantity: 1, sessionId: null, sessionIds: ['s2', 's5'] },
]);
});
test('sessions must be chosen, upcoming, and not on whole-course tickets', () => {
test('sessions must be chosen, upcoming, and only on events where people choose them', () => {
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, [{ eventOptionId: 'full', quantity: 1, sessionIds: ['s1'] }], { now: wed(5) })).toThrow(/already started/);
expect(() => expandPickLines({ ...pickEvent, attendanceMode: 'whole' }, [{ 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/);
});
+60
View File
@@ -29,3 +29,63 @@ describe('pickOffers', () => {
expect(pickOffers(q(3, 4), 2, false)).toEqual([]);
});
});
const { lineOpen, offerExpiry, lineName } = require('../src/utils/waitlist');
const { sessionPlacesFree } = require('../src/utils/sessions');
const at = (h) => new Date(Date.UTC(2026, 10, 4, h));
const sessions = [
{ id: 's1', startsAt: at(8), endsAt: at(9), capacity: 10, cancelledAt: null },
{ id: 's2', startsAt: at(10), endsAt: at(11), capacity: 4, cancelledAt: null },
{ id: 's3', startsAt: at(12), endsAt: at(13), capacity: null, cancelledAt: null },
{ id: 's4', startsAt: at(14), endsAt: at(15), capacity: 1, cancelledAt: at(1) },
];
describe('sessionPlacesFree', () => {
const booked = new Map([['s1', 7], ['s2', 4], ['s3', 50]]);
test('a picked session: its own capacity', () => {
expect(sessionPlacesFree(sessions, booked, 's1')).toBe(3);
expect(sessionPlacesFree(sessions, booked, 's2')).toBe(0);
});
test('a session without a capacity is unlimited', () => {
expect(sessionPlacesFree(sessions, booked, 's3')).toBeNull();
});
test('whole-course: the fullest limited session, cancelled ones ignored', () => {
expect(sessionPlacesFree(sessions, booked, null)).toBe(0);
expect(sessionPlacesFree(sessions, new Map([['s1', 2], ['s2', 1]]), null)).toBe(3);
});
});
describe('lineOpen', () => {
const event = { scheduleType: 'sessions', attendanceMode: 'pick', endDate: at(13), sessions };
test('a session can be offered until it starts', () => {
expect(lineOpen(event, 's2', at(9))).toBe(true);
expect(lineOpen(event, 's2', at(10))).toBe(false);
});
test('cancelled or unknown sessions are not offered', () => {
expect(lineOpen(event, 's4', at(1))).toBe(false);
expect(lineOpen(event, 'nope', at(1))).toBe(false);
});
test('whole-course lines close when the course does', () => {
expect(lineOpen(event, null, at(7))).toBe(true);
expect(lineOpen(event, null, at(8))).toBe(false);
expect(lineOpen({ scheduleType: 'single' }, null, at(20))).toBe(true);
});
});
describe('offerExpiry', () => {
test('the offer hours, but never after the session starts', () => {
expect(offerExpiry(24, null, at(0))).toEqual(new Date(at(0).getTime() + 24 * 3600 * 1000));
expect(offerExpiry(24, sessions[1], at(0))).toEqual(at(10));
expect(offerExpiry(2, sessions[1], at(0))).toEqual(at(2));
});
});
describe('lineName', () => {
test('names the session for picked lines', () => {
expect(lineName({ eventOption: { name: 'Drop-in' }, variant: null, session: null })).toBe('Drop-in');
expect(lineName({ eventOption: { name: 'Drop-in' }, variant: { name: 'Adult' }, session: { ...sessions[1], label: 'Class 2' } }, sessions))
.toBe('Drop-in (Adult) — Class 2, Wed 4 Nov');
expect(lineName({ eventOption: { name: 'Drop-in' }, session: sessions[1] }, sessions)).toBe('Drop-in — Session 2, Wed 4 Nov');
});
});
+5 -4
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-23, after **1.35.0**. The plan itself is in
Last updated: 2026-10-23, after **1.35.1**. The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -12,6 +12,7 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, `scripts/deploy.sh` |
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
| 1.35.1 | Waitlist per session, session picker at the door, "All sessions" tickets let people tick sessions at one price; fixes (per-session stock limits, offline service worker serving stale scripts) |
## Next up
@@ -83,8 +84,8 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
## Known gaps / follow-ups
- The at-the-door booking pop-up doesn't have the session picker; per-session tickets
are booked from Manual registration (the page says so).
- The waitlist works per ticket option, not per session.
- At the door, per-session tickets are booked on new registrations only; editing an
existing registration leaves its booked sessions as they are (the backend refuses
changes to picked lines). "Add & charge" for a session at the scanner isn't built.
- Ticket scanning by camera isn't covered by the e2e tests (headless browsers have no
camera); scans are tested through the API.
+2 -1
View File
@@ -15,7 +15,8 @@ so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events
phase A (sessions, late joining, per-session check-in, attendance register,
reminders, certificates); 1.34.0 job queue (pg-boss), Playwright E2E tests, Gitea CI
workflow (waiting for a runner) and deploy script; 1.35.0 multi-day events phase B
(pick sessions, per-session capacity). **Next: 1.36.0 payment provider interface (Yoco
(pick sessions, per-session capacity), with 1.35.1 adding the per-session waitlist, the session
picker at the door and session choice on "All sessions" tickets. **Next: 1.36.0 payment provider interface (Yoco
moved behind it), then PayFast, SnapScan, Zapper.**
**Repositories:**
+1 -1
View File
@@ -5,7 +5,7 @@ import path from "path";
export type Role = "admin" | "supervisor" | "staff" | "user";
export type Seed = {
users: Record<Role, { id: string; name: string; email: string; token: string }>;
events: { free: string; paid: string; course: string; dropIn: string };
events: { free: string; paid: string; course: string; dropIn: string; conference: string };
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
};
+69 -1
View File
@@ -1,6 +1,7 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
type Registration = { id: string; eventId: string; registrationOptions: { sessionId?: string | null; priceSnapshot: number }[] };
type Registration = { id: string; eventId: string; user?: { name: string }; registrationOptions: { sessionId?: string | null; sessionIds?: string[]; priceSnapshot: number; quantity: number }[] };
type WaitlistEntry = { eventId: string; sessionId: string | null; quantity: number };
test.describe("pick sessions", () => {
test("book two classes of a drop-in event; a full class can't be ticked", async ({ page }) => {
@@ -23,4 +24,71 @@ test.describe("pick sessions", () => {
const reg = regs.find((r) => r.eventId === seed().events.dropIn);
expect(reg?.registrationOptions.filter((o) => o.sessionId)).toHaveLength(2);
});
test("an all-sessions pass: tick the days, pay one price", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.conference}`);
await expect(page.getByText("R200.00", { exact: true })).toBeVisible();
await expect(page.getByText("per session")).toHaveCount(0);
const picker = page.getByRole("group", { name: "Choose sessions" });
await picker.getByRole("checkbox").nth(0).check();
await picker.getByRole("checkbox").nth(2).check();
await expect(page.getByText("Total: R200.00")).toBeVisible();
await page.getByRole("button", { name: "Register", exact: true }).click();
await page.waitForURL(/\/registration\/success/);
const regs = await api<Registration[]>(page, "user", "GET", "/api/registrations/myregistrations");
const line = regs.find((r) => r.eventId === seed().events.conference)?.registrationOptions[0];
expect(line?.sessionId ?? null).toBeNull();
expect(line?.sessionIds).toHaveLength(2);
expect(line?.priceSnapshot).toBe(200);
expect(line?.quantity).toBe(1);
});
test("join the waitlist for a full class", async ({ page }) => {
await loginAs(page, "admin");
await page.goto(`/register/${seed().events.dropIn}`);
await page.getByRole("button", { name: "+" }).first().click();
const picker = page.getByRole("group", { name: "Choose sessions" });
await picker.getByRole("button", { name: "Join waitlist" }).click();
await expect(page.getByText(/Drop-in — Class 1/)).toBeVisible();
await page.locator("form").filter({ hasText: "Join the waitlist" }).getByRole("button", { name: "Join waitlist" }).click();
await expect(page.getByText(/You're on the waitlist/)).toBeVisible();
const mine = await api<WaitlistEntry[]>(page, "admin", "GET", "/api/waitlist/mine");
const entry = mine.find((e) => e.eventId === seed().events.dropIn);
expect(entry?.sessionId).toBeTruthy();
expect(entry?.quantity).toBe(1);
});
test("at the door: book a class for a walk-in", async ({ page }) => {
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/at-the-door");
const value = await page.locator("option", { hasText: "E2E Drop-in Class" }).getAttribute("value");
// The page picks a default event once its list loads; choose ours after that.
await page.waitForLoadState("networkidle");
await page.locator("select").first().selectOption(value!);
await page.waitForLoadState("networkidle");
await expect(page.locator("select").first()).toHaveValue(value!);
await page.getByPlaceholder("Name, email or phone…").fill("Door Walk In");
await page.getByText('New attendee "Door Walk In"…').click();
await page.getByPlaceholder("Email address").fill("door-walkin@e2e.test");
await page.getByRole("button", { name: "Next — Choose Items" }).click();
await expect(page.getByText("R 50.00 per session")).toBeVisible();
await page.getByRole("button", { name: "+" }).first().click();
const picker = page.getByRole("group", { name: "Choose sessions" });
await picker.getByRole("checkbox").nth(1).check();
await expect(page.getByText("Total: R 50.00")).toBeVisible();
await page.getByRole("button", { name: "Confirm Registration" }).click();
await expect(page.getByText("Select Items")).toBeHidden();
const regs = await api<Registration[]>(page, "supervisor", "GET", `/api/registrations/event/${seed().events.dropIn}`);
const reg = regs.find((r) => r.user?.name === "Door Walk In");
expect(reg?.registrationOptions.filter((o) => o.sessionId)).toHaveLength(1);
});
});
+17 -2
View File
@@ -110,12 +110,27 @@ async function seed(url) {
data: {
title: 'E2E Drop-in Class', description: 'Come to the classes you like.', startDate: classes[0].startsAt, endDate: classes[2].endsAt,
price: 50, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
scheduleType: 'sessions', attendanceMode: 'pick',
scheduleType: 'sessions', attendanceMode: 'pick', waitlistEnabled: true,
sessions: { create: classes },
eventOptions: { create: [{ name: 'Drop-in', price: 50, isMainTicket: true, sessionMode: 'pick' }] },
},
include: { sessions: { orderBy: { startsAt: 'asc' } }, eventOptions: true },
});
// A conference sold per pass: people tick the days they'll come, one price for the pass.
const days = [2, 3, 4].map((d, i) => {
const startsAt = at(d, 7, 0);
return { label: `Day ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 8 * 60 * 60 * 1000), order: i };
});
const conference = await prisma.event.create({
data: {
title: 'E2E Conference', description: 'Three days; come to the days you can.', startDate: days[0].startsAt, endDate: days[2].endsAt,
price: 200, location: '1 Test Street, Pretoria', createdById: users.supervisor.id,
scheduleType: 'sessions', attendanceMode: 'pick',
sessions: { create: days },
eventOptions: { create: [{ name: 'Conference pass', price: 200, isMainTicket: true, sessionMode: 'all' }] },
},
});
// Someone else already has the only place in class 1.
await prisma.registration.create({
data: {
@@ -138,7 +153,7 @@ async function seed(url) {
const out = {
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id },
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id },
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
};
fs.mkdirSync(path.join(root, 'e2e', '.data'), { recursive: true });
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events-frontend",
"version": "1.35.0",
"version": "1.35.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.35.0",
"version": "1.35.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events-frontend",
"version": "1.35.0",
"version": "1.35.1",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
+17 -9
View File
@@ -1,14 +1,18 @@
/*
* Service worker for offline ticket scanning.
*
* Only the scanner page and the static files it needs are cached:
* - /_next/static/* (hashed, never change): cache-first.
* - The scanner page itself: network-first, falling back to the cached copy offline.
* Only the scanner page and the static files it needs are cached, both network-first
* with the cached copy as the offline fallback:
* - /_next/static/*. Not cache-first: under `next dev` these files keep their names when
* their content changes, so cache-first served stale code on every page, for good.
* (Production files are hashed and come from the browser's HTTP cache anyway.)
* - The scanner page itself.
* - The page can ask (postMessage { type: 'cache-urls', urls }) for the files it has
* loaded to be cached up front, so it works offline straight after "Enable offline mode".
* API calls are never cached — offline scans go through IndexedDB instead.
*/
const CACHE = 'hope-scanner-v1';
// v2: drops v1 caches, which could hold stale development scripts (see above).
const CACHE = 'hope-scanner-v2';
const SCANNER_PATH = '/dashboard/staff/ticket-scanning';
self.addEventListener('install', () => self.skipWaiting());
@@ -41,11 +45,15 @@ self.addEventListener('fetch', (event) => {
if (url.pathname.startsWith('/_next/static/')) {
event.respondWith((async () => {
const cache = await caches.open(CACHE);
const hit = await cache.match(req);
if (hit) return hit;
const res = await fetch(req);
if (res.ok) cache.put(req, res.clone());
return res;
try {
const res = await fetch(req);
if (res.ok) cache.put(req, res.clone());
return res;
} catch {
const hit = await cache.match(req);
if (hit) return hit;
throw new Error('offline and not cached');
}
})());
return;
}
@@ -1,6 +1,5 @@
"use client";
import Link from "next/link";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation";
@@ -16,7 +15,8 @@ import { DoorOpen, UserPlus, CreditCard, CheckSquare, Ticket, RotateCcw, type Lu
import { errorMessage } from "@/lib/utils";
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
import { printTicketCards, ticketTypeLabel as ticketLabel } from "@/lib/ticketPrint";
import { isPickOption, todaysSession } from "@/lib/sessions";
import { bookableSessions, choosesSessions, isPickOption, todaysSession } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import type { Event, EventOption, PublicBundle, Registration, Ticket as TicketRecord, UserSummary } from "@/types";
type NewAttendee = { name: string; email: string | null; phone: string | null; notifPref: "email" | "whatsapp" | "both" };
@@ -98,6 +98,10 @@ export default function AtTheDoorPage() {
const [eventOptions, setEventOptions] = useState<EventOption[]>([]);
const [doorHasPickOptions, setDoorHasPickOptions] = useState(false);
// "Pick sessions" events: the event (for its sessions) and the sessions chosen per
// quantity key. Picked sessions are booked on new registrations only.
const [doorEvent, setDoorEvent] = useState<Event | null>(null);
const [picks, setPicks] = useState<Record<string, string[]>>({});
const [showOptionsModal, setShowOptionsModal] = useState(false);
const [pendingUser, setPendingUser] = useState<NewAttendee | null>(null);
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
@@ -160,16 +164,16 @@ export default function AtTheDoorPage() {
authToken: token
});
// Per-session tickets ("pick sessions") are booked from Manual registration,
// which has the session picker.
const all = ev.options || ev.eventOptions || [];
setEventOptions(all.filter(o => !isPickOption(ev, o)));
setDoorHasPickOptions(all.some(o => isPickOption(ev, o)));
setDoorEvent(ev);
setEventOptions(all);
setDoorHasPickOptions(choosesSessions(ev));
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
setDoorCollectGender(!!ev.collectGender);
setDoorBundles(ev.bundles || []);
} catch {
setDoorEvent(null);
setEventOptions([]);
setDoorBundles([]);
}
@@ -213,6 +217,10 @@ export default function AtTheDoorPage() {
}, [activeRegistration]);
// Charged per session / has sessions to tick (every ticket on a "pick sessions" event).
const isPick = (optionId: string) => isPickOption(doorEvent, eventOptions.find(o => o.id === optionId));
const choosing = choosesSessions(doorEvent);
const confirmRegistration = async () => {
if (confirming) return;
@@ -220,13 +228,18 @@ export default function AtTheDoorPage() {
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(choosing && !pendingEditReg ? { sessionIds: picks[key] || [] } : {}) };
});
if (!opts.length) {
setError("Select at least one item");
return;
}
const noSessions = opts.find(o => "sessionIds" in o && o.sessionIds && o.sessionIds.length === 0);
if (noSessions) {
setError(`Choose the sessions for "${eventOptions.find(o => o.id === noSessions.eventOptionId)?.name || "the ticket"}".`);
return;
}
for (const [key, minQty] of Object.entries(minQuantities)) {
if (minQty > 0 && (quantities[key] || 0) < minQty) {
@@ -244,6 +257,8 @@ export default function AtTheDoorPage() {
if (missing) { setError(missing); return; }
}
// Picked-session lines take no names.
const nameOptions = eventOptions.filter(o => !isPick(o.id));
setConfirming(true);
try {
if (pendingEditReg) {
@@ -286,8 +301,8 @@ export default function AtTheDoorPage() {
options: opts,
notificationPreference: pendingUser.notifPref,
...(doorCoupon ? { couponCode: doorCoupon.code } : {}),
...(doorAllowsNames && (doorNaming || doorYouthStart) && attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries).length > 0
? { attendees: attendeesPayload(eventOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries), ticketDelivery: doorDelivery }
...(doorAllowsNames && (doorNaming || doorYouthStart) && attendeesPayload(nameOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries).length > 0
? { attendees: attendeesPayload(nameOptions, (o, v) => quantities[v ? `${o}::${v}` : o] || 0, doorEntries), ticketDelivery: doorDelivery }
: {}),
}
});
@@ -311,16 +326,20 @@ export default function AtTheDoorPage() {
// Called when NewAttendeeModal is confirmed — proceed to options selection
const handleNewAttendeeConfirm = ({ name, email, phone, notifPref }: { name: string; email: string; phone: string; notifPref: "email" | "whatsapp" | "both" }) => {
const qtyMap = initQuantities(eventOptions);
// Default main ticket to 1 (first variant if variants exist)
const pickMap: Record<string, string[]> = {};
// Default main ticket to 1 (first variant if variants exist). On a "pick sessions"
// event with a session today that still has room, that's one place in today's session.
const today = todaysSession(doorEvent);
const todayPick = today && doorEvent && choosing ? eventOptions.find(o => o.isMainTicket
&& bookableSessions(doorEvent, o, true).some(s => s.id === today.id && (s.left === null || s.left > 0))) : undefined;
eventOptions.forEach(o => {
if (!o.isMainTicket) return;
if ((o.variants || []).length > 0) {
qtyMap[`${o.id}::${(o.variants || [])[0].id}`] = 1;
} else {
qtyMap[o.id] = 1;
}
if (!o.isMainTicket || (todayPick ? o !== todayPick : choosing)) return;
const key = (o.variants || []).length > 0 ? `${o.id}::${(o.variants || [])[0].id}` : o.id;
qtyMap[key] = 1;
if (o === todayPick && today) pickMap[key] = [today.id];
});
setQuantities(qtyMap);
setPicks(pickMap);
setMinQuantities({});
setPendingUser({ name, email: email || null, phone: phone || null, notifPref });
setPendingEditReg(null);
@@ -345,8 +364,8 @@ export default function AtTheDoorPage() {
return;
}
const qtyMap = initQuantities(eventOptions);
const regOptions = freshReg.registrationOptions || [];
const qtyMap = initQuantities(eventOptions.filter(o => !isPick(o.id)));
const regOptions = (freshReg.registrationOptions || []).filter(ro => !ro.sessionId);
regOptions.forEach((ro) => {
const key = ro.variantId ? `${ro.eventOptionId}::${ro.variantId}` : ro.eventOptionId;
if (key in qtyMap) qtyMap[key] = ro.quantity || 0;
@@ -361,6 +380,7 @@ export default function AtTheDoorPage() {
});
setQuantities(qtyMap);
setPicks({});
setMinQuantities(minQtyMap);
setPendingEditReg(freshReg);
setPendingUser(null);
@@ -499,15 +519,11 @@ export default function AtTheDoorPage() {
{mode === "refund" && (
<DoorRefundPanel token={token} eventId={eventId} setInfo={setInfo} />
)}
{doorHasPickOptions && (
<div className="mt-4 p-3 border rounded bg-amber-50 border-amber-200 text-amber-900 text-sm">
This event has per-session tickets. Book those from <Link className="underline font-medium" href="/dashboard/supervisor/manual">Manual registration</Link>, which lets you choose the sessions.
</div>
)}
<OptionsModal
open={showOptionsModal}
onClose={() => { setShowOptionsModal(false); setPendingEditReg(null); }}
options={eventOptions}
options={pendingEditReg ? eventOptions.filter(o => !isPick(o.id)) : eventOptions}
pick={doorEvent && doorHasPickOptions ? { event: doorEvent, picks, setPicks } : null}
quantities={quantities}
setQuantities={setQuantities}
minQuantities={minQuantities}
@@ -1194,15 +1210,30 @@ type OptionsModalProps = {
bundles: PublicBundle[];
// Named-attendee editor settings, or null when the event doesn't take names.
names: Omit<React.ComponentProps<typeof AttendeeNamesEditor>, "options" | "qtyFor" | "payerLabel"> | null;
// A "pick sessions" event: its sessions and the sessions chosen per quantity key.
pick: { event: Event; picks: Record<string, string[]>; setPicks: React.Dispatch<React.SetStateAction<Record<string, string[]>>> } | null;
};
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, bundles, names }: OptionsModalProps) {
function OptionsModal({ open, onClose, options, quantities, setQuantities, minQuantities = {}, onConfirm, confirming, isEdit, totalPaid = 0, eventId, token, coupon, setCoupon, couponForUserId, existingCode, bundles, names, pick }: OptionsModalProps) {
const pickOpt = (optionId: string) => !!pick && isPickOption(pick.event, (options || []).find(o => o.id === optionId));
// Sessions are ticked on new bookings only (edits leave booked sessions as they are).
const choiceOpt = (optionId: string) => !!pick && !isEdit && (options || []).some(o => o.id === optionId);
// A picked option is priced per session.
const times = (key: string) => (pick && pickOpt(key.split("::")[0]) ? (pick.picks[key] || []).length : 1);
const basket = Object.entries(quantities)
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
return { eventOptionId, quantity, variantId: variantId || null, ...(pick && choiceOpt(eventOptionId) ? { sessionIds: pick.picks[key] || [] } : {}) };
});
// The sessions show straight away; ticking one when the quantity is 0 makes it 1.
const picker = (key: string, option: EventOption) => {
if (!pick || !choiceOpt(option.id)) return null;
const qty = quantities[key] || 0;
return <SessionPicker event={pick.event} option={option} staff quantity={Math.max(1, qty)} selected={qty > 0 ? pick.picks[key] || [] : []}
onChange={ids => { pick.setPicks(p => ({ ...p, [key]: ids })); if (qty === 0 && ids.length > 0) setQuantities(q => ({ ...q, [key]: 1 })); }} />;
};
const perSession = (o: EventOption) => (pickOpt(o.id) ? " per session" : "");
// Bundle deals apply to the whole registration, so this holds for edits too.
const bundleQuote = useBundleQuote({ eventId, basket, token, forUserId: couponForUserId, enabled: open });
@@ -1213,9 +1244,9 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
const total = safeOptions.reduce((sum, o) => {
if ((o.variants || []).length > 0) {
return sum + (o.variants || []).reduce((vs, v) =>
vs + (quantities[`${o.id}::${v.id}`] || 0) * variantUnitNow(o, v), 0);
vs + (quantities[`${o.id}::${v.id}`] || 0) * times(`${o.id}::${v.id}`) * variantUnitNow(o, v), 0);
}
return sum + (quantities[o.id] || 0) * optionUnitNow(o);
return sum + (quantities[o.id] || 0) * times(o.id) * optionUnitNow(o);
}, 0);
const afterBundles = bundleQuote ? bundleQuote.total : total;
@@ -1236,6 +1267,11 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
<div className="text-xl font-semibold">
{isEdit ? "Edit Registration" : "Select Items"}
</div>
{isEdit && pick && (
<div className="text-sm text-gray-500 mt-1">
Sessions already booked stay as they are. To add sessions, book them as a new registration.
</div>
)}
{isEdit && totalPaid > 0 && (
<div className="text-sm text-gray-500 mt-1">
Already paid: <strong>R {Number(totalPaid).toFixed(2)}</strong> — new total must be at least this amount.
@@ -1267,11 +1303,12 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
const key = `${opt.id}::${v.id}`;
const minQty = minQuantities[key] || 0;
return (
<div key={v.id} className="flex items-center justify-between px-4 py-2.5 border-b last:border-b-0">
<div key={v.id} className="px-4 py-2.5 border-b last:border-b-0">
<div className="flex items-center justify-between">
<div>
<div className="text-sm">{v.name}</div>
<div className="text-xs text-gray-500">
R {unit.toFixed(2)}
R {unit.toFixed(2)}{perSession(opt)}
{unit < basePrice && basePrice > 0 && <span className="ml-1 text-green-600">(early bird)</span>}
</div>
{minQty > 0 && <div className="text-xs text-gray-400">{minQty} already issued</div>}
@@ -1282,6 +1319,8 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
<button onClick={() => stepper(key, 1)} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
</div>
</div>
{picker(key, opt)}
</div>
);
})}
</div>
@@ -1293,7 +1332,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
<div key={opt.id} className="border rounded-xl p-3">
<div className="font-medium">{opt.name}</div>
<div className="text-xs text-gray-500">
R {unit.toFixed(2)}
R {unit.toFixed(2)}{perSession(opt)}
{unit < opt.price && opt.price > 0 && <span className="ml-1 text-green-600">(early bird, was R {opt.price.toFixed(2)})</span>}
{opt.isMainTicket ? " • Main" : ""}
</div>
@@ -1303,6 +1342,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
<div className="w-6 text-center text-lg font-semibold">{quantities[opt.id] || 0}</div>
<button onClick={() => stepper(opt.id, 1)} className="w-8 h-8 border rounded flex items-center justify-center text-lg font-bold bg-emerald-50 border-emerald-300 text-emerald-700">+</button>
</div>
{picker(opt.id, opt)}
</div>
);
})}
@@ -1323,7 +1363,7 @@ function OptionsModal({ open, onClose, options, quantities, setQuantities, minQu
{names && (
<div className="mb-3 max-h-64 overflow-y-auto">
<AttendeeNamesEditor
options={safeOptions}
options={safeOptions.filter(o => !pickOpt(o.id))}
qtyFor={(o: string, v: string | null) => quantities[v ? `${o}::${v}` : o] || 0}
{...names}
payerLabel="the person registering"
@@ -12,7 +12,7 @@ import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload,
import { UserPlus, CreditCard } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { optionUnitNow, variantUnitNow } from "@/lib/pricing";
import { isPickOption } from "@/lib/sessions";
import { choosesSessions, isPickOption } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
@@ -289,11 +289,19 @@ export default function ManualRegistrationPage() {
const o = (fullEvent?.eventOptions || []).find(x => x.id === optionId);
return fullEvent && o && isPickOption(fullEvent, o) ? o : null;
};
// On a "pick sessions" event every ticket has sessions to tick; only pickOpt ones are charged per session.
const choiceOpt = (optionId: string) => {
const o = (fullEvent?.eventOptions || []).find(x => x.id === optionId);
return fullEvent && o && choosesSessions(fullEvent) ? o : null;
};
const times = (key: string) => (pickOpt(key.split("::")[0]) ? (picks[key] || []).length : 1);
const picker = (key: string, optionId: string) => {
const o = pickOpt(optionId);
if (!o || !fullEvent || !(quantities[key] > 0)) return null;
return <SessionPicker event={fullEvent} option={o} staff quantity={quantities[key]} selected={picks[key] || []} onChange={ids => setPicks(p => ({ ...p, [key]: ids }))} />;
const o = choiceOpt(optionId);
if (!o || !fullEvent) return null;
// The sessions show straight away; ticking one when the quantity is 0 makes it 1.
const qty = quantities[key] || 0;
return <SessionPicker event={fullEvent} option={o} staff quantity={Math.max(1, qty)} selected={qty > 0 ? picks[key] || [] : []}
onChange={ids => { setPicks(p => ({ ...p, [key]: ids })); if (qty === 0 && ids.length > 0) setQuantities(q => ({ ...q, [key]: 1 })); }} />;
};
const totalDue = useMemo(() => {
@@ -307,7 +315,7 @@ export default function ManualRegistrationPage() {
const basket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null, ...(pickOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
return { eventOptionId, quantity, variantId: variantId || null, ...(choiceOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
}), [quantities, picks, fullEvent]); // eslint-disable-line react-hooks/exhaustive-deps
const bundleQuote = useBundleQuote({ eventId: selectedEventId, basket, token, forUserId: pickedUserId });
const payable = coupon ? coupon.total : bundleQuote ? bundleQuote.total : totalDue;
@@ -322,7 +330,7 @@ export default function ManualRegistrationPage() {
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(pickOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(choiceOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
});
if (opts.length === 0) { setError("Please select at least one ticket option."); return; }
const noSessions = opts.find(o => "sessionIds" in o && o.sessionIds && o.sessionIds.length === 0);
@@ -9,6 +9,7 @@ import { useDismissingState } from "@/hooks/useDismissingState";
import { ListChecks, ArrowUp, ArrowDown } from "lucide-react";
import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from "@/components/ui/table";
import { errorMessage } from "@/lib/utils";
import { entryLineName } from "@/lib/waitlist";
import type { Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
@@ -22,6 +23,7 @@ type Entry = {
createdAt: string;
eventOption: { name: string };
variant: { name: string } | null;
session: { label: string | null; startsAt: string } | null;
user: { name: string; email: string; phoneNumber: string | null };
};
@@ -153,7 +155,7 @@ export default function WaitlistPage() {
<div className="text-sm font-medium">{e.user.name}</div>
<div className="text-xs text-gray-500">{[e.user.email, e.user.phoneNumber].filter(Boolean).join(" · ")}</div>
</TableCell>
<TableCell className="text-sm">{e.eventOption.name}{e.variant ? ` (${e.variant.name})` : ""}</TableCell>
<TableCell className="text-sm">{entryLineName(e)}</TableCell>
<TableCell className="text-sm">{e.quantity}{e.status === "offered" && e.offeredQuantity !== e.quantity ? ` (${e.offeredQuantity} offered)` : ""}</TableCell>
<TableCell className="text-xs text-gray-600">{formatDate(new Date(e.createdAt))}</TableCell>
<TableCell>
@@ -6,7 +6,7 @@ import { apiFetch } from "@/lib/api";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
import { isPickOption, latePrice, registrationClosesAt } from "@/lib/sessions";
import { choosesSessions, isPickOption, latePrice, registrationClosesAt } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
@@ -80,7 +80,7 @@ function withLatePrices(event: RegisterEvent): RegisterEvent {
};
}
type WaitlistOffer = { eventOptionId: string; variantId: string | null; quantity: number; expiresAt: string; optionName: string; variantName: string | null };
type WaitlistOffer = { eventOptionId: string; variantId: string | null; sessionId?: string | null; lineName?: string; quantity: number; expiresAt: string; optionName: string; variantName: string | null };
// Key format: "optionId" for no-variant options, "optionId:variantId" for variant options
type QtyMap = Record<string, number>;
@@ -161,8 +161,9 @@ function QtyControl({
);
}
// A "pick sessions" option: the sessions chosen per quantity key, and the event's schedule.
type PickProps = { event: RegisterEvent; picks: Record<string, string[]>; onPick: (key: string, ids: string[]) => void };
// A "pick sessions" event: the sessions chosen per quantity key, and the event's schedule.
// perSession: this ticket is charged per session (otherwise one price for the sessions ticked).
type PickProps = { event: RegisterEvent; picks: Record<string, string[]>; onPick: (key: string, ids: string[]) => void; perSession: boolean };
function OptionCard({
opt,
@@ -178,9 +179,12 @@ function OptionCard({
pick?: PickProps | null;
}) {
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
const perSession = pick ? " per session" : "";
const picker = (key: string, qty: number, unit: number) => (pick && qty > 0 ? (
<SessionPicker event={pick.event} option={opt} selected={pick.picks[key] || []} onChange={(ids) => pick.onPick(key, ids)} quantity={qty} unitPrice={unit} />
const perSession = pick?.perSession ? " per session" : "";
// The sessions show straight away; ticking one when the quantity is 0 makes it 1.
const picker = (key: string, qty: number, unit: number, variant?: { id: string; name: string }) => (pick ? (
<SessionPicker event={pick.event} option={opt} selected={qty > 0 ? pick.picks[key] || [] : []} quantity={Math.max(1, qty)} unitPrice={pick.perSession ? unit : undefined}
onChange={(ids) => { pick.onPick(key, ids); if (qty === 0 && ids.length > 0) onQtyChange(key, 1); }}
waitlist={waitlist ? { ...waitlist, variantId: variant?.id ?? null, label: variant ? `${opt.name} (${variant.name})` : opt.name } : null} />
) : null);
const isSoldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
@@ -246,7 +250,7 @@ function OptionCard({
max={maxQty}
/>
</div>
{picker(key, qty, unitPrice)}
{picker(key, qty, unitPrice, variant)}
</div>
);
})}
@@ -397,6 +401,10 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
...priced,
eventOptions: (priced.eventOptions || []).map(o => {
if (o.id !== offer.eventOptionId) return o;
if (offer.sessionId) {
const left = o.sessionAvailability?.[offer.sessionId];
return { ...o, ...(left !== undefined ? { sessionAvailability: { ...o.sessionAvailability, [offer.sessionId]: left + offer.quantity } } : {}) };
}
return {
...o,
availableCount: o.availableCount !== undefined ? o.availableCount + offer.quantity : o.availableCount,
@@ -452,11 +460,17 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
setQuantities((q) => ({ ...q, [key]: val }));
};
// "Pick sessions" options: the sessions chosen for each quantity key. Whole-course
// tickets on such an event stop selling when the course starts.
// "Pick sessions" events: the sessions chosen for each quantity key. Every ticket
// shows the sessions; per-session tickets are charged once per session ticked.
const [picks, setPicks] = useState<Record<string, string[]>>({});
const wholeCourseClosed = event.attendanceMode === "pick" && !!event.wholeCourseClosesAt && Date.now() >= new Date(event.wholeCourseClosesAt).getTime();
const choosing = choosesSessions(event);
const pickFor = (opt: EventOption) => isPickOption(event, opt);
// A waitlist offer for one session: that session is ticked for them.
useEffect(() => {
if (!offer?.sessionId) return;
const key = offer.variantId ? `${offer.eventOptionId}:${offer.variantId}` : offer.eventOptionId;
setPicks(p => ({ ...p, [key]: [offer.sessionId as string] }));
}, [offer]);
const sessionsFor = (key: string, opt: EventOption) => (pickFor(opt) ? (picks[key] || []).length : 1);
const total = useMemo(() => {
@@ -505,7 +519,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
if (qty <= 0) return;
const [eventOptionId, variantId] = key.includes(":") ? key.split(":") : [key, undefined];
const opt = (event.eventOptions || []).find(o => o.id === eventOptionId);
const sessionIds = opt && pickFor(opt) ? picks[key] || [] : undefined;
const sessionIds = opt && choosing ? picks[key] || [] : undefined;
items.push({ eventOptionId, quantity: qty, ...(variantId ? { variantId } : {}), ...(sessionIds ? { sessionIds } : {}) });
});
return items;
@@ -636,7 +650,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
{offer && (
<div className="mb-4 p-4 border rounded-lg bg-green-50 border-green-200 text-sm text-green-900">
<strong>Your waitlist place:</strong> {offer.quantity} × {offer.optionName}{offer.variantName ? ` (${offer.variantName})` : ""} — held for you until {new Date(offer.expiresAt).toLocaleString()}.
<strong>Your waitlist place:</strong> {offer.quantity} × {offer.lineName || `${offer.optionName}${offer.variantName ? ` (${offer.variantName})` : ""}`} — held for you until {new Date(offer.expiresAt).toLocaleString()}.
</div>
)}
{offerError && (
@@ -691,18 +705,15 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
{!eventSoldOut && <BundleOffers bundles={event.bundles} className="mb-3" />}
{wholeCourseClosed && (
<p className="mb-3 text-sm text-gray-600 bg-gray-50 border rounded-lg px-3 py-2">The course has started, so whole-course tickets are no longer on sale — choose the sessions you&apos;d like to come to.</p>
)}
<div className="space-y-3">
{(event.eventOptions || []).filter(opt => !(wholeCourseClosed && !pickFor(opt))).map((opt) => (
{(event.eventOptions || []).map((opt) => (
<OptionCard
key={opt.id}
opt={opt}
quantities={quantities}
onQtyChange={handleQtyChange}
waitlist={event.waitlistEnabled ? { eventId, guestsAllowed: event.requiresAuth === false } : null}
pick={pickFor(opt) ? { event, picks, onPick: (key, ids) => setPicks(p => ({ ...p, [key]: ids })) } : null}
pick={choosing ? { event, picks, onPick: (key, ids) => setPicks(p => ({ ...p, [key]: ids })), perSession: pickFor(opt) } : null}
/>
))}
</div>
+13 -5
View File
@@ -9,7 +9,7 @@ import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/co
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { errorMessage } from "@/lib/utils";
import type { Event, PublicBundle, Registration } from "@/types";
import { isPickOption } from "@/lib/sessions";
import { choosesSessions, isPickOption } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -230,11 +230,19 @@ export default function SelfServicePage() {
const o = (kioskFull?.eventOptions || []).find(x => x.id === optionId);
return kioskFull && o && isPickOption(kioskFull, o) ? o : null;
};
// On a "pick sessions" event every ticket has sessions to tick; only pickOpt ones are charged per session.
const choiceOpt = (optionId: string) => {
const o = (kioskFull?.eventOptions || []).find(x => x.id === optionId);
return kioskFull && o && choosesSessions(kioskFull) ? o : null;
};
const times = (key: string) => (pickOpt(key.split("::")[0]) ? (picks[key] || []).length : 1);
const picker = (key: string, optionId: string) => {
const o = pickOpt(optionId);
if (!o || !kioskFull || !(quantities[key] > 0)) return null;
return <SessionPicker event={kioskFull} option={o} quantity={quantities[key]} selected={picks[key] || []} onChange={ids => setPicks(p => ({ ...p, [key]: ids }))} />;
const o = choiceOpt(optionId);
if (!o || !kioskFull) return null;
// The sessions show straight away; ticking one when the quantity is 0 makes it 1.
const qty = quantities[key] || 0;
return <SessionPicker event={kioskFull} option={o} quantity={Math.max(1, qty)} selected={qty > 0 ? picks[key] || [] : []}
onChange={ids => { setPicks(p => ({ ...p, [key]: ids })); if (qty === 0 && ids.length > 0) setQuantities(q => ({ ...q, [key]: 1 })); }} />;
};
const total = useMemo(() => {
@@ -456,7 +464,7 @@ export default function SelfServicePage() {
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(pickOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(choiceOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
}),
user,
// If the visitor declined to create an account, don't push an unsolicited
@@ -14,6 +14,7 @@ type Claim = {
eventStart: string;
optionName: string;
variantName: string | null;
lineName?: string;
quantity: number;
requested: number;
expiresAt: string;
@@ -53,7 +54,7 @@ export default function WaitlistClaimPage() {
{claim.status === "offered" ? (
<>
<p className="text-sm text-gray-700">
{claim.name ? `Hi ${claim.name}, ` : ""}{claim.quantity} × {claim.optionName}{claim.variantName ? ` (${claim.variantName})` : ""} {claim.quantity === 1 ? "is" : "are"} being held for you until <strong>{new Date(claim.expiresAt).toLocaleString()}</strong>.
{claim.name ? `Hi ${claim.name}, ` : ""}{claim.quantity} × {claim.lineName || `${claim.optionName}${claim.variantName ? ` (${claim.variantName})` : ""}`} {claim.quantity === 1 ? "is" : "are"} being held for you until <strong>{new Date(claim.expiresAt).toLocaleString()}</strong>.
{claim.quantity < claim.requested && ` You asked for ${claim.requested}; this is what came free.`}
</p>
<a href={`/register/${claim.eventId}?waitlist=${encodeURIComponent(token)}`} className="inline-block px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
@@ -46,6 +46,7 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
eventId,
eventOptionId: line.eventOptionId,
...(line.variantId ? { variantId: line.variantId } : {}),
...(line.sessionId ? { sessionId: line.sessionId } : {}),
quantity: qty,
...(!loggedIn ? { guestName: guest.name.trim(), guestEmail: guest.email.trim(), guestPhone: guest.phone.trim() || undefined } : {}),
},
@@ -124,5 +125,5 @@ export function JoinWaitlistButton({ eventId, lines, guestsAllowed, initialLine,
}
function keyOf(l?: WaitlistLine) {
return l ? `${l.eventOptionId}::${l.variantId || ""}` : "";
return l ? `${l.eventOptionId}::${l.variantId || ""}::${l.sessionId || ""}` : "";
}
@@ -3,10 +3,12 @@
import React from "react";
import type { Event } from "@/types";
import { bookableSessions, formatSessionWhen, sessionLabel } from "@/lib/sessions";
import { JoinWaitlistButton } from "@/components/events/JoinWaitlistButton";
// Tick boxes for the sessions of a "pick sessions" ticket. Full sessions (or ones with
// fewer places than `quantity`) can't be ticked.
export function SessionPicker({ event, option, selected, onChange, quantity = 1, staff = false, unitPrice }: {
// fewer places than `quantity`) can't be ticked; with `waitlist`, they get a "Join
// waitlist" button for that session instead.
export function SessionPicker({ event, option, selected, onChange, quantity = 1, staff = false, unitPrice, waitlist }: {
event: Pick<Event, "sessions">;
option: { id: string; sessionAvailability?: Record<string, number> };
selected: string[];
@@ -14,6 +16,7 @@ export function SessionPicker({ event, option, selected, onChange, quantity = 1,
quantity?: number;
staff?: boolean;
unitPrice?: number;
waitlist?: { eventId: string; guestsAllowed: boolean; variantId?: string | null; label: string } | null;
}) {
const sessions = bookableSessions(event, option, staff);
if (sessions.length === 0) return <p className="text-xs text-gray-500 mt-2">No sessions left to book.</p>;
@@ -29,13 +32,20 @@ export function SessionPicker({ event, option, selected, onChange, quantity = 1,
</div>
{sessions.map(s => {
const full = s.left !== null && s.left < Math.max(1, quantity);
const name = sessionLabel(s, event.sessions);
return (
<label key={s.id} className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm ${full ? "bg-gray-50 text-gray-400" : selected.includes(s.id) ? "border-brand-300 bg-brand-50" : "hover:bg-gray-50"}`}>
<input type="checkbox" disabled={full && !selected.includes(s.id)} checked={selected.includes(s.id)} onChange={() => toggle(s.id)} />
<span className="font-medium">{sessionLabel(s, event.sessions)}</span>
<span className="text-gray-500">{formatSessionWhen(s)}</span>
<span className="ml-auto text-xs">{s.left === null ? "" : s.left <= 0 ? "Full" : `${s.left} left`}</span>
</label>
<div key={s.id} className={`flex items-center gap-2 rounded border px-2 py-1.5 text-sm ${full ? "bg-gray-50 text-gray-400" : selected.includes(s.id) ? "border-brand-300 bg-brand-50" : "hover:bg-gray-50"}`}>
<label className="flex items-center gap-2 flex-1 min-w-0">
<input type="checkbox" disabled={full && !selected.includes(s.id)} checked={selected.includes(s.id)} onChange={() => toggle(s.id)} />
<span className="font-medium">{name}</span>
<span className="text-gray-500">{formatSessionWhen(s)}</span>
<span className="ml-auto text-xs">{s.left === null ? "" : s.left <= 0 ? "Full" : `${s.left} left`}</span>
</label>
{full && waitlist && !selected.includes(s.id) && (
<JoinWaitlistButton compact eventId={waitlist.eventId} guestsAllowed={waitlist.guestsAllowed}
lines={[{ eventOptionId: option.id, variantId: waitlist.variantId, sessionId: s.id, label: `${waitlist.label} — ${name}, ${formatSessionWhen(s)}` }]} />
)}
</div>
);
})}
</div>
@@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useState } from "react";
import { ListChecks } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { entryLineName } from "@/lib/waitlist";
type Entry = {
id: string;
@@ -14,6 +15,7 @@ type Entry = {
event: { id: string; title: string; startDate: string };
eventOption: { name: string };
variant: { name: string } | null;
session: { label: string | null; startsAt: string } | null;
};
/** My Events: the waitlists I'm on, with Leave (and a note when a place is on offer). */
@@ -46,7 +48,7 @@ export function MyWaitlists({ token, onMessage }: { token: string; onMessage: (m
<li key={e.id} className="py-2 flex items-center justify-between gap-3 text-sm">
<div>
<div className="font-medium">{e.event.title}</div>
<div className="text-xs text-gray-500">{e.quantity} × {e.eventOption.name}{e.variant ? ` (${e.variant.name})` : ""}</div>
<div className="text-xs text-gray-500">{e.quantity} × {entryLineName(e)}</div>
{e.status === "offered" ? (
<div className="text-xs text-green-700 mt-0.5">
{e.offeredQuantity} place(s) held for you until {e.offerExpiresAt ? new Date(e.offerExpiresAt).toLocaleString() : ""} — use the link we sent you to claim.
@@ -43,6 +43,7 @@ export const supervisorAtTheDoorHelpContent: HelpContent = {
<p>Selecting a registration automatically jumps to Payment mode if it&apos;s unpaid, or Check-in mode if it&apos;s already fully paid.</p>
<p>When editing an existing registration&apos;s items, quantities can&apos;t be reduced below what&apos;s already been printed or paid for.</p>
<p>The item picker has a &quot;Have a code?&quot; field for discount codes. On an existing registration the code is applied when you save.</p>
<p>On events where people pick sessions, tick the sessions under a per-session ticket. For a new attendee, a place in today&apos;s session is selected for you when there&apos;s room. Sessions already booked on a registration can&apos;t be changed when editing it — book extra sessions as a new registration.</p>
</div>
),
},
@@ -38,7 +38,7 @@ export const supervisorEventsHelpContent: HelpContent = {
Choose &quot;Multiple sessions&quot; for one event on several dates — e.g. every Wednesday 18:30–20:30 for 5 weeks. Use the repeat pattern to create the dates, then edit any of them, add a label (&quot;Week 1 — …&quot;), a different location, or an extra date. People register and pay once and get one ticket for every session. Cancel a session (e.g. a public holiday) rather than deleting it once people have been checked in. Registration closes when the first session starts unless you turn on late joining.
</GuideItem>
<GuideItem icon={CalendarDays} title="Pick sessions" tone="violet">
For day passes or a drop-in class, set &quot;How people book&quot; to &quot;Pick sessions&quot;, give sessions an optional capacity, and in Items &amp; Pricing set a ticket to &quot;Pick sessions (price per session)&quot;. People tick the sessions they want; each one is its own ticket. Whole-course tickets can still be sold too, until the course starts.
For day passes or a drop-in class, set &quot;How people book&quot; to &quot;Pick sessions&quot;, give sessions an optional capacity, and in Items &amp; Pricing set a ticket to &quot;Pick sessions (price per session)&quot;. People tick the sessions they want; each one is its own ticket. A ticket left on &quot;All sessions&quot; also shows the sessions to tick, but it&apos;s one ticket at one price — it then only scans on the sessions ticked.
</GuideItem>
<GuideItem icon={ListChecks} title="Items & pricing" tone="green">
Ticket options and stock, variants of each option, and early-bird pricing tiers. An optional staff price is charged automatically to Staff and above when it&apos;s lower than the normal price.
@@ -19,13 +19,13 @@ export const supervisorWaitlistHelpContent: HelpContent = {
In the event wizard&apos;s Visibility step, tick &quot;Waitlist for sold-out tickets&quot;, set how long offered places are held (24 hours by default), and whether someone can be offered fewer places than they asked for.
</GuideItem>
<GuideItem icon={ListChecks} title="Joining" tone="green">
When a ticket is sold out, the event and registration pages show &quot;Join waitlist&quot;. People pick the ticket and how many, and can leave again from My Events.
When a ticket is sold out, the event and registration pages show &quot;Join waitlist&quot;. People pick the ticket and how many, and can leave again from My Events. On events where people pick sessions, the waitlist is per session: a full session shows &quot;Join waitlist&quot; in the session list, and the Ticket column shows which session someone is waiting for.
</GuideItem>
<GuideItem icon={Send} title="Offers" tone="violet">
When places free up — a cancellation, a smaller booking, or a higher stock limit — the next person in line is emailed or WhatsApped a claim link. The places are held for them, so nobody else can buy them in the meantime.
</GuideItem>
<GuideItem icon={Clock} title="Expiry" tone="amber">
If the offer isn&apos;t claimed in time, it expires and the places go to the next person. Claiming goes through normal registration and payment, with early-bird and coupon rules as usual.
If the offer isn&apos;t claimed in time, it expires and the places go to the next person. An offer for a session never runs past the session&apos;s start. Claiming goes through normal registration and payment, with early-bird and coupon rules as usual.
</GuideItem>
</div>
</div>
+8
View File
@@ -174,6 +174,14 @@ export function todaysSession(event: Pick<Event, "scheduleType" | "sessions"> |
return list.find(s => now <= new Date(s.endsAt).getTime()) || list[list.length - 1] || null;
}
/**
* Whether people tick sessions when booking this event ("pick sessions" attendance).
* Every ticket shows the sessions; only per-session tickets are charged per session.
*/
export function choosesSessions(event: Pick<Event, "scheduleType" | "attendanceMode"> | null | undefined): boolean {
return event?.scheduleType === "sessions" && event?.attendanceMode === "pick";
}
/** Whether an option is bought per session (only in "pick sessions" events). */
export function isPickOption(event: Pick<Event, "scheduleType" | "attendanceMode"> | null | undefined, option: { sessionMode?: "all" | "pick" } | null | undefined): boolean {
return event?.scheduleType === "sessions" && event?.attendanceMode === "pick" && option?.sessionMode === "pick";
+10 -1
View File
@@ -1,7 +1,16 @@
// Pure helpers shared by server and client components. Must not be "use client",
// or server pages calling these get a client reference instead of the function.
export type WaitlistLine = { eventOptionId: string; variantId?: string | null; label: string };
// sessionId: one session of a "pick sessions" option (those are waited for per session).
export type WaitlistLine = { eventOptionId: string; variantId?: string | null; sessionId?: string | null; label: string };
/** "Drop-in (Adult)" or "Drop-in — Class 2, Wed 4 Nov" for a waitlist entry. */
export function entryLineName(e: { eventOption: { name: string }; variant?: { name: string } | null; session?: { label?: string | null; startsAt: string } | null }): string {
const base = `${e.eventOption.name}${e.variant ? ` (${e.variant.name})` : ""}`;
if (!e.session) return base;
const when = new Date(e.session.startsAt).toLocaleDateString(undefined, { weekday: "short", day: "numeric", month: "short" });
return `${base} — ${e.session.label ? `${e.session.label}, ` : ""}${when}`;
}
/** The sold-out lines of an event, for the waitlist button. */
export function soldOutLines(options: { id: string; name: string; stockLimit?: number; availableCount?: number | null; variants?: { id: string; name: string; stockLimit?: number; availableCount?: number | null }[] | null }[]): WaitlistLine[] {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.35.0",
"version": "1.35.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.35.0",
"version": "1.35.1",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.35.0",
"version": "1.35.1",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",