Compare commits

...
4 Commits
Author SHA1 Message Date
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
joshuaandClaude Opus 5.5 b441ffac60 Bump to 1.35.0
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 09:25:32 +02:00
joshuaandClaude Opus 5.5 6010641629 Pick sessions (frontend), roadmap and handover docs
Event wizard: how people book (whole course / pick sessions), capacity
per session and per-option session mode. Session picker on the public
registration page, Manual registration and the kiosk; at the door points
to Manual registration. Register, tickets and My Events handle picked
sessions. Adds docs/ROADMAP-v2.md and docs/HANDOVER.md so work can carry
on from another machine, and a pick-sessions e2e test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 09:25:21 +02:00
joshuaandClaude Opus 5.5 e338768f6e Pick sessions (backend): book individual sessions with per-session capacity
Options on a multi-day event can be bought per session (one line and
ticket per session, priced per session, bookable until the session
starts). Sessions can have a capacity covering picked places and
whole-course tickets. Picked tickets only scan on their own session; the
register, certificates and reminders follow the sessions booked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 16:05:48 +02:00
44 changed files with 2170 additions and 158 deletions
+14
View File
@@ -7,6 +7,20 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
## [1.35.0] - 2026-10-23
### Added
- **Pick sessions.** A multi-day event can let people choose which sessions to come to — day passes for a conference, or a weekly drop-in class. In the event wizard choose "How people book: Pick sessions", then in Items & Pricing set a ticket to "Pick sessions (price per session)". People tick the sessions they want when they register; each session is its own line and its own ticket, at the per-session price (early-bird and staff prices apply per session; late-joiner pricing doesn't). A session can be booked until it starts (staff can book until it ends). Whole-course tickets can still be sold alongside, and stop selling when the course starts.
- **Session capacity**: in pick-sessions events each session can have a capacity, covering picked places plus whole-course tickets. Full sessions can't be ticked. A per-session ticket's stock limit is places per session.
- Picked-session tickets only scan on their own session ("This ticket is for Class 2 (2026-11-11)…"), on and offline. The attendance register shows one row per person with a dash for sessions they didn't book, and completion and certificates count the sessions they booked. Session reminders go only to the people booked on that session. Tickets, printouts and the ticket email show the session's date.
- The session picker is on the public registration page, Manual registration and the self-service kiosk.
- The roadmap to 2.0 and a handover note are now in the repo: `docs/ROADMAP-v2.md`, `docs/HANDOVER.md` (plus the previous round's spec, `docs/FEATURES-ROUND-1.md`).
### Changed
- Per-session tickets can't be changed from My Events' "edit booking" (book more sessions by registering again); they still count towards the total. At the door, per-session tickets are booked from Manual registration (the page says so).
## [1.34.0] - 2026-10-22
### Added
+8
View File
@@ -97,6 +97,14 @@ Feedback → Attendance**.
with the next session highlighted; cards show "5 sessions · Wed 4 Nov – Wed
2 Dec" and stay listed until the last session. Calendar files get one entry
per session. Saving moved or cancelled sessions offers to message attendees.
- **Pick sessions** (multi-day) — instead of the whole course, people can choose
which sessions to come to (day passes, drop-in classes). Tickets set to "Pick
sessions" are priced and bought per session — one line and one ticket per session,
bookable until the session starts (staff: until it ends) — alongside optional
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.
- **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
+12 -1
View File
@@ -451,13 +451,24 @@ Create/update fields (`POST`/`PUT /api/events`):
| Field | Notes |
|-------|-------|
| `scheduleType` | `single` (default) or `sessions`. Switching back to `single` is refused once there are session check-ins |
| `sessions` | `[{ id?, label?, startsAt, endsAt, location?, cancelled? }]` — the full list. Rows left out are deleted (refused when they have check-ins — send `cancelled: true` instead). The event and its sessions save together, so a refused change saves nothing |
| `attendanceMode` | `whole` (default) or `pick` — people choose sessions. Not allowed with `requiresParentalConsent` |
| `sessions` | `[{ id?, label?, startsAt, endsAt, location?, cancelled?, capacity? }]` — the full list. Rows left out are deleted (refused when they have check-ins — send `cancelled: true` instead). The event and its sessions save together, so a refused change saves nothing |
| `lateJoinEnabled`, `lateJoinUntilSessionIndex` | Registration stays open until that session (index into the sent list, sorted by start) starts; otherwise it closes when the first session starts |
| `latePricing`, `lateFixedPrice` | `full`, `prorata` (price × sessions left ÷ sessions, nearest R10, at least R10) or `fixed` (only ever lowers the price). Main tickets only |
| `checkInWindow` | `whole_day` (default — any time on the session's date, in `APP_TIMEZONE`, default Africa/Johannesburg) or `session_only` (2 h before start until the end) |
| `sessionReminders` | `null` (off) or `{ offsets: [60 \| 180 \| 1440], channels: ["email", "whatsapp"] }` |
| `certificatesEnabled`, `certificateConfig` | `{ template: classic \| modern \| church \| kids, title, body ({name} {event} {sessionsAttended} {totalSessions} {endDate}), signatureName, signatureRole, signatureImage, rule: { type: all \| count \| percent, value }, sendOnIssue }` |
Event options take `sessionMode: "all" | "pick"` (`POST /api/events/:id/options`, `PUT /api/events/options/:id`).
In a pick-mode event, a `pick` option is bought per session: registration lines send
`sessionIds: [...]` (`POST /api/registrations`, `/manual`, coupon/bundle quotes), and each
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
(`wholeCourseClosesAt` gives when whole-course tickets stop). Single-event responses add
`booked` and `placesLeft` per session and `sessionAvailability` per pick option.
Responses for events include `sessions[]` (with `cancelled` and `hasCheckIns`),
`registrationClosesAt`, `nextSession` and `lateJoin: { active, mode, sessionsLeft, totalSessions, fixedPrice }`.
`PUT` also returns `sessionChanges: { added, moved, cancelled, restored, removed }`.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "event-management-backend",
"version": "1.34.0",
"version": "1.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "event-management-backend",
"version": "1.34.0",
"version": "1.35.0",
"hasInstallScript": true,
"dependencies": {
"@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "event-management-backend",
"version": "1.34.0",
"version": "1.35.0",
"description": "Event Management System Backend",
"main": "src/index.js",
"scripts": {
@@ -0,0 +1,17 @@
-- CreateEnum
CREATE TYPE "OptionSessionMode" AS ENUM ('all', 'pick');
-- AlterTable
ALTER TABLE "EventOption" ADD COLUMN "sessionMode" "OptionSessionMode" NOT NULL DEFAULT 'all';
-- AlterTable
ALTER TABLE "EventSession" ADD COLUMN "capacity" INTEGER;
-- AlterTable
ALTER TABLE "RegistrationOption" ADD COLUMN "sessionId" TEXT;
-- CreateIndex
CREATE INDEX "RegistrationOption_sessionId_idx" ON "RegistrationOption"("sessionId");
-- AddForeignKey
ALTER TABLE "RegistrationOption" ADD CONSTRAINT "RegistrationOption_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
+16
View File
@@ -237,6 +237,9 @@ model EventOption {
stockLimit Int @default(0)
// Applied automatically for Staff and above instead of `price` (when lower). Null = no staff price.
staffPrice Float?
// Multi-day events in "pick sessions" mode: `pick` options are bought per session
// (price is per session, stockLimit is places per session); `all` options cover every session.
sessionMode OptionSessionMode @default(all)
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
registrationOptions RegistrationOption[]
earlyBirdTiers EarlyBirdTier[]
@@ -337,11 +340,16 @@ model RegistrationOption {
tickets Ticket[]
tranches RegistrationOptionTranche[]
attendees Attendee[]
// A "pick sessions" option: one line (and ticket) per session booked. Null for
// everything else.
sessionId String?
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
@@index([registrationId])
@@index([eventOptionId])
@@index([variantId])
@@index([appliedTierId])
@@index([sessionId])
}
// One row per purchase-at-a-price for a RegistrationOption. Never mutated after creation
@@ -1445,6 +1453,11 @@ enum LatePricing {
fixed
}
enum OptionSessionMode {
all
pick
}
enum CheckInWindow {
whole_day
session_only
@@ -1462,11 +1475,14 @@ model EventSession {
// and left out of attendance totals.
cancelledAt DateTime?
order Int @default(0)
// Optional limit on people at this session (picked places + whole-course tickets).
capacity Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
usages TicketUsage[]
reminders SessionReminderLog[]
registrationOptions RegistrationOption[]
@@index([eventId, startsAt])
}
+22 -11
View File
@@ -42,7 +42,7 @@ async function buildRegister(eventId) {
holder: { select: { name: true } },
user: { select: { name: true, email: true, phoneNumber: true } },
usages: { select: { quantityRedeemed: true, sessionId: true, manual: true } },
registrationOption: { select: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } },
registrationOption: { select: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true, eventOption: { select: { name: true } }, variant: { select: { name: true } } } },
certificates: { select: { id: true, code: true, issuedAt: true, revokedAt: true, manual: true, emailedAt: true } },
},
orderBy: { createdAt: 'asc' },
@@ -50,18 +50,27 @@ async function buildRegister(eventId) {
const activeIds = new Set(active.map(s => s.id));
const config = sessionsUtil.normalizeCertificateConfig(event.certificateConfig);
const rows = tickets.map(t => {
// A picked-session booking (one ticket per session) shows as one row.
const rows = sessionsUtil.groupTickets(tickets).map(g => {
const t = g.tickets[0];
const bySession = {};
for (const u of t.usages) {
const key = multi ? u.sessionId : WHOLE_EVENT;
if (!key) continue;
bySession[key] = (bySession[key] || 0) + (u.quantityRedeemed || 1);
for (const tk of g.tickets) {
for (const u of tk.usages) {
const key = multi ? u.sessionId : WHOLE_EVENT;
if (!key) continue;
bySession[key] = (bySession[key] || 0) + (u.quantityRedeemed || 1);
}
}
const attended = multi ? Object.keys(bySession).filter(k => activeIds.has(k)).length : (bySession[WHOLE_EVENT] ? 1 : 0);
const total = multi ? active.length : 1;
const booked = g.sessionIds ? g.sessionIds.filter(id => activeIds.has(id)) : null;
const counted = booked ? new Set(booked) : activeIds;
const attended = multi ? Object.keys(bySession).filter(k => counted.has(k)).length : (bySession[WHOLE_EVENT] ? 1 : 0);
const total = multi ? counted.size : 1;
const cert = t.certificates[0] || null;
return {
ticketId: t.id,
// Picked sessions: which sessions this person booked, and the ticket for each.
bookedSessions: g.sessionIds,
sessionTickets: g.sessionIds ? Object.fromEntries(g.tickets.map(tk => [tk.registrationOption.sessionId, tk.id])) : null,
name: t.attendee?.name || t.holder?.name || t.user?.name || '',
bookedBy: t.attendee || t.holder ? t.user?.name || '' : '',
email: t.user?.email || '',
@@ -108,7 +117,7 @@ const markAttendance = async (req, res) => {
const { eventId } = req.params;
const { ticketId, present } = req.body || {};
const sessionKey = String(req.body?.sessionId || '');
const ticket = await prisma.ticket.findUnique({ where: { id: String(ticketId || '') }, include: { usages: true, event: { select: { scheduleType: true, cashupStatus: true } } } });
const ticket = await prisma.ticket.findUnique({ where: { id: String(ticketId || '') }, include: { usages: true, event: { select: { scheduleType: true, cashupStatus: true } }, registrationOption: { select: { sessionId: true } } } });
if (!ticket || ticket.eventId !== eventId) throw notFound('Ticket not found for this event');
if (ticket.event.cashupStatus === 'closed') throw badRequest('This event is closed — reopen it to change attendance.');
const multi = ticket.event.scheduleType === 'sessions';
@@ -117,6 +126,7 @@ const markAttendance = async (req, res) => {
const session = await prisma.eventSession.findUnique({ where: { id: sessionKey } });
if (!session || session.eventId !== eventId) throw notFound('Session not found for this event');
if (session.cancelledAt) throw badRequest('That session was cancelled');
if (ticket.registrationOption?.sessionId && ticket.registrationOption.sessionId !== session.id) throw badRequest('That ticket is for a different session');
sessionId = session.id;
} else if (sessionKey !== WHOLE_EVENT) {
throw badRequest('This event has no sessions');
@@ -202,8 +212,9 @@ const exportRegister = async (req, res) => {
cols.forEach((c, i) => {
const qty = r.checkIns[c.id] || 0;
const x = 36 + nameW + i * colW;
const notBooked = r.bookedSessions && !r.bookedSessions.includes(c.id);
if (qty && r.quantity <= 1) tick(x + (colW - 2) / 2, y + 4);
else doc.fillColor(qty ? '#15803d' : '#cbd5e1').text(qty ? String(qty) : '·', x, y, { width: colW - 2, align: 'center', lineBreak: false });
else doc.fillColor(qty ? '#15803d' : '#cbd5e1').text(qty ? String(qty) : notBooked ? '-' : '·', x, y, { width: colW - 2, align: 'center', lineBreak: false });
});
doc.fillColor('#1e293b').text(`${r.attended}/${r.total}`, 36 + nameW + cols.length * colW, y, { width: totalW, align: 'center', lineBreak: false });
doc.y = y + 14;
@@ -223,7 +234,7 @@ const exportRegister = async (req, res) => {
for (const r of reg.rows) {
ws.addRow([
r.name, r.bookedBy, r.variant ? `${r.option} — ${r.variant}` : r.option, r.email, r.phone, r.quantity,
...cols.map(c => r.checkIns[c.id] ? (r.quantity > 1 ? r.checkIns[c.id] : '✓') : ''),
...cols.map(c => r.checkIns[c.id] ? (r.quantity > 1 ? r.checkIns[c.id] : '✓') : (r.bookedSessions && !r.bookedSessions.includes(c.id) ? 'not booked' : '')),
`${r.attended}/${r.total}`, r.completed ? 'Yes' : 'No',
r.certificate && !r.certificate.revokedAt ? r.certificate.code : '',
]);
+3 -1
View File
@@ -250,7 +250,9 @@ async function basketBuyer(req, forUserId) {
/** Prices a basket the same way registration will, as coupon/bundle lines. */
async function priceBasket(event, options, buyer) {
const registrationOptions = [];
for (const o of options || []) {
// A "pick sessions" line counts once per chosen session.
const expanded = (options || []).flatMap(o => (Array.isArray(o.sessionIds) && o.sessionIds.length ? o.sessionIds.map(sessionId => ({ ...o, sessionId })) : [o]));
for (const o of expanded) {
const eo = event.eventOptions.find(x => x.id === o.eventOptionId);
const qty = Number(o.quantity || 0);
if (!eo || qty < 1) continue;
+32 -6
View File
@@ -13,14 +13,21 @@ const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' }, include: { _count: { se
// Adds the multi-day schedule to an event for API responses: sessions (cancelled ones
// flagged), when registration closes, the next session, and late-joiner pricing.
function withSchedule(ev) {
const sessions = (ev.sessions || []).map(({ _count, ...s }) => ({ ...s, cancelled: !!s.cancelledAt, hasCheckIns: (_count?.usages || 0) > 0 }));
// `bookings` (Map sessionId → people booked) adds `booked` and `placesLeft` per session.
function withSchedule(ev, bookings = null) {
const sessions = (ev.sessions || []).map(({ _count, ...s }) => ({
...s,
cancelled: !!s.cancelledAt,
hasCheckIns: (_count?.usages || 0) > 0,
...(bookings ? { booked: bookings.get(s.id) || 0, placesLeft: s.capacity > 0 ? Math.max(0, s.capacity - (bookings.get(s.id) || 0)) : null } : {}),
}));
const active = sessionsUtil.activeSessions(sessions);
const now = new Date();
return {
...ev,
sessions,
registrationClosesAt: sessionsUtil.registrationClosesAt(ev, sessions),
wholeCourseClosesAt: ev.scheduleType === 'sessions' ? sessionsUtil.wholeCourseClosesAt(ev, sessions) : null,
nextSession: ev.scheduleType === 'sessions' ? active.find(s => new Date(s.endsAt) > now) || null : null,
lateJoin: ev.scheduleType === 'sessions' ? sessionsUtil.lateInfo(ev, sessions, now) : null,
};
@@ -32,6 +39,10 @@ function scheduleData(body, current = {}) {
const has = (k) => body[k] !== undefined;
const data = {};
if (has('scheduleType')) data.scheduleType = body.scheduleType === 'sessions' ? 'sessions' : 'single';
if (has('attendanceMode')) data.attendanceMode = body.attendanceMode === 'pick' ? 'pick' : 'whole';
if ((data.attendanceMode ?? current.attendanceMode) === 'pick' && (body.requiresParentalConsent ?? current.requiresParentalConsent) === true) {
throw sessionsUtil.sessionError("Choosing sessions isn't available on youth events (parental consent).");
}
if (has('checkInWindow')) data.checkInWindow = body.checkInWindow === 'session_only' ? 'session_only' : 'whole_day';
if (has('lateJoinEnabled')) data.lateJoinEnabled = body.lateJoinEnabled === true;
if (has('latePricing')) data.latePricing = ['prorata', 'fixed'].includes(body.latePricing) ? body.latePricing : 'full';
@@ -342,7 +353,8 @@ const getEvents = async (req, res) => {
const withStock = await Promise.all(events.map(async ev => {
const earlyBirdEndsAt = earlyBirdEnds.get(ev.id) || null;
try {
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0);
// Picked-session options have per-session limits, so they never make the event sold out.
const limitedOpts = (ev.eventOptions || []).filter(o => (o.stockLimit || 0) > 0 && !sessionsUtil.isPickOption(ev, o));
let isSoldOut = false;
if (limitedOpts.length > 0) {
const { takenCount } = require('../utils/stock');
@@ -551,6 +563,16 @@ const getEventById = async (req, res) => {
eventOptionsWithStock = await Promise.all((event.eventOptions || []).map(async opt => {
// Taken = non-cancelled registrations + places held for live waitlist offers
const { takenCount } = require('../utils/stock');
// Picked-session options: the stock limit is places per session.
if (sessionsUtil.isPickOption(event, opt)) {
const sessionAvailability = {};
if (opt.stockLimit > 0) {
for (const s of sessionsUtil.activeSessions(event.sessions)) {
sessionAvailability[s.id] = Math.max(0, opt.stockLimit - await takenCount({ eventOptionId: opt.id, sessionId: s.id }));
}
}
return { ...opt, soldCount: 0, availableCount: null, sessionAvailability };
}
const soldCount = await takenCount({ eventOptionId: opt.id });
const availableCount = opt.stockLimit > 0 ? Math.max(0, opt.stockLimit - soldCount) : null;
@@ -569,6 +591,7 @@ const getEventById = async (req, res) => {
}
if (!event.sessions) event.sessions = await prisma.eventSession.findMany({ where: { eventId }, ...SESSIONS_INCLUDE }).catch(() => []);
const bookings = event.scheduleType === 'sessions' ? await sessionsUtil.sessionBookings(eventId).catch(() => null) : null;
const mapped = withSchedule({
...event,
eventOptions: eventOptionsWithStock,
@@ -579,7 +602,7 @@ const getEventById = async (req, res) => {
})),
form,
bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []),
});
}, bookings);
res.json(mapped);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
@@ -915,7 +938,7 @@ const createEventOption = async (req, res) => {
await assertEventOpen(req.params.id, res);
const { name, price, isMainTicket, stockLimit, staffPrice } = req.body;
const { name, price, isMainTicket, stockLimit, staffPrice, sessionMode } = req.body;
const eventOption = await prisma.eventOption.create({
data: {
@@ -926,8 +949,10 @@ const createEventOption = async (req, res) => {
isMainTicket: isMainTicket || false,
stockLimit: stockLimit ? parseInt(stockLimit, 10) : 0,
staffPrice: parseStaffPrice(staffPrice),
sessionMode: sessionMode === 'pick' ? 'pick' : 'all',
}
});
sessionsUtil.forgetSchedule(req.params.id);
res.status(201).json(eventOption);
} catch (error) {
@@ -952,7 +977,7 @@ const updateEventOption = async (req, res) => {
await assertEventOpen(eventOption.eventId, res);
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice } = req.body;
const { name, price, isMainTicket, stockLimit, earlyBirdTiers, variants, staffPrice, sessionMode } = req.body;
const updatedEventOption = await prisma.eventOption.update({
where: { id: req.params.id },
@@ -962,6 +987,7 @@ const updateEventOption = async (req, res) => {
isMainTicket: isMainTicket !== undefined ? isMainTicket : eventOption.isMainTicket,
stockLimit: stockLimit !== undefined ? parseInt(stockLimit, 10) : eventOption.stockLimit,
staffPrice: parseStaffPrice(staffPrice, eventOption.staffPrice),
sessionMode: sessionMode !== undefined ? (sessionMode === 'pick' ? 'pick' : 'all') : eventOption.sessionMode,
}
});
@@ -38,7 +38,8 @@ const TICKET_DELIVERY = ['payer', 'each'];
*/
function placesAfter(existingLines, requested) {
const out = {};
for (const l of [...(existingLines || []), ...(requested || [])]) {
// Picked-session lines take no attendee names.
for (const l of [...(existingLines || []), ...(requested || [])].filter(x => !x.sessionId)) {
const k = lineKey(l.eventOptionId, l.variantId);
out[k] = (out[k] || 0) + Number(l.quantity || 0);
}
@@ -52,7 +53,7 @@ const ACTIVE_COUPONS_INCLUDE = {
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
};
const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles');
const { registrationClosesAt } = require('../utils/sessions');
const { registrationClosesAt, wholeCourseClosesAt, expandPickLines, assertSessionCapacity, isPickOption } = require('../utils/sessions');
/**
* Coupon lines for options that haven't been saved yet, so a code can be checked
@@ -92,18 +93,18 @@ async function priceRequestedOption(eventOption, variantId, qty, buyer) {
* offers count as taken, except the offer being claimed (`excludeEntryId`).
* Returns { available: boolean, remaining: number|null }
*/
async function checkOptionStock(option, requestedQty, excludeEntryId = null) {
async function checkOptionStock(option, requestedQty, excludeEntryId = null, sessionId = null) {
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null };
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id }, { excludeEntryId });
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id, sessionId }, { excludeEntryId });
return { available: remaining >= requestedQty, remaining };
}
/**
* Check stock availability for a specific variant (same rules as checkOptionStock).
*/
async function checkVariantStock(variant, requestedQty, excludeEntryId = null) {
async function checkVariantStock(variant, requestedQty, excludeEntryId = null, sessionId = null) {
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null };
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id }, { excludeEntryId });
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id, sessionId }, { excludeEntryId });
return { available: remaining >= requestedQty, remaining };
}
@@ -222,11 +223,20 @@ const createRegistration = async (req, res) => {
throw new Error('At least one option must be selected');
}
// "Pick sessions" options become one line per chosen session; whole-course tickets
// on such an event still close when the whole course does.
const lines = expandPickLines(event, options, { now });
if (event.attendanceMode === 'pick' && lines.some(l => !l.sessionId) && now >= wholeCourseClosesAt(event, event.sessions)) {
res.status(400);
throw new Error('Whole-course tickets are no longer on sale — choose the sessions you want instead.');
}
await assertSessionCapacity(event, lines);
// Validate each option and resolve prices / check stock.
// Options are independent of each other, so resolve them concurrently. Errors are marked
// with `.isStockError` rather than relying on shared `res.statusCode` (which would race
// across concurrent iterations) — the outer catch always responds 400 regardless.
const resolvedOptions = await Promise.all(options.map(async (option) => {
const resolvedOptions = await Promise.all(lines.map(async (option) => {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
if (!eventOption) {
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
@@ -239,7 +249,7 @@ const createRegistration = async (req, res) => {
// Check overall option stock
try {
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id);
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id, option.sessionId);
if (!stockCheck.available) {
const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
err.isStockError = true;
@@ -259,7 +269,7 @@ const createRegistration = async (req, res) => {
throw new Error(`Variant not found for option "${eventOption.name}"`);
}
try {
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id);
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id, option.sessionId);
if (!vStock.available) {
const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
err.isStockError = true;
@@ -320,9 +330,9 @@ const createRegistration = async (req, res) => {
// tickets bought at different early-bird prices into a single (wrong) price. Each
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
for (const opt of resolvedOptions) {
// Match on eventOptionId + variantId for correct row
// Match on eventOptionId + variantId (+ session, for picked sessions) for correct row
const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null)
);
if (existing) {
await prisma.$transaction([
@@ -348,6 +358,7 @@ const createRegistration = async (req, res) => {
eventOptionId: opt.eventOptionId,
quantity: opt.quantity,
variantId: opt.variantId || null,
sessionId: opt.sessionId || null,
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: {
@@ -395,6 +406,7 @@ const createRegistration = async (req, res) => {
eventOptionId: option.eventOptionId,
quantity: option.quantity,
variantId: option.variantId || null,
sessionId: option.sessionId || null,
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
@@ -941,7 +953,7 @@ const createManualRegistration = async (req, res) => {
//Check event
const event = await prisma.event.findUnique({
where: {id: eventId},
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } }, sessions: true },
});
if (!event) {
@@ -962,9 +974,13 @@ const createManualRegistration = async (req, res) => {
throw new Error('At least one option must be selected');
}
// Picked sessions become one line each; staff can still book a session until it ends.
const lines = expandPickLines(event, options, { staff: true });
await assertSessionCapacity(event, lines);
// Validate each option up front, before any account is created or changed —
// including stock, which staff registrations previously skipped.
for (const option of options) {
for (const option of lines) {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
if (!eventOption) {
res.status(400);
@@ -975,8 +991,8 @@ const createManualRegistration = async (req, res) => {
throw new Error('Quantity must be at least 1');
}
const variant = option.variantId ? (eventOption.variants || []).find(v => v.id === option.variantId) : null;
const oStock = await checkOptionStock(eventOption, option.quantity);
const vStock = variant ? await checkVariantStock(variant, option.quantity) : { available: true };
const oStock = await checkOptionStock(eventOption, option.quantity, null, option.sessionId);
const vStock = variant ? await checkVariantStock(variant, option.quantity, null, option.sessionId) : { available: true };
if (!oStock.available || !vStock.available) {
res.status(400);
const left = Math.max(0, Math.min(oStock.remaining ?? Infinity, vStock.remaining ?? Infinity));
@@ -986,7 +1002,7 @@ const createManualRegistration = async (req, res) => {
// Pricing (appliedTierId + priceSnapshot) depends on who the attendee is — Staff and
// above get the staff price — so it runs once the account is resolved below.
const priceManualOptions = (attendee) => Promise.all(options.map(async (option) => {
const priceManualOptions = (attendee) => Promise.all(lines.map(async (option) => {
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
const variantId = option.variantId || null;
const priced = await priceRequestedOption(eventOption, variantId, option.quantity, attendee);
@@ -1158,7 +1174,7 @@ const createManualRegistration = async (req, res) => {
// tickets bought at different early-bird prices would blend into a single wrong price.
await Promise.all(resolvedManualOptions.map(opt => {
const existing = existingReg.registrationOptions.find(
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null) && (ro.sessionId || null) === (opt.sessionId || null)
);
if (existing) {
return prisma.$transaction([
@@ -1188,6 +1204,7 @@ const createManualRegistration = async (req, res) => {
eventOptionId: opt.eventOptionId,
quantity: opt.quantity,
variantId: opt.variantId || null,
sessionId: opt.sessionId || null,
appliedTierId: opt.appliedTierId || null,
priceSnapshot: opt.priceSnapshot,
tranches: {
@@ -1256,6 +1273,7 @@ const createManualRegistration = async (req, res) => {
eventOptionId: option.eventOptionId,
quantity: option.quantity,
variantId: option.variantId || null,
sessionId: option.sessionId || null,
appliedTierId: option.appliedTierId || null,
priceSnapshot: option.priceSnapshot,
tranches: {
@@ -1465,6 +1483,7 @@ const updateRegistrationOptions = async (req, res) => {
}
const validOptionIds = new Set((registration.event?.eventOptions || []).map(eo => eo.id));
const pickOptionIds = new Set((registration.event?.eventOptions || []).filter(eo => isPickOption(registration.event, eo)).map(eo => eo.id));
for (const opt of options) {
if (!opt || !opt.eventOptionId || !Number.isInteger(opt.quantity) || opt.quantity < 1) {
res.status(400);
@@ -1474,8 +1493,17 @@ const updateRegistrationOptions = async (req, res) => {
res.status(400);
throw new Error('One or more options do not belong to this event');
}
if (pickOptionIds.has(opt.eventOptionId)) {
res.status(400);
throw new Error("Sessions are booked by registering for them — they can't be changed here.");
}
}
// Picked-session lines aren't edited here: leave them exactly as they are (they still
// count towards the total due).
const pickedLines = registration.registrationOptions.filter(ro => ro.sessionId);
registration.registrationOptions = registration.registrationOptions.filter(ro => !ro.sessionId);
// Compute totalPaid
const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
@@ -1576,6 +1604,7 @@ const updateRegistrationOptions = async (req, res) => {
resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId, existingRows, existingQty, delta, removalPlan });
}
newTotalDue += rowsTotal(pickedLines);
if (newTotalDue < totalPaid) {
res.status(400);
throw new Error('Cannot reduce items below the amount already paid');
+11 -7
View File
@@ -92,6 +92,7 @@ const getUserTickets = async (req, res) => {
include: {
eventOption: true,
variant: true,
session: true,
registration: true
}
},
@@ -262,6 +263,7 @@ const getScanPreview = async (req, res) => {
t."isUsed",
t."eventId",
ro.id AS "registrationOptionId",
ro."sessionId" AS "pinnedSessionId",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
ov.id AS "variantId",
@@ -314,7 +316,7 @@ const getScanPreview = async (req, res) => {
if (r.scheduleType === 'sessions') {
const sessionsUtil = require('../utils/sessions');
const schedule = await sessionsUtil.eventSchedule(r.eventId);
const found = sessionsUtil.scanSession(schedule, schedule.sessions, req.query.sessionId || null);
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, req.query.sessionId || null);
sessionError = found.error;
if (found.session) {
session = { id: found.session.id, label: sessionsUtil.sessionLabel(found.session, schedule.sessions) };
@@ -361,6 +363,7 @@ const scanTicket = async (req, res) => {
t."eventId",
e.title AS "eventTitle",
e."scheduleType" AS "scheduleType",
ro."sessionId" AS "pinnedSessionId",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
eo."isMainTicket" AS "isMainTicket",
@@ -381,7 +384,7 @@ const scanTicket = async (req, res) => {
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${req.params.qrCode}
GROUP BY t.id, e.id, eo.id
GROUP BY t.id, e.id, eo.id, ro.id
`;
if (!rows || rows.length === 0) {
@@ -438,7 +441,7 @@ const scanTicket = async (req, res) => {
if (r.scheduleType === 'sessions') {
sessionsUtil = require('../utils/sessions');
schedule = await sessionsUtil.eventSchedule(ticket.eventId);
const found = sessionsUtil.scanSession(schedule, schedule.sessions, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null);
const found = sessionsUtil.ticketScanSession(schedule, schedule.sessions, r.pinnedSessionId, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null);
if (!found.session) return res.status(400).json({ message: found.error, code: 'NO_SESSION', ticket });
session = found.session;
}
@@ -544,7 +547,7 @@ const getTicketsByEvent = async (req, res) => {
const tickets = await prisma.ticket.findMany({
where: { eventId: req.params.eventId },
include: {
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
registrationOption: { include: { eventOption: true, variant: true, session: true, registration: { select: { id: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
attendee: { select: { id: true, name: true, email: true, phone: true } },
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true, collectGender: true, scheduleType: true, sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } } } },
@@ -914,7 +917,7 @@ function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) {
// Event title, with the date on the right of the same line
let cy = y + pad;
const eventDate = ticket.event.startDate ? new Date(ticket.event.startDate) : null;
const dateStr = require('../utils/sessions').ticketDateText(ticket.event)
const dateStr = require('../utils/sessions').ticketDateText(ticket.event, 'short', ticket.registrationOption.session)
|| (eventDate ? new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate) : '');
doc.font('Helvetica').fontSize(9);
const dateW = dateStr ? doc.widthOfString(dateStr) : 0;
@@ -980,7 +983,7 @@ async function emailTicketsInternal(req, res, userOverride) {
const ticketInclude = {
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
registrationOption: { include: { eventOption: true, variant: true, session: true, registration: { select: { ticketDelivery: true } } } },
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
user: { select: { name: true } },
usages: true,
@@ -1088,7 +1091,8 @@ async function emailTicketsInternal(req, res, userOverride) {
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
const eventTitle = tickets[0].event.title;
const eventDate = require('../utils/sessions').ticketDateText(tickets[0].event, 'long') || (tickets[0].event.startDate
const pinnedSessions = tickets.map(t => t.registrationOption?.session).filter(Boolean);
const eventDate = require('../utils/sessions').ticketDateText(tickets[0].event, 'long', pinnedSessions.length === tickets.length && pinnedSessions.length === 1 ? pinnedSessions[0] : null, pinnedSessions.length === tickets.length ? pinnedSessions : null) || (tickets[0].event.startDate
? new Date(tickets[0].event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
: '');
const pdfFilename = `tickets-${eventTitle.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
+12 -5
View File
@@ -17,7 +17,7 @@ const path = require('path');
const PDFDocument = require('pdfkit');
const QRCode = require('qrcode');
const prisma = require('../config/db');
const { activeSessions, completionMet, normalizeCertificateConfig, attendanceBySession, TIMEZONE } = require('./sessions');
const { activeSessions, completionMet, normalizeCertificateConfig, attendanceBySession, groupTickets, TIMEZONE } = require('./sessions');
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const AUTO_ISSUE_DAYS = 14;
@@ -57,18 +57,25 @@ async function attendanceForCertificates(eventId) {
holder: { select: { id: true, name: true } },
user: { select: { id: true, name: true } },
usages: { select: { quantityRedeemed: true, sessionId: true } },
registrationOption: { select: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true } },
},
orderBy: { createdAt: 'asc' },
});
if (event.scheduleType === 'sessions') {
const active = activeSessions(event.sessions);
const activeIds = new Set(active.map(s => s.id));
const bySession = await attendanceBySession(eventId);
// A picked-session booking (one ticket per session) is one person: its first ticket
// carries the certificate, and it's measured against the sessions it booked.
return {
event,
rows: tickets.map(t => {
const m = bySession.get(t.id) || new Map();
const attended = [...m.entries()].filter(([sid, qty]) => activeIds.has(sid) && qty > 0).length;
return { ticket: t, attended, total: active.length };
rows: groupTickets(tickets).map(g => {
const booked = g.sessionIds ? new Set(g.sessionIds.filter(id => activeIds.has(id))) : activeIds;
const attendedIds = new Set();
for (const t of g.tickets) {
for (const [sid, qty] of (bySession.get(t.id) || new Map())) if (booked.has(sid) && qty > 0) attendedIds.add(sid);
}
return { ticket: g.tickets[0], attended: attendedIds.size, total: booked.size };
}),
};
}
+6 -1
View File
@@ -56,6 +56,7 @@ function judgeSyncedScan(ticket, qty) {
if (!ticket) return { redeem: 0, conflict: 'not_found' };
if (ticket.transferred) return { redeem: 0, conflict: 'transferred' };
if (ticket.noSession) return { redeem: 0, conflict: 'no_session' };
if (ticket.wrongSession) return { redeem: 0, conflict: 'wrong_session' };
if (ticket.cancelled) return { redeem: 0, conflict: 'cancelled' };
if (!ticket.inScope) return { redeem: 0, conflict: 'wrong_scope' };
if (ticket.remaining <= 0) return { redeem: 0, conflict: 'already_used' };
@@ -73,6 +74,7 @@ const CONFLICT_TEXT = {
partly_used: 'Fewer places were left than were checked in offline',
consent: 'Checked in without parental consent',
no_session: 'Scanned on a day (or at a time) with no session open for check-in',
wrong_session: 'Ticket is for a different session',
};
// ─── Database helpers ─────────────────────────────────────────────────────────
@@ -145,8 +147,10 @@ async function snapshotFor(lock) {
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
noSessionToday: schedule?.scheduleType === 'sessions' && !session,
tickets: tickets.map(t => {
// A picked-session ticket for another session has nothing left today.
const otherSession = !!(t.registrationOption.sessionId && t.registrationOption.sessionId !== session?.id);
const redeemed = schedule?.scheduleType === 'sessions'
? (session ? sessionsUtil.redeemedForSession(t.usages, session.id) : t.quantity || 1)
? (session && !otherSession ? sessionsUtil.redeemedForSession(t.usages, session.id) : t.quantity || 1)
: t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
return {
id: t.id,
@@ -200,6 +204,7 @@ async function syncScans(lock, scans) {
state = {
sessionId: session?.id || null,
noSession: hasSessions && !session,
wrongSession: !!(session && ticket.registrationOption.sessionId && ticket.registrationOption.sessionId !== session.id),
remaining: (ticket.quantity || 1) - redeemed,
cancelled: ticket.registrationOption.registration.status === 'cancelled',
inScope: !optionIds || optionIds.has(ticket.registrationOption.eventOptionId),
+5 -6
View File
@@ -3,7 +3,7 @@
* before" and "1 day before" each session, by email and/or WhatsApp.
*
* Rules:
* - Goes to everyone with a ticket for the event (the booker, and named attendees with
* - Goes to everyone with a ticket for the session (the booker, and named attendees with
* an account), skipping cancelled registrations. One message per person per session.
* - Each (session, offset) is sent once (SessionReminderLog). When several offsets are
* already due — e.g. reminders were switched on 2 h before a session — only the
@@ -21,10 +21,10 @@ function fmtWhen(d) {
return new Date(d).toLocaleString('en-GB', { timeZone: TIMEZONE, weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
}
/** People to remind for an event: Map userId → user. */
async function recipientsFor(eventId) {
/** People to remind about a session: whole-course tickets plus picked places on it. Map userId → user. */
async function recipientsFor(eventId, sessionId) {
const tickets = await prisma.ticket.findMany({
where: { eventId, registrationOption: { registration: { status: { not: 'cancelled' } } } },
where: { eventId, registrationOption: { registration: { status: { not: 'cancelled' } }, OR: [{ sessionId: null }, { sessionId }] } },
select: {
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
holder: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
@@ -84,7 +84,6 @@ async function runSessionReminderJob(now = new Date()) {
const offsets = (config.offsets || []).map(Number).filter(n => n > 0).sort((a, b) => a - b);
if (offsets.length === 0) continue;
const allSessions = await prisma.eventSession.findMany({ where: { eventId: event.id } });
let recipients = null;
for (const session of event.sessions) {
const minutesLeft = (new Date(session.startsAt) - now) / 60000;
const loggedOffsets = new Set(session.reminders.map(r => r.offset));
@@ -102,7 +101,7 @@ async function runSessionReminderJob(now = new Date()) {
}
}
if (!claimed.includes(due[0])) continue; // the closest one was claimed elsewhere
recipients = recipients || await recipientsFor(event.id);
const recipients = await recipientsFor(event.id, session.id);
let count = 0;
for (const user of recipients.values()) {
try {
+161 -8
View File
@@ -13,6 +13,12 @@
* session N starts. A registration deadline, if set, still wins when it's earlier.
* - Late pricing applies to main tickets only, for people registering after the first
* session has started: pro-rata on the sessions left (nearest R10), or a fixed price.
* - "Pick sessions" events (attendanceMode pick): options with sessionMode `pick` are
* bought per session — one registration line (and ticket) per session, priced per
* session, bookable until that session starts. `all` options cover every session and
* follow the whole-course rules above. A session's capacity counts picked places plus
* whole-course main tickets; a pick option's stock limit is places per session.
* Not available on youth (parental consent) events, and picked lines take no names.
*/
const prisma = require('../config/db');
@@ -70,6 +76,7 @@ function normalizeSessionsInput(list) {
endsAt,
location: s.location ? String(s.location).trim().slice(0, 300) || null : null,
cancelled: s.cancelled === true,
capacity: s.capacity === null || s.capacity === undefined || s.capacity === '' ? null : Math.max(1, parseInt(s.capacity, 10) || 1),
};
});
out.sort((a, b) => a.startsAt - b.startsAt);
@@ -102,7 +109,8 @@ function currentSession(sessions, now = new Date(), window = 'whole_day') {
}
/** When public registration closes for an event (Date). */
function registrationClosesAt(event, sessions) {
/** When whole-course (all-sessions) tickets stop selling (Date). */
function wholeCourseClosesAt(event, sessions) {
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
let closes = new Date(event.endDate);
if (event.scheduleType === 'sessions') {
@@ -115,6 +123,98 @@ function registrationClosesAt(event, sessions) {
return deadline && deadline < closes ? deadline : closes;
}
/**
* When public registration closes (Date). "Pick sessions" events stay open until the
* last session starts (each session can be booked until it starts).
*/
function registrationClosesAt(event, sessions) {
const whole = wholeCourseClosesAt(event, sessions);
if (event.scheduleType !== 'sessions' || event.attendanceMode !== 'pick') return whole;
const active = activeSessions(sessions);
if (active.length === 0) return whole;
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
const last = new Date(active[active.length - 1].startsAt);
const closes = last > whole ? last : whole;
return deadline && deadline < closes ? deadline : closes;
}
/** Whether an option is bought per session (only in "pick sessions" events). */
function isPickOption(event, option) {
return event?.scheduleType === 'sessions' && event?.attendanceMode === 'pick' && option?.sessionMode === 'pick';
}
/**
* Turns requested lines into registration lines: a pick option with sessionIds becomes
* one line per session (same quantity); other options stay as they are. Checks the
* sessions can still be booked: public — before the session starts; staff — until it ends.
* @param {object} event - with eventOptions and sessions
* @param {object[]} lines - [{ eventOptionId, variantId?, quantity, sessionIds? }]
*/
function expandPickLines(event, lines, { now = new Date(), staff = false } = {}) {
const out = [];
for (const line of lines || []) {
const option = (event.eventOptions || []).find(o => o.id === line.eventOptionId);
const pick = isPickOption(event, option);
const ids = Array.isArray(line.sessionIds) ? [...new Set(line.sessionIds.map(String))] : [];
if (!pick) {
if (ids.length) throw sessionError(`"${option?.name || 'This ticket'}" covers every session — no sessions to choose.`);
out.push({ ...line, sessionId: null });
continue;
}
if (event.requiresParentalConsent) throw sessionError('Choosing sessions isn\'t available on youth events.');
if (ids.length === 0) throw sessionError(`Choose the sessions for "${option.name}".`);
for (const id of ids) {
const session = (event.sessions || []).find(s => s.id === id);
if (!session || session.cancelledAt) throw sessionError(`A chosen session for "${option.name}" isn't available.`);
const cutoff = staff ? new Date(session.endsAt) : new Date(session.startsAt);
if (cutoff <= now) throw sessionError(`${sessionLabel(session, event.sessions)} has ${staff ? 'ended' : 'already started'} — it can't be booked any more.`);
const { sessionIds, ...rest } = line;
void sessionIds;
out.push({ ...rest, sessionId: id });
}
}
return out;
}
/**
* People booked per session: picked places on that session plus whole-course main
* tickets (which attend every session). Cancelled registrations don't count.
* @returns {Promise<Map<string, number>>}
*/
async function sessionBookings(eventId, db = prisma) {
const lines = await db.registrationOption.findMany({
where: { registration: { eventId, status: { not: 'cancelled' } }, eventOption: { isMainTicket: true } },
select: { quantity: true, sessionId: true },
});
const sessions = await db.eventSession.findMany({ where: { eventId, cancelledAt: null }, select: { id: true } });
const whole = lines.filter(l => !l.sessionId).reduce((s, l) => s + (l.quantity || 0), 0);
const out = new Map(sessions.map(s => [s.id, whole]));
for (const l of lines) if (l.sessionId && out.has(l.sessionId)) out.set(l.sessionId, out.get(l.sessionId) + (l.quantity || 0));
return out;
}
/**
* Checks requested lines fit each session's capacity. Whole-course main tickets count
* against every session; picked main tickets against their own.
*/
async function assertSessionCapacity(event, lines, db = prisma) {
const limited = activeSessions(event.sessions).filter(s => s.capacity > 0);
if (limited.length === 0) return;
const mains = new Set((event.eventOptions || []).filter(o => o.isMainTicket).map(o => o.id));
const wanted = new Map();
const whole = lines.filter(l => !l.sessionId && mains.has(l.eventOptionId)).reduce((s, l) => s + (Number(l.quantity) || 0), 0);
for (const s of limited) wanted.set(s.id, whole);
for (const l of lines) if (l.sessionId && mains.has(l.eventOptionId) && wanted.has(l.sessionId)) wanted.set(l.sessionId, wanted.get(l.sessionId) + (Number(l.quantity) || 0));
if ([...wanted.values()].every(n => n === 0)) return;
const booked = await sessionBookings(event.id, db);
for (const s of limited) {
const left = s.capacity - (booked.get(s.id) || 0);
if ((wanted.get(s.id) || 0) > left) {
throw sessionError(`${sessionLabel(s, event.sessions)} is ${left <= 0 ? 'full' : `nearly full — only ${left} place${left === 1 ? '' : 's'} left`}.`);
}
}
}
/**
* Late-joiner pricing state at `now`: active once the first session has started (for
* events with late joining and a late price). sessionsLeft counts sessions not yet started.
@@ -204,7 +304,7 @@ async function syncEventSessions(tx, eventId, input) {
for (let i = 0; i < wanted.length; i++) {
const w = wanted[i];
const data = { label: w.label, startsAt: w.startsAt, endsAt: w.endsAt, location: w.location, order: i };
const data = { label: w.label, startsAt: w.startsAt, endsAt: w.endsAt, location: w.location, capacity: w.capacity, order: i };
const old = w.id ? byId.get(w.id) : null;
if (old) {
const cancelledAt = w.cancelled ? (old.cancelledAt || new Date()) : null;
@@ -225,7 +325,7 @@ async function syncEventSessions(tx, eventId, input) {
}
const SCHEDULE_SELECT = {
id: true, endDate: true, registrationDeadline: true, scheduleType: true, checkInWindow: true,
id: true, endDate: true, registrationDeadline: true, scheduleType: true, attendanceMode: true, checkInWindow: true,
lateJoinEnabled: true, lateJoinUntilSessionId: true, latePricing: true, lateFixedPrice: true,
sessions: { orderBy: { startsAt: 'asc' } },
};
@@ -284,11 +384,22 @@ function scanSession(event, sessions, requestedId = null, now = new Date()) {
* (short) or "Wednesday 4 November 2026 (first of 5 sessions — one ticket for all)" (long).
* Returns null for single-date events (callers keep their own date format).
*/
function ticketDateText(event, style = 'short') {
function ticketDateText(event, style = 'short', session = null, sessions = null) {
if (event?.scheduleType !== 'sessions') return null;
const fmt = (d, opts) => new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, ...opts });
// A picked-session ticket is for its own session only.
if (session) {
const label = sessionLabel(session, event.sessions);
return style === 'long'
? `${fmt(session.startsAt, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })} (${label})`
: `${label} · ${fmt(session.startsAt, { day: '2-digit', month: 'short', year: 'numeric' })}`;
}
if (sessions && sessions.length > 1 && style === 'long') {
const sorted = [...sessions].sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt));
return `${sorted.map(s => fmt(s.startsAt, { day: 'numeric', month: 'short' })).join(', ')} (one ticket per session)`;
}
const active = activeSessions(event.sessions);
if (active.length === 0) return null;
const fmt = (d, opts) => new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, ...opts });
if (style === 'long') {
const first = fmt(active[0].startsAt, { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
return active.length === 1 ? first : `${first} (first of ${active.length} sessions — one ticket for all)`;
@@ -299,6 +410,46 @@ function ticketDateText(event, style = 'short') {
: `${active.length} sessions · ${fmt(active[0].startsAt, short)} – ${fmt(active[active.length - 1].startsAt, short)}`;
}
/**
* scanSession for a ticket: a picked-session ticket only checks in on its own session
* (whatever the scanner has selected).
*/
function ticketScanSession(event, sessions, pinnedSessionId, requestedId = null, now = new Date()) {
if (!pinnedSessionId) return scanSession(event, sessions, requestedId, now);
const pinned = (sessions || []).find(s => s.id === pinnedSessionId);
if (!pinned) return { session: null, error: 'This ticket\'s session no longer exists' };
const label = sessionLabel(pinned, sessions);
if (requestedId && requestedId !== pinnedSessionId) {
return { session: null, error: `This ticket is only for ${label} (${dayKey(pinned.startsAt)}), not the selected session` };
}
const found = scanSession(event, sessions, pinnedSessionId, now);
return found.session ? found : { session: null, error: `This ticket is for ${label} (${dayKey(pinned.startsAt)}) — ${found.error.charAt(0).toLowerCase()}${found.error.slice(1)}` };
}
/**
* Groups an event's tickets into people for the register and certificates: a
* picked-session booking has one ticket per session, so its tickets (same registration,
* option and variant) form one group. Every other ticket is its own group.
* Each ticket needs registrationOption: { registrationId, eventOptionId, variantId, sessionId }.
* @returns {{ key: string, tickets: object[], sessionIds: string[]|null }[]} sessionIds null = all sessions
*/
function groupTickets(tickets) {
const groups = new Map();
for (const t of tickets) {
const ro = t.registrationOption || {};
if (!ro.sessionId) {
groups.set(t.id, { key: t.id, tickets: [t], sessionIds: null });
continue;
}
const key = `pick:${ro.registrationId}:${ro.eventOptionId}:${ro.variantId || ''}`;
const g = groups.get(key) || { key, tickets: [], sessionIds: [] };
g.tickets.push(t);
g.sessionIds.push(ro.sessionId);
groups.set(key, g);
}
return [...groups.values()];
}
/** Places already checked in for one session, from a ticket's usages. */
function redeemedForSession(usages, sessionId) {
return (usages || []).filter(u => u.sessionId === sessionId).reduce((sum, u) => sum + (u.quantityRedeemed || 1), 0);
@@ -306,7 +457,8 @@ function redeemedForSession(usages, sessionId) {
/** Applies late pricing to a resolved option price ({ price, tierId }). */
async function withLatePrice(option, resolved) {
if (!option?.isMainTicket || !option.eventId || !(resolved.price > 0)) return resolved;
// Picked sessions are priced per session, so late joining doesn't change them.
if (!option?.isMainTicket || option.sessionMode === 'pick' || !option.eventId || !(resolved.price > 0)) return resolved;
const event = await eventSchedule(option.eventId);
if (!event || event.scheduleType !== 'sessions') return resolved;
const price = latePrice(resolved.price, lateInfo(event, event.sessions));
@@ -334,7 +486,8 @@ async function attendanceBySession(eventId) {
module.exports = {
TIMEZONE, MAX_SESSIONS, REMINDER_OFFSETS, CERTIFICATE_TEMPLATES, sessionError,
dayKey, activeSessions, scheduleBounds, normalizeSessionsInput, sessionAcceptsCheckIn, currentSession,
sessionLabel, scanSession, redeemedForSession, ticketDateText,
registrationClosesAt, lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig,
sessionLabel, scanSession, ticketScanSession, redeemedForSession, ticketDateText, groupTickets,
registrationClosesAt, wholeCourseClosesAt, isPickOption, expandPickLines, sessionBookings, assertSessionCapacity,
lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig,
syncEventSessions, eventSchedule, forgetSchedule, withLatePrice, attendanceBySession,
};
+6 -4
View File
@@ -3,18 +3,20 @@
*
* Taken = places on registrations that aren't cancelled + places held for a live
* waitlist offer (until it expires). An option's stock limit covers all its variants;
* a variant can also have its own limit. 0 means unlimited.
* a variant can also have its own limit. 0 means unlimited. For a "pick sessions"
* option the limit is per session (pass sessionId).
*/
const prisma = require('../config/db');
/**
* @param {{ eventOptionId: string, variantId?: string|null }} line - variantId given → that variant only
* @param {{ eventOptionId: string, variantId?: string|null, sessionId?: string|null }} line -
* variantId given → that variant only; sessionId given → that session's picked places only
* @param {{ excludeEntryId?: string, db?: object }} [opts] - excludeEntryId: ignore this
* waitlist offer's hold (the person claiming it)
*/
async function takenCount({ eventOptionId, variantId = null }, { excludeEntryId = null, db = prisma } = {}) {
const scope = variantId ? { variantId } : { eventOptionId };
async function takenCount({ eventOptionId, variantId = null, sessionId = null }, { excludeEntryId = null, db = prisma } = {}) {
const scope = { ...(variantId ? { variantId } : { eventOptionId }), ...(sessionId ? { sessionId } : {}) };
const [registered, held] = await Promise.all([
db.registrationOption.aggregate({
where: { ...scope, registration: { status: { not: 'cancelled' } } },
+2 -2
View File
@@ -48,11 +48,11 @@ const generateTicketsForRegistration = async (registrationId) => {
}
} catch (_) { /* form models unavailable — don't block */ }
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId) ──
// ── Step 1: Consolidate duplicate registrationOptions for the same (eventOptionId, variantId, session) ──
// Key on both fields so that different variants of the same option are never merged
const byOption = new Map();
for (const ro of registration.registrationOptions) {
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
const key = `${ro.eventOptionId}::${ro.variantId || ''}::${ro.sessionId || ''}`;
if (!byOption.has(key)) byOption.set(key, []);
byOption.get(key).push(ro);
}
+64
View File
@@ -14,6 +14,11 @@ const {
scanSession,
redeemedForSession,
sessionLabel,
isPickOption,
expandPickLines,
ticketScanSession,
groupTickets,
wholeCourseClosesAt,
} = require('../src/utils/sessions');
// Africa/Johannesburg is UTC+2 all year, so 16:30Z is 18:30 local.
@@ -268,3 +273,62 @@ describe('redeemedForSession / sessionLabel', () => {
expect(sessionLabel(list[2], list)).toBe('Session 2');
});
});
describe('pick sessions', () => {
const pickEvent = {
scheduleType: 'sessions', attendanceMode: 'pick', endDate: wed(32, 18, 30), registrationDeadline: null,
lateJoinEnabled: false, sessions: COURSE,
eventOptions: [{ id: 'drop', name: 'Drop-in', sessionMode: 'pick' }, { id: 'full', name: 'Full course', sessionMode: 'all' }],
};
test('isPickOption needs a pick-mode multi-day event and a pick option', () => {
expect(isPickOption(pickEvent, pickEvent.eventOptions[0])).toBe(true);
expect(isPickOption(pickEvent, pickEvent.eventOptions[1])).toBe(false);
expect(isPickOption({ ...pickEvent, attendanceMode: 'whole' }, pickEvent.eventOptions[0])).toBe(false);
});
test('a pick line becomes one line per session; others stay as they are', () => {
const out = expandPickLines(pickEvent, [
{ eventOptionId: 'drop', quantity: 2, sessionIds: ['s3', 's4', 's3'] },
{ eventOptionId: 'full', quantity: 1 },
], { now: wed(5) });
expect(out).toEqual([
{ eventOptionId: 'drop', quantity: 2, sessionId: 's3' },
{ eventOptionId: 'drop', quantity: 2, sessionId: 's4' },
{ eventOptionId: 'full', quantity: 1, sessionId: null },
]);
});
test('sessions must be chosen, upcoming, and not on whole-course tickets', () => {
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1 }])).toThrow(/Choose the sessions/);
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: wed(5) })).toThrow(/already started/);
expect(() => expandPickLines(pickEvent, [{ eventOptionId: 'full', quantity: 1, sessionIds: ['s3'] }])).toThrow(/covers every session/);
expect(() => expandPickLines({ ...pickEvent, requiresParentalConsent: true }, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s3'] }], { now: wed(5) })).toThrow(/youth/);
});
test('staff can still book a session that is in progress', () => {
const during = new Date(wed(4).getTime() + 30 * 60000);
expect(expandPickLines(pickEvent, [{ eventOptionId: 'drop', quantity: 1, sessionIds: ['s1'] }], { now: during, staff: true })).toHaveLength(1);
});
test('registration stays open until the last session; whole-course tickets close at the first', () => {
expect(registrationClosesAt(pickEvent, COURSE)).toEqual(wed(32));
expect(wholeCourseClosesAt(pickEvent, COURSE)).toEqual(wed(4));
});
test('a picked ticket only checks in on its own session', () => {
const event = { checkInWindow: 'whole_day' };
expect(ticketScanSession(event, COURSE, 's3', null, new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3');
expect(ticketScanSession(event, COURSE, 's3', null, new Date('2026-11-11T09:00:00Z')).error).toMatch(/This ticket is for Session 3 \(2026-11-18\)/);
expect(ticketScanSession(event, COURSE, 's3', 's2', new Date('2026-11-18T09:00:00Z')).error).toMatch(/only for Session 3/);
expect(ticketScanSession(event, COURSE, null, null, new Date('2026-11-11T09:00:00Z')).session.id).toBe('s2');
});
test('groupTickets puts one booking\'s picked tickets together', () => {
const t = (id, sessionId, registrationId = 'r1', eventOptionId = 'drop') => ({ id, registrationOption: { registrationId, eventOptionId, variantId: null, sessionId } });
const groups = groupTickets([t('a', 's1'), t('b', null, 'r1', 'full'), t('c', 's2'), t('d', 's1', 'r2')]);
expect(groups.map(g => g.tickets.map(x => x.id))).toEqual([['a', 'c'], ['b'], ['d']]);
expect(groups[0].sessionIds).toEqual(['s1', 's2']);
expect(groups[1].sessionIds).toBeNull();
});
});
+506
View File
@@ -0,0 +1,506 @@
# Hope Events — New Features
Planned features for the next round of development. Each section covers what
the feature does, who uses it, how it fits the existing platform, and the edge
cases to handle. See [FEATURES.md](./FEATURES.md) for what already exists.
---
## Contents
1. [Coupon codes](#1-coupon-codes)
2. [Waitlist](#2-waitlist)
3. [Group tickets](#3-group-tickets)
4. [Offline scanning](#4-offline-scanning)
5. [Parental consent (youth events)](#5-parental-consent-youth-events)
6. [Sponsor a spot](#6-sponsor-a-spot)
7. [Ticket transfer](#7-ticket-transfer)
8. [Post-event feedback form](#8-post-event-feedback-form)
9. [Group / room allocation](#9-group--room-allocation)
10. [Registration funnel stats](#10-registration-funnel-stats)
11. [2FA for Admins and Supervisors](#11-2fa-for-admins-and-supervisors)
12. [Admin "impersonate user"](#12-admin-impersonate-user)
13. [Suggested build order](#suggested-build-order)
---
## 1. Coupon codes
Discount codes applied at registration. The main use case is **staff
registering for events** at a reduced or free rate, but codes also work for
promos, leaders, and volunteers.
**Who:** Supervisors create and manage codes. Users enter them at checkout.
Supervisors can also apply them in Manual registration, At the door and the
kiosk.
### Code settings
| Setting | Options |
|---------|---------|
| Code | Unique text, case-insensitive (e.g. `STAFF50`) |
| Discount type | Percentage · Fixed amount · Comp (100% free) |
| Scope | Whole event · Specific ticket option(s) · Specific variant(s) |
| Events | One event, several events, or all events |
| Valid from / until | Optional date window |
| Max total uses | Optional |
| Max uses per user | Optional (default 1) |
| Min quantity | Optional (e.g. 4+ tickets) |
| Restricted to | Anyone · Specific users · Roles (e.g. Staff and above) · Email domain |
| Stack with early-bird | Yes (apply on top of early-bird price) / No (best price wins) |
| Active | On / off toggle |
### How it works
- The user enters a code on the registration page. It is validated server-side
and the discount shows as its own line before payment.
- The discount is stored as a **separate discount line on the registration**,
with its value **locked at apply time**, the same way early-bird tranches
are locked. Pricing refresh never re-prices an applied coupon, even if the
code later expires or is disabled.
- **Restricted codes** (by user, role or domain) need the user to be logged
in. Guest registration can't use them. This stops staff codes leaking in
group chats.
- **Role-based staff pricing (optional):** a ticket option can also carry a
"Staff price" that applies automatically for Staff+ users without a code.
### Edge cases
- **Edit options:** percentage codes recalculate over the new item total.
Fixed-amount codes stay fixed. Comp codes cover only in-scope options.
- **Cancellation / refund:** a cancelled registration releases its use count.
- **Discount can't go below R0.** A comp code on a fully covered registration
marks it Paid and generates tickets.
- **Concurrency:** check use limits inside a transaction so the last use
can't be claimed twice.
### Plumbing
- Invoices and receipts show the code and discount line.
- **New report: Discounts.** Uses per code, total value given away, per event.
- Finance and profit reports subtract discounts from expected revenue.
- **Audit log:** code created, edited, disabled, and manually applied by staff.
### Data model (sketch)
```prisma
model Coupon {
id String @id @default(cuid())
code String @unique
type CouponType // PERCENT | FIXED | COMP
value Decimal?
stackEarlyBird Boolean @default(false)
validFrom DateTime?
validUntil DateTime?
maxUses Int?
maxUsesPerUser Int? @default(1)
minQuantity Int?
restrictRoles Role[]
restrictDomain String?
active Boolean @default(true)
events CouponEvent[]
options CouponOption[]
allowedUsers CouponUser[]
redemptions CouponRedemption[]
createdById String
createdAt DateTime @default(now())
}
model CouponRedemption {
id String @id @default(cuid())
couponId String
registrationId String
userId String
discountAmount Decimal // locked at apply time
appliedById String? // staff member if applied manually
createdAt DateTime @default(now())
releasedAt DateTime? // set on cancellation
}
```
---
## 2. Waitlist
When an event or ticket option is sold out, users can join a waitlist instead
of hitting a dead end.
**Who:** Users join. Supervisors view and manage the queue.
### How it works
- Sold-out options show a **"Join waitlist"** button instead of "Sold Out".
- The user picks an option, variant and quantity. Login is required if the
event requires login. Otherwise a guest account is created as usual.
- When stock frees up (a cancellation, a refund, or a supervisor raising the
stock limit), the **next person in line** gets an email/WhatsApp with a
**claim link**.
- The claim link **reserves the stock for a set time** (default 24 h, set per
event). If it isn't used in time, the offer moves to the next person.
- A claimed offer goes through normal registration and payment, with
early-bird and coupon rules applied as usual.
### Supervisor tools
- A waitlist tab per event shows position, person, option, quantity, joined
date and offer status.
- Supervisors can manually offer a spot to someone, reorder the queue, or
remove an entry.
- Supervisors can toggle the waitlist on or off per event.
### Edge cases
- Reserved-but-unclaimed stock counts as sold until the offer expires, so the
public site doesn't oversell.
- If an offer is for quantity 3 and only 2 free up, offer the 2 with a note,
or skip to the next person who fits. This is configurable per event.
- Users can leave the waitlist from their dashboard.
- Add the waitlist to the Daily summary (count per event).
---
## 3. Group tickets
One payer registers **several named attendees**, each with their own ticket
and their own form response. For example, a parent registers three kids for
camp.
**This is optional.** The payer can still choose to receive all tickets
themselves.
### How it works
- On the registration page, a toggle reads **"Registering for other people?"**
- For each attendee: name, plus optional email/phone, plus the event form if
there is one.
- **Ticket delivery choice per registration:**
- **Send all tickets to me** (default), so the payer gets every ticket.
- **Send each person their own ticket**, using their email/WhatsApp. If an
attendee has no contact details, their ticket falls back to the payer.
- Tickets are **individual, named tickets** rather than a quantity ticket, so
each one can be checked in, transferred or allocated to a room/group
separately.
- The payer stays the account owner for billing, invoices and payments.
### Edge cases
- Works alongside the existing quantity tickets, since an event can allow
either.
- If an attendee's email/phone matches an existing account, link the ticket
to that account (with the same duplicate-account protection as today).
- Attendee forms: each attendee has their own response. The payer can fill
them in, or each attendee can get a link to fill in their own.
- Feeds into parental consent (one consent request per minor attendee) and
group/room allocation.
---
## 4. Offline scanning
Lets the scanner keep working when venue Wi-Fi or mobile data drops.
> ⚠️ **Rules: offline mode is per section, and only one device can scan a
> section offline at a time.**
> While offline, devices can't see each other's scans. If two devices scanned
> the same section offline, the same ticket could be redeemed twice. Locking
> each section to a single device removes that risk without needing conflict
> resolution.
### How it works
1. **Before going offline (while online):** a staff member picks an event and
section and taps **"Enable offline mode"**.
2. The server **locks that section to this device** and records the device ID
and the staff member. Other devices see "Offline-locked by [name] on [device]"
and can't scan that section, online or offline, until it's released.
3. The device **downloads a snapshot** of every ticket valid for that section
(ticket ID, holder name, type, quantity remaining) into IndexedDB.
4. **While offline:** scans validate against the local snapshot and update
remaining quantities locally. Every scan is queued with a timestamp.
5. **When back online:** queued scans sync to the server automatically. Any
conflicts (for example, a ticket transferred or cancelled while offline)
show in a **sync report** for a supervisor to review.
6. Staff tap **"Release section"** to end offline mode and unlock the section.
### Rules and edge cases
- One section can only be locked to one device at a time. Other sections of
the same event can be locked to other devices.
- A supervisor can **force-release** a lock if a phone dies or goes missing.
Unsynced scans on that device are lost unless the device reconnects later,
in which case they sync and are flagged.
- **Snapshot age:** show "Ticket list downloaded at 17:42". Tickets bought
after that time won't be on the device, and the scanner shows "Not found in
offline list. Check at the door desk."
- The snapshot is stored only for the lock's duration and cleared on release.
- Check-in confirmation messages to holders are sent after sync, not
instantly.
- **Audit log:** lock, release, force-release, and sync with scan count.
- Built as a **PWA** (service worker caches the scanner page and assets).
---
## 5. Parental consent (youth events)
For youth events, a parent or guardian must give consent before an attendee
under 18 can attend. The parent gets an **email and a WhatsApp** with a link
to review and give consent.
### Event setup
- New event setting: **"Youth event: requires parental consent"**.
- Supervisors write the **consent text** (indemnity, activities, photo
permission, transport, medical treatment authorisation) in the event wizard.
- Optional extra sections on the consent page: **medical info** (allergies,
medication, conditions, medical aid), **emergency contact**, and **photo
consent** (Yes/No, recorded separately).
### How it works
1. During registration, each attendee gives their **date of birth**. If they
are under 18, **parent/guardian name, email and phone** are required.
2. On registration, the parent gets an **email and a WhatsApp** (always both,
regardless of anyone's notification preference) with a unique, secure
consent link.
3. The consent page shows the event details, attendee name and consent text.
The parent fills in any medical/emergency fields, **types their full name**
as a signature, ticks "I am the parent/legal guardian", and taps
**Give consent** (or **Decline**).
4. The system records the timestamp, IP address, device, typed name and the
**version of the consent text** they agreed to.
5. The registrant and the event's notify recipients get a confirmation.
### Rules
- **Tickets are generated but marked "Consent pending"** until consent is
given. Scanners show a clear warning and block check-in for that ticket
(supervisors can override, with an audit log entry).
- **Reminders:** automatic reminder email/WhatsApp after 2 days, then every
3 days until the event, or until consent is given or declined.
- **Declined consent:** notifies the registrant and supervisors. The
registration stays but can't be checked in. A supervisor handles the refund.
- **Consent links expire** at the event's end date, and can be resent from
the registration detail screen.
- If the consent text is edited after some parents have consented, they keep
their record of the old version. Supervisors can choose to re-request
consent.
### Supervisor tools
- **Consent status column** on registrations and At the door (Pending /
Given / Declined).
- **New reports:** Consent status per event, and **Medical & emergency list**
(printable, for camp leaders). Access to the medical list is restricted to
Supervisor+ and logged.
- Bulk "Resend consent request" to everyone still pending.
### Privacy
Under POPIA, processing a child's personal information needs the consent of a
competent person (parent/guardian). Medical data is special personal
information. Store it encrypted, restrict access, include it in the existing
anonymise/delete flows, and delete it a set period after the event.
---
## 6. Sponsor a spot
A public donation flow linked to a specific event, so people can fund a spot
for someone who can't afford it (bursaries).
### How it works
- A new event setting: **"Allow sponsorships"**. This adds a **"Sponsor a
spot"** button on the event page.
- Sponsors choose: **one full spot** (the main ticket price), **multiple
spots**, or **a custom amount**. Payment goes through Yoco as usual.
- A sponsor can be anonymous, or add a name and message.
- The money is recorded as a **donation tagged to the event**, and lands in a
**sponsorship pool** for that event.
- Supervisors assign pool funds to registrations using the existing donation
assign/unassign flow, fully or partly.
- An optional public progress bar shows "12 of 20 spots sponsored".
### Plumbing
- Sponsors get a receipt. They can optionally get a thank-you after the event
through Automations.
- The Donations breakdown report gains a sponsorship pool column (received,
assigned, remaining) per event.
- Recipients' identities are never shown to sponsors.
---
## 7. Ticket transfer
Users can hand their ticket to someone else before the event.
### How it works
- From My Events, a user taps **Transfer** on a ticket, and enters the new
holder's name and email/phone.
- The new holder gets the ticket by email/WhatsApp. If they don't have an
account, an inactive one is created with an activation link (same as guest
registration).
- The **old QR code is invalidated** and a new one is issued, so screenshots
of the old ticket won't scan.
- The original payer stays responsible for the registration (billing doesn't
move).
### Rules
- Allowed only before the event starts, and only for unredeemed tickets
(partially redeemed quantity tickets can't be transferred).
- Can be disabled per event. Supervisors can always transfer.
- If the event needs a form or parental consent, the new holder must complete
their own.
- **Audit log:** transfer from, to, by whom and when. Offline scanning sync
flags any scan of a transferred ticket's old code.
---
## 8. Post-event feedback form
An automatic survey sent after the event.
### How it works
- In the event wizard, a new **Feedback** step reuses the form builder, plus
new field types: **Rating (1–5 stars)** and **Multiple choice**.
- Supervisors pick a **send time** (e.g. the morning after the event ends).
It's sent by the existing scheduled job to everyone who **checked in**, or
to all registrants, as chosen.
- Responses can be anonymous or named (per event setting).
- One automatic reminder after 3 days for anyone who hasn't responded.
### Reporting
- **New report: Feedback summary.** Response rate, average rating per
question, multiple-choice breakdown (bar charts), and a list of text
answers. Exports to PDF/Excel like other reports.
- Ratings can be compared across events (e.g. Camp 2025 vs Camp 2026).
---
## 9. Group / room allocation
Assign attendees to cabins, rooms, small groups, teams or buses. Built for
camps and lockdowns.
### How it works
- Per event, supervisors create **allocation sets**, e.g. "Rooms", "Small
groups", "Buses". An event can have several sets.
- Each set has **groups** with an optional **capacity**, an optional
**gender restriction** and an optional **leader** (a staff user).
- **Drag-and-drop** board: unassigned attendees on the left, groups on the
right, with capacity counters.
- **Auto-allocate:** fill groups evenly, respecting capacity and gender, and
optionally keeping registrations together (siblings) or splitting them
(friends).
- Works per attendee, so it relies on group tickets (named attendees).
### Outputs
- Printable lists per group (with leader), and a master list.
- Optionally show the group/room on the attendee's ticket and in My Events.
- Group leaders (staff) can see their group's list, including medical
info if they're allowed to.
- Bulk message by group ("Bus 2 leaves at 07:30").
---
## 10. Registration funnel stats
Shows where people drop off between seeing an event and paying.
### Funnel stages (per event)
1. **Event page views** (unique visitors)
2. **Registration started** (opened the registration page)
3. **Registration submitted**
4. **Paid** (fully paid or free)
### How it works
- Lightweight first-party tracking (no third-party analytics). Log page views
and stage events with an anonymous session ID. No personal data until the
user logs in or registers.
- **New report: Registration funnel.** Count and conversion % at each stage,
over a date range, with a daily trend chart.
- Optional breakdowns: by traffic source (UTM tags or short link alias, e.g.
`/camp-2025` vs direct), by device (mobile/desktop), and by ticket option.
- A dashboard tile shows conversion % for active events.
### Privacy
Respect cookie consent. Aggregate raw page-view data after 90 days.
---
## 11. 2FA for Admins and Supervisors
Two-factor authentication, **required** for Admin and Supervisor accounts.
### How it works
- **Method:** authenticator app (TOTP, e.g. Google Authenticator, Microsoft
Authenticator). WhatsApp code can be offered as a backup method since WAWP
is already set up.
- **Enrolment:** on the next login after being given the Supervisor/Admin
role, the user must set up 2FA by scanning a QR code and confirming a code.
- **Recovery codes:** 10 one-time codes shown once at setup, downloadable.
- **Remember this device** for 30 days (optional, per login).
- Optional for Staff and User roles, from Profile & Security.
### Admin controls
- Admins can **reset another user's 2FA** (e.g. a lost phone). This revokes
their sessions and forces re-enrolment.
- Security event log entries: 2FA enabled, disabled, reset, recovery code
used, failed code attempts.
- Failed 2FA attempts count toward the existing login lockout.
- The setup wizard enrols the first admin in 2FA.
---
## 12. Admin "impersonate user"
Lets an Admin see the site exactly as a specific user sees it, to debug
problems ("my ticket isn't showing").
### How it works
- From Manage users, an Admin taps **Impersonate** on a user and must enter a
**reason** (required).
- A short-lived session is created (max 30 minutes) that acts as the user.
- A **bright banner on every page**: "Viewing as [Name]. [End impersonation]".
### Safety rules
- **Read-only by default.** Payments, refunds, cancellations, password/email
changes, deleting data, giving consent and sending messages are blocked
while impersonating.
- Can't impersonate another Admin.
- Requires 2FA to be active on the admin's account.
- **Audit log:** start, end, reason, and every page/action during the
session.
- Optional: notify the user that an admin viewed their account (an
organisation-level setting).
---
## Suggested build order
| Phase | Features | Why |
|-------|----------|-----|
| 1 | 2FA, Coupon codes | Security first. Coupons are self-contained and give an immediate win for staff. |
| 2 | Group tickets | Foundation for consent, transfer and room allocation (named attendees). |
| 3 | Parental consent, Waitlist | The biggest wins for youth events. |
| 4 | Offline scanning, Ticket transfer | Door-day reliability. Transfer must exist before offline sync handles it. |
| 5 | Group/room allocation, Sponsor a spot | Camp-specific and bursary support. |
| 6 | Feedback form, Funnel stats, Impersonate user | Insights and support tooling. |
Each phase should add matching help content (`frontend/src/content/help/`),
audit log entries, a CHANGELOG entry, and updates to FEATURES.md and
SETTINGS.md.
+90
View File
@@ -0,0 +1,90 @@
# Handover — where things stand and how to carry on
Last updated: 2026-10-23, after **1.35.0**. 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).
## Done so far (road to 2.0)
| Version | What |
|---------|------|
| 1.32.0 | Early-bird ribbon on event cards |
| 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) |
## Next up
1. **1.36.0 — payment provider interface.** Move Yoco behind a `PaymentProvider`
interface (`createCheckout`, `handleWebhook`, `getTransaction`, `refund`,
`listTransactions`) with no behaviour change; generalise `YocoTransaction` to a
provider transaction table; Site settings → Payments with an Enabled toggle per
provider and display order. Decisions: fees are **never** deducted or passed on
(people handle fees at cashup); minimum online payment **R15** for all providers.
2. **1.37.0 PayFast → 1.38.0 SnapScan → 1.39.0 Zapper** (one release each). Get the
current API documentation links from Joshua before building each one, and don't
guess at an API — sandbox/test credentials needed for SnapScan and Zapper.
3. **1.40.0–1.41.0 — POPIA toolkit** (spec §3 in the roadmap).
4. **1.42.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
`joshua/hope-events-hub`).
## Working on this repo
- **Release routine:** branch `feat/<name>` → commit → bump with
`npm version X.Y.Z --no-git-tag-version` in `.`, `backend` and `frontend` → add the
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → `git merge --no-ff`
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
version per feature.
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language.
- **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md,
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and
in-app help in `frontend/src/content/help/` (register new pages in `registry.ts`).
- **Prisma migrations:** `npx prisma migrate dev --create-only --name x` against a local
DB. Prisma 7 keeps adding an unrelated `_EventNotifyRecipients` primary-key/index
change — delete those two blocks from the generated SQL. It also rewrites the comment
in `migration_lock.toml`; `git checkout` that file back. New `AdminAuditAction` values
need a migration.
- **Server Node version:** the deployment notes install Node 20, so dependencies must
support Node 20 (that's why pg-boss is pinned to 10.x).
- **Pushing:** pushing from some shells fails with "Failed to authenticate user" (a stale
saved credential); pushing from your own terminal works.
## Tests
- Backend unit tests: `cd backend && npm test` (Jest).
- End-to-end: `npm run test:e2e` from the repo root (first time: `npm install` and
`npx playwright install chromium`). They start their own backend (5100) and
frontend (3100) against `<your DATABASE_URL database>_e2e`, which is dropped and
re-seeded every run (`e2e/support/prepare-db.js`). The DB user must be able to
create databases. ~5 minutes in dev mode.
Locally, Playwright reuses servers already running on 5100/3100. If the run times out
waiting for the frontend (the home page returns 500 until the e2e backend is up), start
them yourself — `node e2e/support/prepare-db.js`, then the backend from `backend/`
with `PORT=5100`, the e2e `DATABASE_URL`, `JOBS_ENABLED=false`,
`TWO_FACTOR_REQUIRED_ROLES=false`, and `npx next dev -p 3100` from `frontend/` with
`NEXT_DIST_DIR=.next-e2e NEXT_PUBLIC_API_URL=http://localhost:5100` — then run
`npx playwright test`. Don't edit frontend files while it runs.
- **Careful with ad-hoc scripts against a dev/test database that has SMTP set up:**
registrations and check-ins send real email/WhatsApp. Use made-up `@test.local`
people only.
- CI: `.gitea/workflows/ci.yml` runs everything once a Gitea Actions runner is
registered (none yet). Tag deploys need the `DEPLOY_HOST`, `DEPLOY_USER`,
`DEPLOY_SSH_KEY` (+ optional `DEPLOY_PATH`) secrets.
## Where the multi-day code lives
- `backend/src/utils/sessions.js` — all session rules (dates, check-in window, late
pricing, pick sessions, capacity, grouping); unit tests in `backend/tests/sessions.test.js`.
- `backend/src/utils/certificates.js`, `sessionReminders.js`,
`controllers/attendanceController.js` (register + certificates API).
- `backend/src/jobs/index.js` — the background job registry.
- Frontend: `src/lib/sessions.ts`, `src/components/events/ScheduleEditor.tsx`,
`SessionPicker.tsx`, `SessionSchedule.tsx`, `src/app/dashboard/supervisor/attendance/`.
## 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.
- Ticket scanning by camera isn't covered by the e2e tests (headless browsers have no
camera); scans are tested through the API.
+774
View File
@@ -0,0 +1,774 @@
# Hope Events — Road to 2.0.0
Planned features and platform work to take Hope Events from 1.32.x to 2.0.0.
Each section covers what the feature does, who uses it, how it fits the
existing platform, and the edge cases to handle. See [FEATURES.md](../FEATURES.md)
for what already exists, and [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md) for the
previous round (1.12.0 – 1.23.0). Where to pick up: [HANDOVER.md](./HANDOVER.md).
**Theme for 2.0:** turn a feature-rich church app into a product that can be
sold — trustworthy (POPIA), flexible (multi-day events, several payment
providers), and maintainable (tests, CI, typed backend, versioned API).
**Status (2026-10-23):** all features in this document are approved. Shipped
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
moved behind it), then PayFast, SnapScan, Zapper.**
**Repositories:**
| Repo | What |
|------|------|
| `joshua/hope-events` (this repo) | The church app. Everything in this document except the Hub app itself — including the Hub *client* (licence step, "Connect to Hub", heartbeat, licence checks) |
| `joshua/hope-events-hub` (new) | The Hub app (§13): its own versions, CHANGELOG, releases and server |
---
## Contents
1. [Multi-day events (sessions)](#1-multi-day-events-sessions) ← **first**
2. [Payment providers: PayFast, SnapScan, Zapper](#2-payment-providers-payfast-snapscan-zapper)
3. [POPIA toolkit](#3-popia-toolkit)
4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels)
5. [Volunteer / serving rosters](#5-volunteer--serving-rosters)
6. [Event series and cloning](#6-event-series-and-cloning)
7. [Apple and Google Wallet tickets](#7-apple-and-google-wallet-tickets)
8. [Outbound webhooks](#8-outbound-webhooks)
9. [Admin dashboard, command palette, bulk actions](#9-admin-dashboard-command-palette-bulk-actions)
10. [Languages (Afrikaans first)](#10-languages-afrikaans-first)
11. [Accessibility pass (WCAG 2.2 AA)](#11-accessibility-pass-wcag-22-aa)
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
13. [Central management platform (Hope Events Hub)](#13-central-management-platform-hope-events-hub)
14. [Foundations (platform work)](#14-foundations-platform-work)
15. [Suggested build order](#suggested-build-order)
16. [Decisions](#decisions-2026-10-22)
---
## 1. Multi-day events (sessions)
One event that runs over several dates, e.g. **"Alpha Course — every Wednesday
18:30–20:30 for 5 weeks"**, a 3-day conference, or a camp with an evening
session each day. People register and pay **once**, get **one ticket**, and are
checked in **per session**.
> Not the same as [Event series](#6-event-series-and-cloning). A series groups
> *separate* events (Camp 2026, Camp 2027) that each have their own
> registrations. Sessions are dates *inside one* event.
**Who:** Supervisors set up the schedule. Users see it on the event page, in
their tickets and in their calendar. Staff check in per session.
### Event setup
New **Schedule** section on the event form:
| Setting | Options |
|---------|---------|
| Schedule type | Single date (today's behaviour) · Multiple sessions |
| Pattern builder | Repeat **daily / weekly / every N weeks** on chosen weekday(s), start date, time from–to, **number of sessions** or **until date** |
| Generated sessions | Editable list: change date/time/location/label of any one session, delete, or add an extra one-off date |
| Session label | Optional, e.g. "Week 1 — Is there more to life?" (defaults to "Session 1, 2, …") |
| Session location | Optional; falls back to the event location |
| Attendance mode | **Whole course** (one registration covers all sessions) · **Pick sessions** (see phase B below) |
| Late joining | Per event: Off (registration closes when the first session starts) · Allow until the start of session N |
| Late-joiner price | Per event, only when late joining is on: Full price · **Pro-rata** (price × remaining sessions ÷ total sessions, rounded to the **nearest R10**, e.g. R183 → R180, R185 → R190) · Fixed late price |
| Check-in window | Per event: **Whole day** (default — any time on the session's date, in case someone was let in without being scanned) · Session time only (2 h before start until the session ends) |
| Session reminders | Per event, **off by default**: when on, choose timing (e.g. 3 h before, the day before at 18:00, or both) and channels (email / WhatsApp) |
| Certificates | Per event, **off by default**: when on, choose a template and the completion rule (see [Certificates](#certificates)) |
The pattern builder is only a helper: it generates rows, then the session list
is the source of truth. Typing "every Wednesday for 5 weeks" and then skipping
a public holiday is just deleting one row.
### How it works
- `Event.startDate` / `endDate` **stay** and are kept in sync automatically
(first session start, last session end). Everything that already reads them —
consent age checks, transfer cut-off, early-bird, feedback timing, ICS,
listings, sitemap — keeps working unchanged. Only places that need
per-session behaviour are updated.
- **Public event page:** shows the full schedule, with past sessions greyed out
and a "Next session" highlight. Event cards show "5 sessions · Wed 4 Nov –
Wed 2 Dec".
- **Event listings:** a multi-session event stays "upcoming / running" until
its **last** session ends, and sorts by its **next** session.
- **Tickets (PDF, email, WhatsApp):** list all session dates. One QR code.
- **Calendar (ICS):** one VEVENT per session (not an RRULE, so edited or
skipped sessions are exact).
- **Check-in:** the scanner auto-selects today's session (on a day with more
than one session, the one happening now or next), with a dropdown to
override. Whether a scan is accepted depends on the event's **check-in
window** (whole day or session time only). A ticket can be checked in
**once per session** (per person for group tickets). Scanning again in the
same session shows "Already checked in for Session 3 at 18:42".
- **Manual check-in:** supervisors can tick someone as present for a session
from the attendance register afterwards (e.g. let in without a scan), logged
in the audit log with who marked it.
- **Reminders:** off by default; when switched on for the event, a reminder
goes out before **each** session (email/WhatsApp, via the scheduled-message
worker), e.g. "See you tonight at 18:30".
- **Late joiners:** when late joining is on, the registration page shows which
sessions are left and the late price ("3 of 5 sessions left — R180").
- **Changes:** moving or cancelling a session after people registered prompts
the supervisor to notify registrants (pre-filled broadcast), and updates the
ICS/tickets.
### Phase B: pick sessions (drop-in)
Second step, released separately. Lets people register for **some** sessions,
e.g. a conference where each day is sold separately or a weekly drop-in class.
- Ticket options can be scoped to sessions: "Day pass (pick days)" priced per
session, "Full conference pass" covering all.
- Optional **capacity per session** on top of the option stock limit.
- The ticket stores which sessions it covers; scanning on a session it doesn't
cover shows "Not registered for this session" (with "Add & charge" at the
door for supervisors).
- Waitlist works per session when sessions have their own capacity.
### Reporting
- **Attendance register:** grid of attendees × sessions with ticks, totals per
session, attendance % per person. Export to Excel and PDF.
- **Completion filter:** "attended at least 4 of 5" — useful for certificates
and for follow-up ("missed the last 2 weeks").
- Daily summary email includes "Sessions today".
- Funnel stats unchanged (they are per event).
### Certificates
Optional completion certificates (PDF), chosen per event.
- **3–4 built-in templates**, e.g. *Classic* (border, serif), *Modern* (clean,
accent colour bar), *Church* (logo-led, scripture line), *Kids* (playful,
for children's programmes). All use the site logo and brand colour from Site
settings.
- **Event settings:** Certificates on/off · Template · Title ("Certificate of
Completion") · Body text with placeholders (`{name}`, `{event}`,
`{sessionsAttended}`, `{totalSessions}`, `{endDate}`) · Signature name and
role (optional uploaded signature image) · **Completion rule**: attended at
least N sessions or N % (default: all sessions).
- **Preview** button in the event form renders a sample PDF.
- **Who gets one:** attendees meeting the completion rule after the last
session. Supervisors can issue/withdraw one manually (audit logged).
- **Delivery:** download from My Events, bulk download (ZIP) from the
attendance register, and optional email/WhatsApp send.
- Each certificate has a short verification code + QR linking to a public
"certificate valid" page (name, event, date only).
- Generated with `pdfkit`, like tickets and receipts (`utils/pdfDocs.js`).
- Also usable on single-date events (completion = checked in).
### Edge cases
| Case | Behaviour |
|------|-----------|
| Delete a session that has check-ins | Blocked; offer **Cancel session** instead (kept for history, hidden from schedule, excluded from attendance %) |
| Switch Multiple → Single after registrations | Allowed only if no session check-ins exist |
| Ticket transfer | Allowed until the **first** session starts (current rule). Optional later: allow mid-course transfers with check-in history kept on the original holder |
| Parental consent | Age is measured at the first session (current `startDate` rule) |
| Medical details deletion | Runs after the **last** session (end date), as today |
| Feedback form | Sent after the last session |
| Cashup | Still per event. At-the-door cash per session is captured with the session on the payment so reports can split it |
| Offline scanning | Session list and per-session usages are part of the offline lock bundle; sync keeps the session id per scan |
| Timezones | Sessions stored as UTC instants, same as `Event.startDate` today (see `icsUtils.js`) |
| Session overlaps / out of order | Allowed (conferences have parallel tracks later), list always sorted by start |
| Kiosk / At the door | Shows the current session; check-in counts against it |
### Data model (sketch)
```prisma
enum ScheduleType { single sessions }
enum AttendanceMode { whole pick }
enum LatePricing { full prorata fixed }
enum CheckInWindow { whole_day session_only }
model Event {
// ...existing fields
scheduleType ScheduleType @default(single)
attendanceMode AttendanceMode @default(whole)
lateJoinEnabled Boolean @default(false)
lateJoinUntilSessionId String?
latePricing LatePricing @default(full)
lateFixedPrice Float?
checkInWindow CheckInWindow @default(whole_day)
sessionReminders Json? // null = off; { offsets: [...], channels: [...] }
certificatesEnabled Boolean @default(false)
certificateConfig Json? // template, title, body, signature, completion rule
sessions EventSession[]
certificates Certificate[]
}
model Certificate {
id String @id @default(uuid())
eventId String
attendeeId String?
userId String?
name String
code String @unique // verification code
manual Boolean @default(false)
issuedAt DateTime @default(now())
revokedAt DateTime?
event Event @relation(fields: [eventId], references: [id], onDelete: Restrict)
@@index([eventId])
}
model EventSession {
id String @id @default(uuid())
eventId String
label String?
startsAt DateTime
endsAt DateTime
location String?
capacity Int? // phase B
cancelledAt DateTime?
order Int @default(0)
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
usages TicketUsage[]
ticketSessions TicketSession[]
@@index([eventId, startsAt])
}
model TicketUsage {
// ...existing fields
sessionId String? // null on single-date events
session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict)
@@index([sessionId])
}
// Phase B only: which sessions a "pick sessions" ticket covers.
model TicketSession {
ticketId String
sessionId String
@@id([ticketId, sessionId])
}
```
`Ticket.isUsed` keeps its meaning for single-date events. For session events
it's not used for check-in decisions; "checked in" is per session
(`TicketUsage.sessionId`).
### Plumbing
- Session CRUD + pattern generator in `backend/src/utils/sessions.js` with
Jest tests (DST-free but still test month boundaries, skipped dates, "until"
vs "count").
- Scan endpoint takes an optional `sessionId`; defaults to the current session.
- Help content, audit log entries (session created/moved/cancelled),
CHANGELOG, FEATURES.md, SETTINGS.md.
---
## 2. Payment providers: PayFast, SnapScan, Zapper
Today Yoco is the only online provider. Add **PayFast**, **SnapScan** and
**Zapper**, and let the admin **enable one or more** in Site settings. At
checkout the user picks from the enabled ones.
**Who:** Admins configure providers. Users choose at payment. Supervisors
reconcile.
### Site settings → Payments
| Setting | Options |
|---------|---------|
| Providers | Yoco · PayFast · SnapScan · Zapper — each with an **Enabled** toggle |
| Credentials | Per provider (merchant id/key, API key, passphrase, webhook secret…), stored encrypted (`utils/encryption.js`, same as WhatsApp) |
| Mode | Live / Sandbox per provider (where supported) |
| Display order / default | Which one is shown first. Default order: Yoco · PayFast · SnapScan · Zapper |
| Minimum amount | **R15 for all providers** (one site-wide minimum for online payments) |
**Fees are not handled automatically.** Payments are recorded at the full
amount paid; provider fees are not deducted, estimated or added to the payer's
total. Fees are dealt with by people during **cashup**, as today. There is no
"pass fee to payer" option.
"Test connection" button per provider, like the WhatsApp setup.
### How it works
- A single **PaymentProvider interface** in the backend:
`createCheckout`, `handleWebhook` (verify signature → mark paid),
`getTransaction`, `refund` (if the provider supports it via API),
`listTransactions` (for reconciliation).
- Yoco is moved behind the same interface first (no behaviour change), then
PayFast, SnapScan and Zapper are added one release each.
- SnapScan and Zapper are QR/app-based: on desktop show the QR, on mobile
deep-link into the app; the page polls/waits for the webhook and moves on
automatically when payment lands.
- `Payment.method` gets the provider name; `YocoTransaction` is generalised to
`ProviderTransaction` (`provider` column) so the existing reconciliation page
works for all providers.
- Refunds: if the provider has no refund API, the supervisor refunds in the
provider's portal and records it here (existing manual refund flow).
### Edge cases
- Provider disabled while a checkout is in progress → webhook still accepted
and processed.
- Duplicate / late webhooks → idempotent on the provider's transaction id.
- Webhook never arrives → "Check payment status" button and a periodic
reconcile job.
- Amount mismatch (user edits amount in app) → payment held as "needs review".
- Only enabled providers appear in kiosk / self-service.
- Amount below R15 → online payment options hidden, with a message to pay at
the door / contact the organiser.
- **Before building each one:** get the documentation links from Joshua and
confirm current API docs for webhooks, sandbox, refunds and QR deep-link
support.
---
## 3. POPIA toolkit
Tools that make the platform **POPIA-ready** out of the box. This is the main
selling point for other churches and organisations: "your attendee data is
handled properly, and you can prove it."
> This is tooling to support compliance, not legal advice. Wording and
> retention periods should be checked by whoever is the customer's
> Information Officer.
**Who:** Admins (and the appointed Information Officer) manage it. Users get
self-service privacy tools.
### For users (data subjects)
- **Privacy centre** in My account:
- **Download my data** — JSON + readable PDF of profile, registrations,
payments, tickets, form answers, consents, messages sent.
- **Correct my data** — edit profile; request correction of locked records.
- **Delete my account** — creates a deletion request (see below).
- **Communication preferences** — email / WhatsApp / marketing opt-in with
timestamps (extends the existing notification preferences).
- **Unsubscribe link** in every bulk email and an opt-out keyword for bulk
WhatsApp. Opted-out users are skipped automatically in broadcasts.
- **Privacy notice acceptance** — versioned; users accept the current version
at registration, and are asked again when it changes.
### For admins
| Tool | What it does |
|------|--------------|
| Information Officer | Name, contact details, shown on privacy notice and contact page |
| Privacy notice editor | Versioned text, published version history |
| Consent log | Who consented to what, when, which notice version, how (web, kiosk, WhatsApp) |
| Data requests inbox | Access, correction and deletion requests with status, due dates and audit trail |
| Deletion workflow | Admin approves → personal info is **anonymised**; financial records kept (amounts, dates) but unlinked from the person, as tax law requires them to be kept |
| Retention policies | Per data category (inactive accounts, form answers, medical details, analytics, audit log, backups): keep for N months, then anonymise/delete via nightly job. Sensible defaults shipped |
| Operator register | List of third parties that process data (Yoco/PayFast/…, WhatsApp provider, email provider, Sentry, hosting) with purpose; feeds the privacy notice |
| Access log | Who exported or viewed personal data in bulk (exports, attendee lists, medical details) |
| Breach register | Record a security compromise, affected users, actions taken; templates for notifying users and the Information Regulator |
| Children's data | Already covered by parental consent; flagged as special/children's info in reports |
| POPIA readiness page | Checklist with score: officer set, notice published, retention set, operators listed, unsubscribe working… |
### Edge cases
- Deletion of a user with an open registration / upcoming event → request
waits until after the event, or the admin cancels and refunds first.
- Anonymised users still count in historic reports ("Deleted user").
- Backups contain deleted data until they age out — stated in the notice;
backup retention is one of the retention policies.
- Impersonation and exports must be logged in the access log.
---
## 4. Kids check-in with security labels
A secure check-in/check-out flow for children's ministry and youth events.
**Builds on sessions (§1), attendees, parental consent and allocation groups.**
**Who:** Parents check in children (self-service kiosk or staff desk).
Kids-ministry staff run rooms. Supervisors set up rooms.
### How it works
- Parent finds their family (phone number / QR from My account) and ticks the
children being dropped off. New children can be added on the spot.
- The system prints **two labels**:
- **Child label:** name, room, allergy/medical icons, short pickup code.
- **Parent label:** the same pickup code.
- **Check-out** requires scanning or typing the matching code. A mismatch
alerts the room leader.
- Rooms come from allocation groups (age/grade rules), with **ratios** (e.g.
1 leader per 5 toddlers) and live room counts.
- **Page a parent:** room leader taps "Call parent" → WhatsApp/SMS to the
parent ("Please come to the Toddlers room").
- Attendance per child per session feeds the attendance register.
### Plumbing / edge cases
- Label printing via browser print with label-size CSS (Dymo / Brother label
printers, 62 mm and 89×36 mm presets); PDF fallback.
- Allergies and medical info come from the consent/medical fields; respect
the medical-info deletion rules.
- Custody notes ("Do not release to …") visible to room leaders only.
- Works on the existing offline-scanning foundations for flaky Wi-Fi.
---
## 5. Volunteer / serving rosters
Plan who serves where, for an event or each session.
- **Serving roles** per event/session: Welcome, Parking, Kids, Sound, Kitchen…
with the number of people needed.
- Volunteers **sign up** for open slots, or supervisors **assign** people.
- Reminders via email/WhatsApp before their slot; "can't make it" → swap
request to others in the role.
- **Volunteer check-in** on the day (separate from attendee check-in).
- Volunteers can get a free/discounted ticket automatically (uses coupons/comp).
- Reports: roster sheet per session, fill rate, hours served per person.
---
## 6. Event series and cloning
- **Clone event:** copy an event with its options, variants, early-bird tiers,
forms, consent text, feedback form, cost template, allocation groups and
sessions (dates shifted to the new start). No registrations.
- **Series:** group separate events (Camp 2025, Camp 2026; monthly Men's
Breakfast) on one series page, with "Next in this series" and a series
follow/notify option.
- **Year-on-year reports:** registrations, income and attendance compared
across events in a series.
- **Season pass:** a bundle across events in a series (extends event bundles).
---
## 7. Apple and Google Wallet tickets
- "Add to Apple Wallet" / "Add to Google Wallet" buttons next to the PDF
ticket, in email and in My Events.
- Pass shows event, date (next session for multi-day), location, holder name,
QR code.
- Passes **update** when the event/session changes (time, venue, cancelled).
- Requires an Apple Developer account (pass signing certificate) and a Google
Wallet issuer account; both configured in Site settings and **optional** —
hidden when not configured.
---
## 8. Outbound webhooks
Let other systems react to what happens in Hope Events.
- Admin adds endpoints and picks events: `registration.created`,
`registration.paid`, `registration.cancelled`, `payment.refunded`,
`ticket.checked_in`, `user.created`, `event.published`…
- Payloads signed with HMAC (secret per endpoint), retries with back-off,
delivery log with "Resend".
- Enables integrations (ChurchSuite, Planning Center, Google Sheets via
Zapier/Make) without building each one. Native integrations can follow later.
---
## 9. Admin dashboard, command palette, bulk actions
- **Dashboard** as supervisors' home: today's events/sessions, check-ins so
far, money in (today/week), pending items (unreconciled payments, consent
outstanding, waitlist offers, data requests), alerts.
- **Command palette (Ctrl+K):** jump to any event, user, registration,
ticket or setting; quick actions ("New event", "Find payment R450").
- **Bulk actions** on registration/attendee lists: message, export, mark
paid, move option, cancel, allocate.
- **Saved filters** per user on list pages.
---
## 10. Languages (Afrikaans first)
- i18n framework on the frontend (e.g. `next-intl`); English + Afrikaans.
- User language preference; emails, WhatsApp messages and PDFs sent in the
user's language, with per-language templates.
- Event content stays as typed by the supervisor (optional translated title /
description fields later).
---
## 11. Accessibility pass (WCAG 2.2 AA)
- Audit public pages, registration, payment, My Events and the kiosk.
- Keyboard navigation, focus states, labels, colour contrast (light and
dark), screen-reader names for icon buttons, error messages linked to
fields.
- Automated `axe` checks in the Playwright suite so it doesn't regress.
---
## 12. Multi-campus (one install per church)
**Decision: one install per church.** Each customer gets their own install
and database (the `/setup` wizard already makes this possible). This gives
full data separation — which also helps the POPIA story — and lets each
church run on a different server. **True multi-tenant is out of scope.**
**Multi-campus** is in scope: one church with several campuses inside one
install.
### Setup
- Site settings → **Campuses**: off by default. When on, admins add campuses
(name, address, contact, optional logo/colour accent, default location for
events).
- Single-campus churches never see campus fields anywhere.
### How it works
- **Events** belong to one campus, or to **All campuses** (church-wide
conferences, camps).
- **Public site:** campus filter on the events list, remembered per visitor;
campus shown on event cards and tickets. Optional campus landing pages
(`/campus/<slug>`).
- **Users** can set a home campus (used as the default filter and for
broadcasts). Accounts stay shared across campuses — one login for the
church.
- **Campus-scoped roles:** a supervisor or staff member can be limited to
one or more campuses (sees and manages only those events, registrations,
scanning, cashups). Admins and unscoped supervisors see everything.
- **Reports, cashup, dashboard, funnel stats:** filter by campus plus a
church-wide total.
- **Broadcasts:** target "everyone at campus X".
- **Kids check-in rooms** and **volunteer roles** are per campus.
- **Notification recipients** per campus (e.g. the campus pastor gets that
campus's daily summary).
### Edge cases
- Turning campuses off with campus data present → hidden, not deleted.
- Deleting a campus with events → blocked; archive instead.
- Moving an event to another campus → scoped supervisors of the old campus
lose access; logged in the audit log.
### Data model (sketch)
```prisma
model Campus {
id String @id @default(uuid())
name String
slug String @unique
address String?
isActive Boolean @default(true)
order Int @default(0)
events Event[]
}
// Event.campusId String? (null = all campuses)
// User.homeCampusId String?
// UserCampusScope { userId, campusId } — empty = unscoped
```
### Running many installs
Because every church is a separate install, a few tools make that manageable:
- **Install script:** fresh server → Node, Postgres, PM2, reverse proxy,
`.env`, migrations, then `/setup` in the browser.
- **Update script:** pull a tagged version, back up the database, run
migrations, build, restart; roll back on failure.
- **Version and health:** current version shown in admin and on a `/health`
endpoint (version, DB, jobs, last backup), reported to the
[Hub](#13-central-management-platform-hope-events-hub).
---
## 13. Central management platform (Hope Events Hub)
A separate app, run by Joshua, to see and manage **every Hope Events
install** from one place: who the customers are, which version each runs,
whether they're healthy, basic usage stats, and their **licence** status.
**Who:** Joshua (and any future support staff). Customers never log in to it.
### Separate app, separate repo, separate server
- **Its own git repository:** `joshua/hope-events-hub` on the same Gitea
server (c-git.crosscode.co.za), with its own semver tags, CHANGELOG and
deploys. It is never shipped to churches, so none of its code (customers,
licence signing key, other churches' data) ends up on a customer's server.
- Same stack (Next.js + Express + Prisma + Postgres) so it's familiar.
- **What lives where:**
- Hub repo: dashboard, customers, licences, licence **signing** (private
key — only ever on the Hub server), `/api/installs/register` and
`/api/installs/heartbeat` endpoints, alerts.
- Hope Events repo: the Hub **client** — licence step in `/setup`,
"Connect to Hub" in Site settings, heartbeat job, licence verification
with the Hub's **public** key, read-only mode, banners.
- **The contract between them** (register / heartbeat payloads, licence token
format) is versioned with a `protocolVersion` field and documented in both
repos (`docs/hub-protocol.md`). The Hub accepts the current and previous
protocol version, so churches on older versions keep reporting while they
wait to update.
- Runs on its own server/domain (e.g. `hub.<your-domain>`). If the Hub is
down, **no church install is affected**.
- Hope Family Church's own site is the first registered install.
### Registration (setup → Hub)
1. The `/setup` wizard gets a **Licence** step: the customer enters the
**licence key** Joshua issued (created in the Hub first).
2. When setup completes, the install sends `POST /api/installs/register`
with: licence key, site name, URL, admin contact, version, server info
(Node/Postgres version, OS), timezone, enabled features.
3. The Hub checks the key, links the install to the customer, and returns an
**install id + install secret** (stored encrypted in AppSettings) and a
**signed licence token**.
4. Existing installs (already past setup) get a **"Connect to Hub"** button
in Site settings → Licence.
All later calls are signed with the install secret (HMAC) and go over HTTPS.
### Heartbeat (install → Hub)
- Every 15 minutes (job queue) the install POSTs a heartbeat:
- **Health:** version, uptime, DB OK, job queue OK, last successful
backup, disk free, last error count (from Sentry or local logs), pending
migrations.
- **Basic stats (aggregate counts only):** users, active/upcoming events,
registrations and tickets (30 days / all time), check-ins, WhatsApp/email
sent, enabled payment providers, campuses on/off.
- **Optional (off by default, customer chooses):** payment totals.
- **Install → Hub only.** The install pushes; the Hub never needs to reach
into the install, so it works behind firewalls and the Hub has no access
to church data.
- **No personal information is ever sent** (no names, emails, phones,
attendee data). Listed in the install's POPIA operator register and
privacy notice as "aggregate usage statistics to the software provider".
### Hub dashboard
- **Installs list:** customer, URL, version (flag if behind the latest tag),
licence status, last heartbeat (red if > 1 h ago), health status.
- **Install detail:** health history, stats charts, version history,
licence history, notes, contacts.
- **Alerts** (email/WhatsApp to Joshua): install offline > 1 h, backup
failed or older than 48 h, disk low, errors spiking, licence expiring.
- **Releases:** latest version and changelog; installs see "Update
available" in their admin.
- **Customers:** contact details, plan, licence(s), billing notes.
### Licences
| State | What the install does |
|-------|----------------------|
| **Active** | Everything works |
| **Grace** (expired or Hub unreachable) | Everything works; admins see a banner "Licence needs renewing — X days left". Default 14 days |
| **Suspended** | **Read-only:** no new events, no new registrations or payments. Existing tickets **still scan**, and admins can still view and **export all data** |
| **Revoked** | Same as suspended, with a permanent banner |
- Joshua turns a licence on/off, sets expiry dates and plan in the Hub; the
install picks up the change on its next heartbeat.
- The licence is a **signed token** (Ed25519: the Hub signs with its private
key, installs only hold the public key). Installs verify it **offline**, so
a Hub outage never switches a church off, and a token can't be faked by
editing the database.
- **Never a hard kill:** a church must always be able to scan tickets for
events people already paid for, and get its own data out (POPIA access
rights). Suspension blocks *new* activity only.
- Optional plan limits later (e.g. number of campuses, WhatsApp) carried in
the token.
### Things to be aware of
- Code runs on the customer's server, so a determined developer could patch
out the licence check. Real protection is the **contract** plus updates and
support only being available to valid licences — the licence check keeps
honest customers honest.
- The Hub should not offer remote commands (run updates, impersonate,
database access) in v1: it's the most valuable target an attacker could
hit, so keeping it read-only toward installs keeps every church safe if it
is ever compromised. Remote "trigger update" can be revisited later.
- The Hub itself needs 2FA, backups and the same audit log approach.
### Data model (Hub, sketch)
```prisma
model Customer { id, name, contactName, email, phone, notes, licences, installs }
model Licence { id, customerId, key, plan, status, expiresAt, graceDays, issuedAt }
model Install { id, customerId, licenceId, name, url, secretHash, version,
lastHeartbeatAt, health Json, registeredAt }
model Heartbeat { id, installId, receivedAt, version, health Json, stats Json }
// rolled up to daily after 30 days
model InstallEvent { id, installId, type, message, createdAt } // alerts, version changes
```
---
## 14. Foundations (platform work)
Not visible to users, but this is what makes 2.0 a safe product to sell and
keep adding to.
| Item | What |
|------|------|
| CI (Gitea Actions) | Lint, typecheck, Jest, build on every push; deploy on tag |
| Playwright E2E | Register → pay (sandbox) → ticket → scan → cashup; multi-day check-in; kiosk |
| Job queue | Move `setInterval` jobs out of `index.js` into `pg-boss` (uses Postgres): retries, no double runs, job history in admin |
| Split big controllers | `registrationController` (2.3k lines), `eventController`, `paymentController` → services by domain. Done **as each feature touches them**, not as a big-bang rewrite |
| TypeScript backend | Incremental (`allowJs`), new code in TS first; share zod schemas/types with the frontend |
| API v2 | `/api/v2` with consistent errors, pagination and an OpenAPI spec (replaces the hand-written API docs). `/api` (v1) kept during the transition |
| Dependencies | Express 5, native `fetch` instead of `node-fetch`, password hashing upgrade with rehash-on-login |
---
## Suggested build order
| Phase | Version | Features | Why |
|-------|---------|----------|-----|
| 0 | 1.32.0 ✅ | **Early-bird ribbon** on event cards (shipped 2026-10-22) | Quick win |
| 1 | 1.33.0 ✅ | **Multi-day events — phase A** (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work |
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
| 4 | 1.36.0 – 1.39.0 | Payment provider interface (Yoco moved behind it) → PayFast → SnapScan → Zapper, one release each | Payment code gets split out of the big controller as part of this |
| 5 | 1.40.0 – 1.41.0 | **POPIA toolkit** (user privacy centre + unsubscribe first, then admin tools, retention jobs, readiness page) | Main selling point; retention jobs use the job queue |
| 6 | 1.42.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
| 7 | 1.43.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
| 8 | 1.44.0 – 1.45.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.46.0 – 1.48.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 12 | **2.0.0** | v1 API removed, migration guide, marketing site/docs for selling | The release |
Each phase should add matching help content (`frontend/src/content/help/`),
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
and SETTINGS.md — same as the previous round. Branch → merge → tag → push per
feature.
---
## Decisions (2026-10-22)
| # | Question | Decision |
|---|----------|----------|
| 1 | Pick sessions in the first release? | No — whole-course first (1.33.0), pick sessions as a separate release (1.35.0) |
| 2 | Late joining and late price | Set **per event**: late joining on/off (until session N), and the late price (full / pro-rata / fixed) |
| 3 | Check-in window | Per event, **whole day** by default (any time on the session's date); "session time only" as the alternative |
| 4 | Certificates | Yes — four templates (Classic, Modern, Church, Kids); the event creator chooses whether to issue certificates and which template |
| 5 | Session reminders | Off by default, configurable per event |
| 6 | Selling model | **One install per church** — data separation, any server. No multi-tenant. **Multi-campus** inside an install is in scope |
| — | Pro-rata rounding | Nearest R10 |
| — | Multi-campus | In scope (§12), inside one install |
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/hope-events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
| 7 | Payment provider order | Yoco · PayFast · SnapScan · Zapper. Joshua can supply documentation links |
| — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" |
| — | Minimum online payment | R15 for all providers |
| — | Scope | All features in this document approved; POPIA toolkit and kids check-in are highlights |
| — | Early-bird ribbon | Added before everything else; shipped as 1.32.0, all later versions moved up by one |
## Still open
Hub (needed before phase 6, not before 1.33.0):
1. Hub domain and server, and where licence keys come from (created by hand
in the Hub per customer?).
2. Grace period length (proposed 14 days) and suspended = read-only (tickets
still scan, data export still works) — agreed?
3. Should payment totals be in the heartbeat at all (opt-in per customer), or
counts only?
4. Licence plans/tiers (e.g. limits on campuses or WhatsApp), or one plan for
now?
+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 };
events: { free: string; paid: string; course: string; dropIn: string };
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
};
+26
View File
@@ -0,0 +1,26 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
type Registration = { id: string; eventId: string; registrationOptions: { sessionId?: string | null; priceSnapshot: number }[] };
test.describe("pick sessions", () => {
test("book two classes of a drop-in event; a full class can't be ticked", async ({ page }) => {
await loginAs(page, "user");
await page.goto(`/register/${seed().events.dropIn}`);
await expect(page.getByText("R50.00 per session").first()).toBeVisible();
await page.getByRole("button", { name: "+" }).first().click();
const picker = page.getByRole("group", { name: "Choose sessions" });
await expect(picker.getByText("Full")).toBeVisible();
await expect(picker.getByRole("checkbox").first()).toBeDisabled();
await picker.getByRole("checkbox").nth(1).check();
await picker.getByRole("checkbox").nth(2).check();
await expect(page.getByText("Total: R100.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 reg = regs.find((r) => r.eventId === seed().events.dropIn);
expect(reg?.registrationOptions.filter((o) => o.sessionId)).toHaveLength(2);
});
});
+24 -1
View File
@@ -101,6 +101,29 @@ async function seed(url) {
});
await prisma.event.update({ where: { id: course.id }, data: { lateJoinUntilSessionId: course.sessions[2].id } });
// A drop-in class: three upcoming classes, booked per class (the first is full).
const classes = [1, 8, 15].map((days, i) => {
const startsAt = at(days, 16, 0);
return { label: `Class ${i + 1}`, startsAt, endsAt: new Date(startsAt.getTime() + 60 * 60 * 1000), order: i, capacity: i === 0 ? 1 : null };
});
const dropIn = await prisma.event.create({
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',
sessions: { create: classes },
eventOptions: { create: [{ name: 'Drop-in', price: 50, isMainTicket: true, sessionMode: 'pick' }] },
},
include: { sessions: { orderBy: { startsAt: 'asc' } }, eventOptions: true },
});
// Someone else already has the only place in class 1.
await prisma.registration.create({
data: {
userId: users.staff.id, eventId: dropIn.id, status: 'paid',
registrationOptions: { create: [{ eventOptionId: dropIn.eventOptions[0].id, quantity: 1, priceSnapshot: 50, sessionId: dropIn.sessions[0].id, tranches: { create: [{ quantity: 1, priceSnapshot: 50 }] } }] },
},
});
// Thandi is booked on the course and was checked in for week 1.
const reg = await prisma.registration.create({
data: {
@@ -115,7 +138,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 },
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.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.34.0",
"version": "1.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events-frontend",
"version": "1.34.0",
"version": "1.35.0",
"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.34.0",
"version": "1.35.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
@@ -1,5 +1,6 @@
"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";
@@ -15,7 +16,7 @@ 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 { todaysSession } from "@/lib/sessions";
import { isPickOption, todaysSession } from "@/lib/sessions";
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" };
@@ -96,6 +97,7 @@ export default function AtTheDoorPage() {
const [eventId, setEventId] = useState("");
const [eventOptions, setEventOptions] = useState<EventOption[]>([]);
const [doorHasPickOptions, setDoorHasPickOptions] = useState(false);
const [showOptionsModal, setShowOptionsModal] = useState(false);
const [pendingUser, setPendingUser] = useState<NewAttendee | null>(null);
const [doorCoupon, setDoorCoupon] = useState<AppliedCoupon | null>(null);
@@ -158,7 +160,11 @@ export default function AtTheDoorPage() {
authToken: token
});
setEventOptions(ev.options || ev.eventOptions || []);
// 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)));
setDoorAllowsNames(!!ev.allowNamedAttendees || !!ev.requiresParentalConsent);
setDoorYouthStart(ev.requiresParentalConsent ? ev.startDate : null);
setDoorCollectGender(!!ev.collectGender);
@@ -493,6 +499,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); }}
@@ -91,7 +91,9 @@ function AttendancePageInner() {
const key = `${row.ticketId}:${sessionId}`;
setPending(key);
try {
await apiFetch(`/api/attendance/${eventId}/mark`, { method: "POST", authToken: token, body: { ticketId: row.ticketId, sessionId, present } });
// Picked sessions have a ticket per session.
const ticketId = row.sessionTickets?.[sessionId] || row.ticketId;
await apiFetch(`/api/attendance/${eventId}/mark`, { method: "POST", authToken: token, body: { ticketId, sessionId, present } });
const col = reg?.columns.find(c => c.id === sessionId);
setInfo(`${row.name || "Attendee"} marked ${present ? "present" : "absent"}${multi && col ? ` for ${col.label}` : ""}.`);
await load();
@@ -229,6 +231,9 @@ function AttendancePageInner() {
{columns.map(c => {
const qty = r.checkIns[c.id] || 0;
const key = `${r.ticketId}:${c.id}`;
if (r.bookedSessions && !r.bookedSessions.includes(c.id)) {
return <td key={c.id} className="px-2 py-2 text-center text-gray-300" title="Not booked for this session">—</td>;
}
const disabled = !canManage || c.cancelled || pending !== null || reg.event.cashupStatus === "closed";
return (
<td key={c.id} className="px-2 py-2 text-center">
@@ -95,6 +95,7 @@ type OptionDraft = {
staffPrice?: string; // empty → no staff price
earlyBirdTiers: Tier[]; // option-level (used only when no variants)
variants: Variant[];
sessionMode?: "all" | "pick"; // "pick sessions" events only
};
type SectionDraft = {
@@ -209,7 +210,7 @@ function FormBuilder({ value, onChange, title = "Registration Form Fields", gate
// ─── Items & Pricing substep editors ────────────────────────────────────────
/** Substep 0 — bare option rows */
function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]; onChange: (o: OptionDraft[]) => void; required?: boolean }) {
function OptionsEditor({ options, onChange, required, pickMode }: { options: OptionDraft[]; onChange: (o: OptionDraft[]) => void; required?: boolean; pickMode?: boolean }) {
const upd = (i: number, patch: Partial<OptionDraft>) => { const c = options.slice(); c[i] = { ...c[i], ...patch }; onChange(c); };
const rem = (i: number) => { const c = options.slice(); c.splice(i, 1); onChange(c); };
const add = () => {
@@ -245,6 +246,15 @@ function OptionsEditor({ options, onChange, required }: { options: OptionDraft[]
<input type="number" step="1" min="0" className="border rounded px-2 py-1.5 text-sm w-24" placeholder="(none)"
value={opt.staffPrice ?? ""} onChange={e => upd(i, { staffPrice: e.target.value })} />
</div>
{pickMode && (
<div>
<label className="block text-[10px] text-gray-500 mb-0.5" title="Pick sessions: bought per session — the price and stock are per session">Sessions</label>
<select className="border rounded px-2 py-1.5 text-sm" value={opt.sessionMode || "all"} onChange={e => upd(i, { sessionMode: e.target.value as "all" | "pick" })}>
<option value="all">All sessions</option>
<option value="pick">Pick sessions (price per session)</option>
</select>
</div>
)}
<div className="flex items-center gap-3 pb-1">
<label className="text-xs flex items-center gap-1 cursor-pointer whitespace-nowrap">
<input type="checkbox" checked={opt.isMainTicket} onChange={e => upd(i, { isMainTicket: e.target.checked })} /> Main ticket
@@ -826,6 +836,7 @@ interface EventDraft {
allowTicketTransfer: boolean; collectGender: boolean;
allowSponsorships: boolean; sponsorshipGoalSpots: string; showSponsorshipProgress: boolean;
scheduleType: "single" | "sessions";
attendanceMode: "whole" | "pick";
}
const blankDraft = (location = ""): EventDraft => ({
@@ -840,6 +851,7 @@ const blankDraft = (location = ""): EventDraft => ({
allowTicketTransfer: true, collectGender: false,
allowSponsorships: false, sponsorshipGoalSpots: "", showSponsorshipProgress: true,
scheduleType: "single",
attendanceMode: "whole",
});
// "YYYY-MM-DDTHH:MM" (local) → ISO
@@ -886,6 +898,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
contactName: ev.contactName || "", contactPhone: ev.contactPhone || "", contactEmail: ev.contactEmail || "",
location: ev.location || "",
scheduleType: ev.scheduleType === "sessions" ? "sessions" : "single",
attendanceMode: ev.attendanceMode === "pick" ? "pick" : "whole",
} : blankDraft(settings.org_address || ""));
// ── multi-day schedule + attendance settings ──
@@ -904,6 +917,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
}));
const updAttendance = (patch: Partial<AttendanceDraft>) => setAttendance(a => ({ ...a, ...patch }));
const multi = draft.scheduleType === "sessions";
const pickMode = multi && draft.attendanceMode === "pick";
// A multi-session event's dates come from its sessions.
const scheduleBounds = useMemo(() => {
const active = sortSessions(sessions).filter(s => !s.cancelled && s.startsAt && s.endsAt);
@@ -926,6 +940,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
isMainTicket: !!o.isMainTicket,
stockLimit: String(o.stockLimit ?? "0"),
staffPrice: o.staffPrice !== null && o.staffPrice !== undefined ? String(o.staffPrice) : "",
sessionMode: o.sessionMode || "all",
// Option-level tiers only (no variantId)
earlyBirdTiers: allTiers
.filter((t) => !t.variantId)
@@ -1075,7 +1090,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// Update option + variants first to get/confirm variant IDs
const updated = await apiFetch<EventOption>(`/api/events/options/${opt.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, variants: variantsPayload }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all", variants: variantsPayload }
});
// Build variant tiers with confirmed IDs from response
@@ -1107,7 +1122,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// New option added during edit
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
});
if (created?.id) {
const updated = await apiFetch<EventOption>(`/api/events/options/${created.id}`, {
@@ -1154,13 +1169,13 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
// Update the auto-created main ticket
await apiFetch(`/api/events/options/${existingMain.id}`, {
method: "PUT", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: true, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
});
optId = existingMain.id;
} else {
const created = await apiFetch<EventOption>(`/api/events/${eventId}/options`, {
method: "POST", authToken: token || undefined,
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null }
body: { name: opt.name, price: parseFloat(opt.price || "0"), isMainTicket: opt.isMainTicket, stockLimit: parseInt(opt.stockLimit || "0"), staffPrice: opt.staffPrice ? parseFloat(opt.staffPrice) : null, sessionMode: pickMode ? opt.sessionMode || "all" : "all" }
});
optId = created?.id;
}
@@ -1234,7 +1249,8 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
endDate: multi && scheduleBounds ? isoOf(scheduleBounds.end) : new Date(draft.endDate).toISOString(),
scheduleType: draft.scheduleType,
...(multi ? {
sessions: sortedSessions.map(s => ({ id: s.id, label: s.label.trim() || null, startsAt: isoOf(s.startsAt), endsAt: isoOf(s.endsAt), location: s.location.trim() || null, cancelled: s.cancelled })),
attendanceMode: draft.attendanceMode,
sessions: sortedSessions.map(s => ({ id: s.id, label: s.label.trim() || null, startsAt: isoOf(s.startsAt), endsAt: isoOf(s.endsAt), location: s.location.trim() || null, cancelled: s.cancelled, capacity: pickMode && s.capacity.trim() ? parseInt(s.capacity, 10) : null })),
lateJoinEnabled: attendance.lateJoinEnabled,
lateJoinUntilSessionIndex: attendance.lateJoinEnabled ? sortedSessions.findIndex(s => s.key === attendance.lateJoinUntilKey) : -1,
latePricing: attendance.latePricing,
@@ -1437,7 +1453,16 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
{multi && <p className="text-[10px] text-gray-400 mt-1">One registration and one ticket for every session, e.g. every Wednesday for 5 weeks. Check-in is per session.</p>}
</div>
{multi ? (
<SessionsEditor sessions={sessions} onChange={setSessions} defaultLocation={draft.location} />
<>
<SessionsEditor sessions={sessions} onChange={setSessions} defaultLocation={draft.location} showCapacity={pickMode} />
<div>
<div className="block text-xs text-gray-600 mb-1">How people book</div>
<div className="space-y-1">
<label className="flex items-start gap-2 text-sm"><input type="radio" className="mt-1" name="attendance-mode" checked={draft.attendanceMode === "whole"} onChange={() => upd({ attendanceMode: "whole" })} /><span><span className="font-medium">The whole course</span><span className="block text-xs text-gray-500">One ticket for every session.</span></span></label>
<label className="flex items-start gap-2 text-sm"><input type="radio" className="mt-1" name="attendance-mode" disabled={draft.requiresParentalConsent} checked={draft.attendanceMode === "pick"} onChange={() => upd({ attendanceMode: "pick" })} /><span><span className="font-medium">Pick sessions</span><span className="block text-xs text-gray-500">People choose which sessions to come to (e.g. day passes or a drop-in class). Set which tickets are per session in Items &amp; Pricing, and an optional capacity per session above.{draft.requiresParentalConsent ? " Not available on youth events." : ""}</span></span></label>
</div>
</div>
</>
) : (
<div className="grid sm:grid-cols-2 gap-3">
<DTInput label="Start" required value={draft.startDate} onChange={v => upd({ startDate: v })} />
@@ -1506,7 +1531,7 @@ function EventModal({ mode, event: ev, onClose, onSuccess }: EventModalProps) {
{step === 1 && pricingSubstep === 0 && (
<div>
<p className="text-xs text-gray-500 mb-3">Define your ticket types or purchasable items. Variants and early-bird pricing are configured in the next two steps.</p>
<OptionsEditor options={options} onChange={handleOptionsChange} required={mode === "create"} />
<OptionsEditor options={options} onChange={handleOptionsChange} required={mode === "create"} pickMode={pickMode} />
</div>
)}
{step === 1 && pricingSubstep === 1 && (
@@ -12,6 +12,8 @@ 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 { SessionPicker } from "@/components/events/SessionPicker";
import type { Event, EventOption, PublicBundle, Registration, RegistrationOption, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink";
@@ -268,29 +270,45 @@ export default function ManualRegistrationPage() {
}
}, [selectedEventId, events]);
// The events list doesn't carry bundle deals; the single-event response does.
// The events list doesn't carry bundle deals or places per session; the single-event
// response does.
const [fullEvent, setFullEvent] = useState<Event | null>(null);
useEffect(() => {
if (!selectedEventId) { setBundles([]); return; }
if (!selectedEventId) { setBundles([]); setFullEvent(null); return; }
let cancelled = false;
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: token || undefined })
.then(ev => { if (!cancelled) setBundles(ev?.bundles || []); })
.catch(() => { if (!cancelled) setBundles([]); });
.then(ev => { if (!cancelled) { setBundles(ev?.bundles || []); setFullEvent(ev || null); } })
.catch(() => { if (!cancelled) { setBundles([]); setFullEvent(null); } });
return () => { cancelled = true; };
}, [selectedEventId, token]);
// "Pick sessions" options: the sessions chosen per quantity key.
const [picks, setPicks] = useState<Record<string, string[]>>({});
useEffect(() => { setPicks({}); }, [selectedEventId]);
const pickOpt = (optionId: string) => {
const o = (fullEvent?.eventOptions || []).find(x => x.id === optionId);
return fullEvent && o && isPickOption(fullEvent, o) ? 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 totalDue = useMemo(() => {
return options.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);
return sum + (o.variants || []).reduce((vs, v) => 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);
}, [options, quantities]);
}, [options, quantities, picks, fullEvent]); // eslint-disable-line react-hooks/exhaustive-deps
const basket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, variantId: variantId || null };
}), [quantities]);
return { eventOptionId, quantity, variantId: variantId || null, ...(pickOpt(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;
@@ -304,9 +322,11 @@ export default function ManualRegistrationPage() {
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(pickOpt(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);
if (noSessions) { setError(`Choose the sessions for "${options.find(o => o.id === noSessions.eventOptionId)?.name || "the ticket"}".`); return; }
if (selectedIsYouth) {
const missing = consentDetailsMissing(options, manualQtyFor, attendeeEntries, selectedEv.startDate);
if (missing) { setError(missing); return; }
@@ -564,7 +584,8 @@ export default function ManualRegistrationPage() {
const basePrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const key = `${opt.id}::${v.id}`;
return (
<div key={v.id} className="flex items-center justify-between px-3 py-2 border-b last:border-b-0">
<div key={v.id} className="border-b last:border-b-0">
<div className="flex items-center justify-between px-3 py-2">
<div>
<div className="text-sm">{v.name}</div>
<div className="text-xs text-gray-500">
@@ -582,6 +603,8 @@ export default function ManualRegistrationPage() {
/>
</div>
</div>
{picker(key, opt.id) && <div className="px-3 pb-2">{picker(key, opt.id)}</div>}
</div>
);
})}
</div>
@@ -605,6 +628,7 @@ export default function ManualRegistrationPage() {
onChange={e => setQuantities(q => ({ ...q, [opt.id]: Math.max(0, parseInt(e.target.value || '0')) }))}
/>
</div>
{picker(opt.id, opt.id)}
</div>
);
})}
+11 -5
View File
@@ -17,6 +17,7 @@ import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing";
import { BundleSavings } from "@/components/shared/BundleDeals";
import { MyCertificates } from "@/components/events/MyCertificates";
import { isPickOption } from "@/lib/sessions";
import type { Event, EventOption, Payment, Registration, RegistrationOption, Ticket as TicketRecord } from "@/types";
// Helper formatters
@@ -484,8 +485,13 @@ export default function UserDashboardPage() {
return !isEventOver(activeReg.event);
}, [activeReg]);
// Picked-session lines can't be edited here (book more by registering), but still count.
const pickedLinesTotal = useMemo(() => (activeReg?.registrationOptions || [])
.filter(ro => ro.sessionId)
.reduce((s, ro) => s + (ro.lineTotal ?? (ro.quantity || 0) * (ro.priceSnapshot || 0)), 0), [activeReg]);
const editTotal = useMemo(() => {
return editEventOptions.reduce((sum, eo) => {
return pickedLinesTotal + editEventOptions.reduce((sum, eo) => {
if (Array.isArray(eo.variants) && eo.variants.length > 0) {
return sum + eo.variants.reduce((vsum, v) => {
const qty = editQuantities[`${eo.id}:${v.id}`] || 0;
@@ -494,7 +500,7 @@ export default function UserDashboardPage() {
}
return sum + (editQuantities[eo.id] || 0) * effectiveEventOptionUnitPrice(eo, null);
}, 0);
}, [editEventOptions, editQuantities]);
}, [editEventOptions, editQuantities, pickedLinesTotal]);
const beginEdit = async () => {
if (!activeReg || !token) return;
@@ -503,13 +509,13 @@ export default function UserDashboardPage() {
try {
// Load event options (with variants) for this event
const eventData = await apiFetch<Event>(`/api/events/${encodeURIComponent(activeReg.eventId)}`);
const eventOptions = eventData?.eventOptions || [];
const eventOptions = (eventData?.eventOptions || []).filter((eo) => !isPickOption(eventData, eo));
const regOptById = new Map<string, RegistrationOption>((activeReg.registrationOptions || []).map((o) => [o.id, o]));
// Prefill quantities from the current registration
const qtyMap: Record<string, number> = {};
for (const ro of (activeReg.registrationOptions || [])) {
for (const ro of (activeReg.registrationOptions || []).filter(r => !r.sessionId)) {
const key = ro.variantId ? `${ro.eventOptionId}:${ro.variantId}` : ro.eventOptionId;
qtyMap[key] = (qtyMap[key] || 0) + (ro.quantity || 0);
}
@@ -519,7 +525,7 @@ export default function UserDashboardPage() {
const minQtyMap: Record<string, number> = {};
for (const t of tickets) {
const ro = t.registrationOptionId ? regOptById.get(t.registrationOptionId) : undefined;
if (!ro) continue;
if (!ro || ro.sessionId) continue;
const key = ro.variantId ? `${ro.eventOptionId}:${ro.variantId}` : ro.eventOptionId;
minQtyMap[key] = (minQtyMap[key] || 0) + (t.quantity || 0);
}
+7 -4
View File
@@ -21,6 +21,7 @@ type EventOption = {
stockLimit?: number;
availableCount?: number;
isMainTicket?: boolean;
sessionMode?: "all" | "pick";
earlyBirdTiers?: { id: string; deadline: string; price: number; stockLimit?: number }[];
variants?: OptionVariant[];
};
@@ -48,6 +49,7 @@ type Event = {
allowSponsorships?: boolean;
bundles?: PublicBundle[];
scheduleType?: "single" | "sessions";
attendanceMode?: "whole" | "pick";
sessions?: EventSession[];
registrationClosesAt?: string;
lateJoin?: LateJoinInfo | null;
@@ -58,7 +60,7 @@ import { ApiImage } from "@/components/shared/ApiImage";
import { formatDateTimeRange } from "@/lib/date";
import { BundleOffers } from "@/components/shared/BundleDeals";
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
import { latePrice, registrationClosesAt, scheduleSummary } from "@/lib/sessions";
import { isPickOption, latePrice, registrationClosesAt, scheduleSummary } from "@/lib/sessions";
import { SessionSchedule } from "@/components/events/SessionSchedule";
import { appUrl, appName } from "@/lib/siteConfig";
import { getServerSettings } from "@/lib/serverSettings";
@@ -292,7 +294,8 @@ function TicketList({ event }: { event: Event }) {
}
// Late joiners on a multi-day event pay a lower main-ticket price.
const late = event.lateJoin?.active ? event.lateJoin : null;
const forLate = (opt: EventOption, price: number) => (late && opt.isMainTicket ? latePrice(price, late) : price);
const forLate = (opt: EventOption, price: number) => (late && opt.isMainTicket && opt.sessionMode !== "pick" ? latePrice(price, late) : price);
const perSession = (opt: EventOption) => (isPickOption(event, opt) ? " per session" : "");
return (
<div className="space-y-2">
<BundleOffers bundles={event.bundles} />
@@ -316,7 +319,7 @@ function TicketList({ event }: { event: Event }) {
<div className="flex items-center justify-between px-3 py-2 bg-gray-50 border-b">
<span className="font-medium text-sm">{opt.name}</span>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-600">{priceLabel}</span>
<span className="text-sm text-gray-600">{priceLabel}{perSession(opt)}</span>
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
</div>
@@ -360,7 +363,7 @@ function TicketList({ event }: { event: Event }) {
<span className="text-xs text-brand-600">Early bird</span>
</>
) : (
<span>{displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}`}</span>
<span>{displayPrice === 0 ? "Free" : `R${displayPrice.toFixed(2)}${perSession(opt)}`}</span>
)}
{soldOut && <span className="text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded px-1.5 py-0.5">Sold out</span>}
{nearlyOut && !soldOut && <span className="text-xs font-medium text-orange-600 bg-orange-50 border border-orange-200 rounded px-1.5 py-0.5">{opt.availableCount} remaining</span>}
@@ -6,7 +6,8 @@ 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 { latePrice, registrationClosesAt } from "@/lib/sessions";
import { 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";
import { analyticsSessionId } from "@/lib/analytics";
@@ -33,6 +34,8 @@ type EventOption = {
isMainTicket?: boolean;
earlyBirdTiers?: EarlyBirdTier[];
variants?: OptionVariant[];
sessionMode?: "all" | "pick";
sessionAvailability?: Record<string, number>;
};
export type RegisterEvent = {
@@ -54,8 +57,10 @@ export type RegisterEvent = {
waitlistEnabled?: boolean;
bundles?: PublicBundle[];
scheduleType?: "single" | "sessions";
attendanceMode?: "whole" | "pick";
sessions?: EventSession[];
registrationClosesAt?: string;
wholeCourseClosesAt?: string | null;
lateJoin?: LateJoinInfo | null;
};
@@ -66,7 +71,7 @@ function withLatePrices(event: RegisterEvent): RegisterEvent {
if (!late) return event;
return {
...event,
eventOptions: (event.eventOptions || []).map(o => (!o.isMainTicket ? o : {
eventOptions: (event.eventOptions || []).map(o => (!o.isMainTicket || o.sessionMode === "pick" ? o : {
...o,
price: latePrice(o.price, late),
earlyBirdTiers: (o.earlyBirdTiers || []).map(t => ({ ...t, price: latePrice(t.price, late) })),
@@ -156,18 +161,27 @@ 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 };
function OptionCard({
opt,
quantities,
onQtyChange,
waitlist,
pick,
}: {
opt: EventOption;
quantities: QtyMap;
onQtyChange: (key: string, val: number) => void;
waitlist?: { eventId: string; guestsAllowed: boolean } | null;
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} />
) : null);
const isSoldOut = (opt.stockLimit ?? 0) > 0 && opt.availableCount !== undefined && opt.availableCount <= 0;
if (hasVariants) {
@@ -177,7 +191,7 @@ function OptionCard({
const variantPrices = variants.map((v) => effectiveUnitForVariant(opt, v));
const minPrice = Math.min(...variantPrices);
const maxPrice = Math.max(...variantPrices);
const priceLabel = minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`;
const priceLabel = (minPrice === maxPrice ? `R${minPrice.toFixed(2)}` : `From R${minPrice.toFixed(2)}`) + perSession;
return (
<div className="border rounded-lg overflow-hidden bg-white shadow-sm">
@@ -214,10 +228,11 @@ function OptionCard({
: undefined;
return (
<div key={variant.id} className="flex items-center justify-between px-4 py-2.5">
<div key={variant.id} className="px-4 py-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 min-w-0">
<span className="text-sm">{variant.name}</span>
<span className="text-xs text-gray-500">R{unitPrice.toFixed(2)}</span>
<span className="text-xs text-gray-500">R{unitPrice.toFixed(2)}{perSession}</span>
<StockBadge stockLimit={variant.stockLimit} availableCount={variant.availableCount} />
{waitlist && variantSoldOut && !isSoldOut && (
<JoinWaitlistButton compact eventId={waitlist.eventId} guestsAllowed={waitlist.guestsAllowed}
@@ -231,6 +246,8 @@ function OptionCard({
max={maxQty}
/>
</div>
{picker(key, qty, unitPrice)}
</div>
);
})}
</div>
@@ -248,7 +265,8 @@ function OptionCard({
: undefined;
return (
<div className="flex items-center justify-between border rounded-lg p-3 bg-white shadow-sm">
<div className="border rounded-lg p-3 bg-white shadow-sm">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1 mr-3">
<div className="font-medium text-sm">{opt.name}</div>
<div className="text-xs text-gray-500 mt-0.5 flex items-center gap-2 flex-wrap">
@@ -260,7 +278,7 @@ function OptionCard({
</span>
</>
) : (
<span>R{price.toFixed(2)}</span>
<span>R{price.toFixed(2)}{perSession}</span>
)}
<StockBadge stockLimit={opt.stockLimit} availableCount={opt.availableCount} />
{waitlist && isSoldOut && (
@@ -275,6 +293,8 @@ function OptionCard({
max={maxQty}
/>
</div>
{picker(key, qty, price)}
</div>
);
}
@@ -432,18 +452,26 @@ 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.
const [picks, setPicks] = useState<Record<string, string[]>>({});
const wholeCourseClosed = event.attendanceMode === "pick" && !!event.wholeCourseClosesAt && Date.now() >= new Date(event.wholeCourseClosesAt).getTime();
const pickFor = (opt: EventOption) => isPickOption(event, opt);
const sessionsFor = (key: string, opt: EventOption) => (pickFor(opt) ? (picks[key] || []).length : 1);
const total = useMemo(() => {
return (event.eventOptions || []).reduce((sum, opt) => {
const hasVariants = Array.isArray(opt.variants) && opt.variants.length > 0;
if (hasVariants) {
return sum + opt.variants!.reduce((vsum, v) => {
const qty = quantities[`${opt.id}:${v.id}`] || 0;
return vsum + qty * effectiveUnitForVariant(opt, v);
const key = `${opt.id}:${v.id}`;
const qty = quantities[key] || 0;
return vsum + qty * sessionsFor(key, opt) * effectiveUnitForVariant(opt, v);
}, 0);
}
return sum + (quantities[opt.id] || 0) * effectiveUnitForOption(opt);
return sum + (quantities[opt.id] || 0) * sessionsFor(opt.id, opt) * effectiveUnitForOption(opt);
}, 0);
}, [event, quantities]);
}, [event, quantities, picks]); // eslint-disable-line react-hooks/exhaustive-deps
const isGuestEligible = !!(event.requiresAuth === false);
const isLoggedIn = !!user || !!token;
@@ -454,7 +482,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
return limitedOpts.length > 0 && limitedOpts.every(o => o.availableCount !== undefined && o.availableCount <= 0);
}, [event]);
const basket = useMemo(() => buildItems(), [quantities]); // eslint-disable-line react-hooks/exhaustive-deps
const basket = useMemo(() => buildItems(), [quantities, picks]); // eslint-disable-line react-hooks/exhaustive-deps
const bundleQuote = useBundleQuote({ eventId, basket, token, enabled: (event.bundles || []).length > 0 });
// The server prices the basket (staff price, tiers, bundle deals) when quoting or checking
// a code, so its total wins.
@@ -472,15 +500,13 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
: 0;
function buildItems() {
const items: { eventOptionId: string; quantity: number; variantId?: string }[] = [];
const items: { eventOptionId: string; quantity: number; variantId?: string; sessionIds?: string[] }[] = [];
Object.entries(quantities).forEach(([key, qty]) => {
if (qty <= 0) return;
if (key.includes(":")) {
const [eventOptionId, variantId] = key.split(":");
items.push({ eventOptionId, quantity: qty, variantId });
} else {
items.push({ eventOptionId: key, quantity: qty });
}
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;
items.push({ eventOptionId, quantity: qty, ...(variantId ? { variantId } : {}), ...(sessionIds ? { sessionIds } : {}) });
});
return items;
}
@@ -491,6 +517,11 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
setError("Select at least one ticket.");
return;
}
const noSessions = items.find(i => i.sessionIds && i.sessionIds.length === 0);
if (noSessions) {
setError(`Choose the sessions for "${(event.eventOptions || []).find(o => o.id === noSessions.eventOptionId)?.name || "your ticket"}".`);
return;
}
if (isYouthEvent) {
const missing = consentDetailsMissing(event.eventOptions || [], qtyFor, attendeeEntries, event.startDate);
if (missing) { setError(missing); return; }
@@ -660,14 +691,18 @@ 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 || []).map((opt) => (
{(event.eventOptions || []).filter(opt => !(wholeCourseClosed && !pickFor(opt))).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}
/>
))}
</div>
+41 -17
View File
@@ -8,7 +8,9 @@ import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeFi
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import { AttendeeNamesEditor, AttendeeEntries, TicketDelivery, attendeesPayload, consentDetailsMissing } from "@/components/shared/AttendeeNamesEditor";
import { errorMessage } from "@/lib/utils";
import type { PublicBundle, Registration } from "@/types";
import type { Event, PublicBundle, Registration } from "@/types";
import { isPickOption } from "@/lib/sessions";
import { SessionPicker } from "@/components/events/SessionPicker";
// ─── Types ────────────────────────────────────────────────────────────────────
type EarlyBirdTier = { deadline: string; price: number; order?: number; variantId?: string | null };
@@ -209,25 +211,41 @@ export default function SelfServicePage() {
[events, selectedEventId]
);
// The events list doesn't carry bundle deals or places per session; the single-event
// response does.
const [kioskFull, setKioskFull] = useState<Event | null>(null);
useEffect(() => {
if (!selectedEventId) { setKioskBundles([]); setKioskFull(null); return; }
let cancelled = false;
apiFetch<Event>(`/api/events/${selectedEventId}`, { authToken: supervisorToken || undefined })
.then(ev => { if (!cancelled) { setKioskBundles(ev?.bundles || []); setKioskFull(ev || null); } })
.catch(() => { if (!cancelled) { setKioskBundles([]); setKioskFull(null); } });
return () => { cancelled = true; };
}, [selectedEventId, supervisorToken]);
// "Pick sessions" options: the sessions chosen per quantity key.
const [picks, setPicks] = useState<Record<string, string[]>>({});
useEffect(() => { setPicks({}); }, [selectedEventId]);
const pickOpt = (optionId: string) => {
const o = (kioskFull?.eventOptions || []).find(x => x.id === optionId);
return kioskFull && o && isPickOption(kioskFull, o) ? 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 total = useMemo(() => {
if (!selectedEvent) return 0;
return (selectedEvent.eventOptions || []).reduce((sum, o) => {
if ((o.variants || []).length > 0) {
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * effectiveVariantUnit(o, v), 0);
return sum + (o.variants || []).reduce((vs, v) => vs + (quantities[`${o.id}::${v.id}`] || 0) * times(`${o.id}::${v.id}`) * effectiveVariantUnit(o, v), 0);
}
return sum + (quantities[o.id] || 0) * effectiveUnit(o);
return sum + (quantities[o.id] || 0) * times(o.id) * effectiveUnit(o);
}, 0);
}, [selectedEvent, quantities]);
// The events list doesn't carry bundle deals; the single-event response does.
useEffect(() => {
if (!selectedEventId) { setKioskBundles([]); return; }
let cancelled = false;
apiFetch<{ bundles?: PublicBundle[] }>(`/api/events/${selectedEventId}`, { authToken: supervisorToken || undefined })
.then(ev => { if (!cancelled) setKioskBundles(ev?.bundles || []); })
.catch(() => { if (!cancelled) setKioskBundles([]); });
return () => { cancelled = true; };
}, [selectedEventId, supervisorToken]);
}, [selectedEvent, quantities, picks, kioskFull]); // eslint-disable-line react-hooks/exhaustive-deps
const kioskBasket = useMemo(() => Object.entries(quantities).map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
@@ -438,7 +456,7 @@ export default function SelfServicePage() {
.filter(([, qty]) => qty > 0)
.map(([key, quantity]) => {
const [eventOptionId, variantId] = key.split("::");
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}) };
return { eventOptionId, quantity, ...(variantId ? { variantId } : {}), ...(pickOpt(eventOptionId) ? { sessionIds: picks[key] || [] } : {}) };
}),
user,
// If the visitor declined to create an account, don't push an unsolicited
@@ -973,7 +991,8 @@ export default function SelfServicePage() {
const baseVariantPrice = v.price !== null && v.price !== undefined ? v.price : opt.price;
const key = `${opt.id}::${v.id}`;
return (
<div key={v.id} className="flex items-center justify-between px-4 py-3">
<div key={v.id}>
<div className="flex items-center justify-between px-4 py-3">
<div>
<p className="text-gray-800">{v.name}</p>
<p className="text-sm text-gray-500">
@@ -999,6 +1018,8 @@ export default function SelfServicePage() {
</button>
</div>
</div>
{picker(key, opt.id) && <div className="px-4 pb-3">{picker(key, opt.id)}</div>}
</div>
);
})}
</div>
@@ -1007,7 +1028,8 @@ export default function SelfServicePage() {
}
const unit = effectiveUnit(opt);
return (
<div key={opt.id} className="flex items-center justify-between border border-gray-200 rounded-xl px-4 py-3.5">
<div key={opt.id} className="border border-gray-200 rounded-xl px-4 py-3.5">
<div className="flex items-center justify-between">
<div>
<p className="font-medium text-gray-800">{opt.name}</p>
<p className="text-sm text-gray-500">{fmtCurrency(unit)}{unit !== opt.price && opt.price > 0 ? ` (was ${fmtCurrency(opt.price)})` : ""}</p>
@@ -1030,6 +1052,8 @@ export default function SelfServicePage() {
</button>
</div>
</div>
{picker(opt.id, opt.id)}
</div>
);
})}
</div>
@@ -115,7 +115,7 @@ function PatternBuilder({ onGenerate }: { onGenerate: (rows: SessionDraft[], mod
);
}
export function SessionsEditor({ sessions, onChange, defaultLocation }: { sessions: KeyedSession[]; onChange: (s: KeyedSession[]) => void; defaultLocation?: string }) {
export function SessionsEditor({ sessions, onChange, defaultLocation, showCapacity }: { sessions: KeyedSession[]; onChange: (s: KeyedSession[]) => void; defaultLocation?: string; showCapacity?: boolean }) {
const [showPattern, setShowPattern] = useState(sessions.length === 0);
const sorted = sortSessions(sessions);
const activeCount = sessions.filter(s => !s.cancelled).length;
@@ -137,7 +137,7 @@ export function SessionsEditor({ sessions, onChange, defaultLocation }: { sessio
const start = new Date(base.getTime() + (last ? 7 * 24 * 60 * 60 * 1000 : 0));
const p = (n: number) => String(n).padStart(2, "0");
const fmt = (d: Date) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
onChange([...sessions, keyed({ label: "", startsAt: fmt(start), endsAt: fmt(new Date(start.getTime() + dur)), location: "", cancelled: false })]);
onChange([...sessions, keyed({ label: "", startsAt: fmt(start), endsAt: fmt(new Date(start.getTime() + dur)), location: "", cancelled: false, capacity: last?.capacity || "" })]);
};
let position = 0;
@@ -180,7 +180,7 @@ export function SessionsEditor({ sessions, onChange, defaultLocation }: { sessio
)}
</div>
{!s.cancelled && (
<div className="grid sm:grid-cols-3 gap-2 pl-8">
<div className={`grid gap-2 pl-8 ${showCapacity ? "sm:grid-cols-4" : "sm:grid-cols-3"}`}>
<label className="text-[11px] text-gray-500">Starts
<input type="datetime-local" className="mt-0.5 w-full border rounded px-2 py-1 text-sm" value={s.startsAt} onChange={e => {
const v = e.target.value;
@@ -197,6 +197,11 @@ export function SessionsEditor({ sessions, onChange, defaultLocation }: { sessio
<label className="text-[11px] text-gray-500">Location (optional)
<input className="mt-0.5 w-full border rounded px-2 py-1 text-sm" placeholder={defaultLocation || "Event location"} value={s.location} onChange={e => update(s.key, { location: e.target.value })} />
</label>
{showCapacity && (
<label className="text-[11px] text-gray-500" title="Most people at this session (picked places plus whole-course tickets). Blank = no limit.">Capacity
<input type="number" min={1} className="mt-0.5 w-full border rounded px-2 py-1 text-sm" placeholder="No limit" value={s.capacity} onChange={e => update(s.key, { capacity: e.target.value })} />
</label>
)}
</div>
)}
{s.hasCheckIns && <p className="pl-8 text-[10px] text-gray-400">Has check-ins — it can be cancelled but not removed.</p>}
@@ -0,0 +1,43 @@
"use client";
import React from "react";
import type { Event } from "@/types";
import { bookableSessions, formatSessionWhen, sessionLabel } from "@/lib/sessions";
// 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 }: {
event: Pick<Event, "sessions">;
option: { id: string; sessionAvailability?: Record<string, number> };
selected: string[];
onChange: (ids: string[]) => void;
quantity?: number;
staff?: boolean;
unitPrice?: number;
}) {
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>;
const toggle = (id: string) => onChange(selected.includes(id) ? selected.filter(x => x !== id) : [...selected, id]);
const allOpen = sessions.filter(s => s.left === null || s.left >= Math.max(1, quantity)).map(s => s.id);
return (
<div className="mt-2 space-y-1" role="group" aria-label="Choose sessions">
<div className="flex items-center justify-between text-xs text-gray-500">
<span>Choose your sessions{unitPrice !== undefined ? ` — R${unitPrice.toFixed(2)} each` : ""}</span>
<button type="button" className="text-brand-700 hover:underline" onClick={() => onChange(selected.length === allOpen.length ? [] : allOpen)}>
{selected.length === allOpen.length && allOpen.length > 0 ? "Clear" : "Select all"}
</button>
</div>
{sessions.map(s => {
const full = s.left !== null && s.left < Math.max(1, quantity);
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>
);
}
@@ -37,6 +37,9 @@ export const supervisorEventsHelpContent: HelpContent = {
<GuideItem icon={CalendarDays} title="Multiple sessions" tone="brand">
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.
</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.
</GuideItem>
+22 -2
View File
@@ -22,6 +22,7 @@ export type SessionDraft = {
endsAt: string;
location: string;
cancelled: boolean;
capacity: string; // "pick sessions" events; blank = no limit
hasCheckIns?: boolean;
};
@@ -54,7 +55,7 @@ export function generateSessions(p: SessionPattern): SessionDraft[] {
const start = atTime(day, p.startTime);
let end = atTime(day, p.endTime);
if (end <= start) end = new Date(end.getTime() + 24 * 60 * 60 * 1000);
out.push({ label: "", startsAt: localDT(start), endsAt: localDT(end), location: "", cancelled: false });
out.push({ label: "", startsAt: localDT(start), endsAt: localDT(end), location: "", cancelled: false, capacity: "" });
};
if (p.frequency === "daily") {
@@ -83,7 +84,7 @@ export function generateSessions(p: SessionPattern): SessionDraft[] {
}
export function toSessionDraft(s: EventSession & { hasCheckIns?: boolean }): SessionDraft {
return { id: s.id, label: s.label || "", startsAt: localDT(new Date(s.startsAt)), endsAt: localDT(new Date(s.endsAt)), location: s.location || "", cancelled: !!(s.cancelled || s.cancelledAt), hasCheckIns: s.hasCheckIns };
return { id: s.id, label: s.label || "", startsAt: localDT(new Date(s.startsAt)), endsAt: localDT(new Date(s.endsAt)), location: s.location || "", cancelled: !!(s.cancelled || s.cancelledAt), capacity: s.capacity ? String(s.capacity) : "", hasCheckIns: s.hasCheckIns };
}
/** Sessions that aren't cancelled, earliest first. */
@@ -172,3 +173,22 @@ export function todaysSession(event: Pick<Event, "scheduleType" | "sessions"> |
const list = activeSessions(event.sessions).filter(x => new Date(x.startsAt).toDateString() === today);
return list.find(s => now <= new Date(s.endsAt).getTime()) || list[list.length - 1] || null;
}
/** 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";
}
/**
* Sessions a pick option can still be booked for, with places left (null = no limit).
* Public: before the session starts; staff: until it ends.
*/
export function bookableSessions(event: Pick<Event, "sessions">, option: { sessionAvailability?: Record<string, number> }, staff = false): (EventSession & { left: number | null })[] {
const now = Date.now();
return activeSessions(event.sessions)
.filter(s => new Date(staff ? s.endsAt : s.startsAt).getTime() > now)
.map(s => {
const limits = [s.placesLeft, option.sessionAvailability?.[s.id]].filter((n): n is number => typeof n === "number");
return { ...s, left: limits.length ? Math.min(...limits) : null };
});
}
+7 -3
View File
@@ -1,6 +1,6 @@
import type { Ticket as TicketRecord } from "@/types";
import { formatDate } from "@/lib/date";
import { scheduleSummary } from "@/lib/sessions";
import { formatSessionWhen, scheduleSummary } from "@/lib/sessions";
// One ticket design for every printed ticket (user dashboard, staff event tickets, at the
// door). The emailed/WhatsApp PDF in backend/src/controllers/ticketController.js mirrors it.
@@ -18,8 +18,12 @@ export function ticketTypeLabel(t: TicketRecord): string {
/** A ticket card. `buyerName` adds a "Purchased by" line. */
export function ticketCardHtml(t: TicketRecord, buyerName?: string | null): string {
const eventTitle = t.event?.title || t.eventId || "Event";
// Multi-day events: "5 sessions · Wed 4 Nov – Wed 2 Dec" — one ticket for every session.
const dateStr = (t.event && scheduleSummary(t.event)) || (t.event?.startDate ? formatDate(t.event.startDate) : "");
// Multi-day events: "5 sessions · Wed 4 Nov – Wed 2 Dec" — one ticket for every session;
// a picked-session ticket shows its own session.
const session = t.registrationOption?.session;
const dateStr = session
? `${session.label || "Session"} · ${formatSessionWhen(session)}`
: (t.event && scheduleSummary(t.event)) || (t.event?.startDate ? formatDate(t.event.startDate) : "");
const qrSrc = `https://api.qrserver.com/v1/create-qr-code/?size=320x320&data=${encodeURIComponent(t.qrCode || t.id)}`;
return `
<div class="ticket">
+17
View File
@@ -73,6 +73,10 @@ export type EventOption = {
earlyBirdTiers?: EarlyBirdTier[];
createdAt?: string;
updatedAt?: string;
// "Pick sessions" events: `pick` options are bought per session (price per session).
sessionMode?: "all" | "pick";
// Pick options with a stock limit: places left per session id.
sessionAvailability?: Record<string, number>;
};
export type FormFieldType =
@@ -158,6 +162,8 @@ export type Event = {
// When public registration closes: the deadline, the first session (or session N with
// late joining), or the end date — whichever applies.
registrationClosesAt?: string;
// "Pick sessions" events: when whole-course tickets stop selling.
wholeCourseClosesAt?: string | null;
lateJoinEnabled?: boolean;
lateJoinUntilSessionId?: string | null;
latePricing?: "full" | "prorata" | "fixed";
@@ -182,6 +188,11 @@ export type EventSession = {
cancelledAt?: string | null;
cancelled?: boolean;
order?: number;
// "Pick sessions" events: optional limit on people, and (single-event responses) how
// many are booked and how many places are left (null = no limit).
capacity?: number | null;
booked?: number;
placesLeft?: number | null;
};
export type LateJoinInfo = {
@@ -208,6 +219,9 @@ export type CertificateConfig = {
export type AttendanceColumn = { id: string; label: string; startsAt: string; endsAt: string; cancelled: boolean };
export type AttendanceRow = {
ticketId: string;
// Picked-session bookings: the sessions booked (null = all) and the ticket for each.
bookedSessions?: string[] | null;
sessionTickets?: Record<string, string> | null;
name: string;
bookedBy: string;
email: string;
@@ -458,6 +472,9 @@ export type RegistrationOption = {
appliedTierId?: string | null;
eventOption?: EventOption;
variant?: OptionVariant | null;
// Picked-session lines: the one session this line (and its ticket) is for.
sessionId?: string | null;
session?: EventSession | null;
// One entry per price the line was bought at (e.g. some before an early-bird tier expired).
tranches?: { id: string; quantity: number; priceSnapshot: number; appliedTierId?: string | null }[];
tickets?: { id: string; quantity?: number }[];
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "hope-events",
"version": "1.34.0",
"version": "1.35.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hope-events",
"version": "1.34.0",
"version": "1.35.0",
"license": "ISC",
"devDependencies": {
"@playwright/test": "^1.64.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "hope-events",
"version": "1.34.0",
"version": "1.35.0",
"main": "index.js",
"scripts": {
"dev:backend": "cd backend && npm run dev",
+2 -2
View File
@@ -36,7 +36,7 @@ export default defineConfig({
cwd: "backend",
url: `http://localhost:${API_PORT}/api/settings/needs-setup`,
timeout: 180_000,
reuseExistingServer: false,
reuseExistingServer: !ci,
stdout: "ignore",
stderr: "pipe",
env: {
@@ -56,7 +56,7 @@ export default defineConfig({
cwd: "frontend",
url: `http://localhost:${WEB_PORT}`,
timeout: 600_000,
reuseExistingServer: false,
reuseExistingServer: !ci,
stdout: "ignore",
stderr: "pipe",
env: {