- Waitlist per session: entries for pick-sessions tickets name a session; offers use that session's free places, never run past its start, and claim links tick it. Held offers count towards session capacity. - At the door: the Select items pop-up has the session picker, with today's session preselected for new attendees. - On pick-sessions events every ticket shows the session list straight away. "All sessions" tickets record the ticked sessions (RegistrationOption.sessionIds) at one price; scanning, attendance, certificates, reminders and capacity follow them. - Fix: per-session stock limits weren't enforced (takenCount filtered WaitlistEntry by a sessionId column that didn't exist, and the error was swallowed), and the public event page lost its stock info. - Fix: editing a door registration with picked sessions couldn't be saved. - Fix: the offline-scanning service worker served /_next/static cache-first forever, so dev browsers that had opened the scanner ran stale code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
251 lines
12 KiB
JavaScript
251 lines
12 KiB
JavaScript
/**
|
||
* Waitlist for sold-out ticket options.
|
||
*
|
||
* Rules:
|
||
* - Only on events with waitlistEnabled, and only for a line that's actually sold out.
|
||
* - When places free up (a cancellation, a smaller booking, a raised stock limit, or an
|
||
* offer expiring), processWaitlist offers them to the next people in line. An offer
|
||
* holds the places for event.waitlistOfferHours — they count as taken (utils/stock.js)
|
||
* so the public site can't sell them — and sends a claim link by email/WhatsApp.
|
||
* - If fewer places are free than someone asked for: with waitlistPartialOffers they're
|
||
* offered what's free; otherwise they're skipped for the next person who fits.
|
||
* - Claiming = registering with the link (createRegistration's waitlistToken). Early-bird
|
||
* and coupon rules apply as usual. An unclaimed offer expires and moves on.
|
||
* - "Pick sessions" options are waited for per session (entry.sessionId): free places are
|
||
* that session's, an offer never runs past the session's start, and nobody is offered a
|
||
* session that has started or was cancelled. Main tickets also need room in the
|
||
* session(s) they cover when sessions have a capacity (utils/sessions.js).
|
||
*/
|
||
|
||
const crypto = require('crypto');
|
||
const prisma = require('../config/db');
|
||
const { remainingFor } = require('./stock');
|
||
const { activeSessions, isPickOption, sessionBookings, sessionPlacesFree, sessionLabel, wholeCourseClosesAt } = require('./sessions');
|
||
|
||
const ACTIVE = ['waiting', 'offered'];
|
||
|
||
function waitlistError(message, statusCode = 400) {
|
||
const err = new Error(message);
|
||
err.statusCode = statusCode;
|
||
return err;
|
||
}
|
||
|
||
const hashToken = (token) => crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`waitlist:${token}`).digest('hex');
|
||
|
||
/**
|
||
* Places free on a waitlist line: option/variant stock (per session for a picked line)
|
||
* and, for main tickets on events with session capacities, room in the session(s).
|
||
* @param {object} event - with sessions
|
||
* @returns {Promise<number|null>} null when unlimited
|
||
*/
|
||
async function placesFree(event, option, variant, sessionId = null, { db = prisma, excludeEntryId = null } = {}) {
|
||
const limits = [await remainingFor(option, variant, { sessionId, db, excludeEntryId })];
|
||
if (event.scheduleType === 'sessions' && option.isMainTicket && (event.sessions || []).some(s => s.capacity > 0)) {
|
||
limits.push(sessionPlacesFree(event.sessions, await sessionBookings(event.id, db, { excludeEntryId }), sessionId));
|
||
}
|
||
const known = limits.filter(n => n !== null);
|
||
return known.length ? Math.min(...known) : null;
|
||
}
|
||
|
||
/** Whether a waitlist line can still be offered at `now` (its session hasn't started, etc.). */
|
||
function lineOpen(event, sessionId, now = new Date()) {
|
||
if (!sessionId) return event.scheduleType !== 'sessions' || now < wholeCourseClosesAt(event, event.sessions);
|
||
const session = activeSessions(event.sessions).find(s => s.id === sessionId);
|
||
return !!session && new Date(session.startsAt) > now;
|
||
}
|
||
|
||
/** When an offer made now lapses: the event's offer hours, but never after its session starts. */
|
||
function offerExpiry(hours, session = null, now = new Date()) {
|
||
const expires = new Date(now.getTime() + (hours || 24) * 60 * 60 * 1000);
|
||
return session && new Date(session.startsAt) < expires ? new Date(session.startsAt) : expires;
|
||
}
|
||
|
||
/** "Drop-in (Large)" or "Drop-in — Class 2, Wed 4 Nov" for messages. */
|
||
function lineName(entry, sessions = entry.event?.sessions) {
|
||
const base = `${entry.eventOption.name}${entry.variant ? ` (${entry.variant.name})` : ''}`;
|
||
if (!entry.session) return base;
|
||
const when = new Date(entry.session.startsAt).toLocaleDateString('en-GB', { timeZone: process.env.APP_TIMEZONE || 'Africa/Johannesburg', weekday: 'short', day: 'numeric', month: 'short' });
|
||
return `${base} — ${sessionLabel(entry.session, sessions || [entry.session])}, ${when}`;
|
||
}
|
||
|
||
function claimLink(token) {
|
||
const base = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').split(',')[0].trim().replace(/\/$/, '');
|
||
return `${base}/waitlist/claim/${token}`;
|
||
}
|
||
|
||
/**
|
||
* Who gets offered what, given the free places on a line. Pure.
|
||
* @param {Array} waiting - entries in queue order [{ id, quantity }]
|
||
* @param {number} free - places free
|
||
* @param {boolean} partial - offer what's free when it's less than asked for
|
||
* @returns {Array<{ entryId: string, quantity: number }>}
|
||
*/
|
||
function pickOffers(waiting, free, partial) {
|
||
const offers = [];
|
||
let left = free;
|
||
for (const entry of waiting) {
|
||
if (left <= 0) break;
|
||
if (entry.quantity <= left) {
|
||
offers.push({ entryId: entry.id, quantity: entry.quantity });
|
||
left -= entry.quantity;
|
||
} else if (partial) {
|
||
// First in line gets what's free; nobody behind them jumps the queue.
|
||
offers.push({ entryId: entry.id, quantity: left });
|
||
left = 0;
|
||
}
|
||
// else: skip to the next person who fits
|
||
}
|
||
return offers;
|
||
}
|
||
|
||
async function notifyOffer(entry, token) {
|
||
const { sendMail, emailWrapper, ctaButton, fallbackLink } = require('./email');
|
||
const { shouldEmail, waText, waTextAny } = require('./notify');
|
||
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
|
||
const user = entry.user;
|
||
const link = claimLink(token);
|
||
const item = lineName(entry);
|
||
const until = new Date(entry.offerExpiresAt).toLocaleString('en-GB', { day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' });
|
||
const partialNote = entry.offeredQuantity < entry.quantity
|
||
? ` You asked for ${entry.quantity}; ${entry.offeredQuantity} came free, and you can take those now.` : '';
|
||
const line = `${entry.offeredQuantity} × ${item} for ${entry.event.title} ${entry.offeredQuantity === 1 ? 'is' : 'are'} now available for you.${partialNote} We'll hold ${entry.offeredQuantity === 1 ? 'it' : 'them'} until ${until}.`;
|
||
|
||
const hasEmail = user?.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
|
||
const sends = [];
|
||
if (hasEmail && shouldEmail(user)) {
|
||
const html = emailWrapper(`
|
||
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">A place has opened up!</p>
|
||
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${user.name}</strong>,</p>
|
||
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">${line}</p>
|
||
${ctaButton('Claim my place', link)}
|
||
${fallbackLink(link)}
|
||
`, { preheader: `Your waitlist place for ${entry.event.title}` });
|
||
sends.push(sendMail({ to: user.email, subject: `A place is available: ${entry.event.title}`, html, text: `Hi ${user.name},\n\n${line}\n\nClaim it here: ${link}` }));
|
||
}
|
||
const wa = `🎟️ Hi ${user?.name || 'there'}, ${line} Claim it here: ${link}`;
|
||
sends.push(hasEmail ? waText(user, wa) : waTextAny(user, wa));
|
||
await Promise.allSettled(sends);
|
||
}
|
||
|
||
const OFFER_INCLUDE = { event: { include: { sessions: true } }, eventOption: true, variant: true, session: true, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } } };
|
||
|
||
/** Puts an entry on offer until `expiresAt` (inside the caller's transaction); returns the raw token. */
|
||
async function writeOffer(db, entry, quantity, expiresAt) {
|
||
const token = crypto.randomBytes(24).toString('hex');
|
||
await db.waitlistEntry.update({
|
||
where: { id: entry.id },
|
||
data: {
|
||
status: 'offered',
|
||
offeredQuantity: quantity,
|
||
offerTokenHash: hashToken(token),
|
||
offeredAt: new Date(),
|
||
offerExpiresAt: expiresAt,
|
||
},
|
||
});
|
||
return token;
|
||
}
|
||
|
||
async function notifyOffers(offers) {
|
||
for (const { entryId, token } of offers) {
|
||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: OFFER_INCLUDE });
|
||
if (entry) notifyOffer(entry, token).catch(e => console.warn('[waitlist] notify failed:', e?.message || e));
|
||
}
|
||
}
|
||
|
||
/** Offers `quantity` places to one entry (e.g. a supervisor's manual offer) and notifies. */
|
||
async function makeOffer(entryId, quantity) {
|
||
const entry = await prisma.waitlistEntry.findUnique({ where: { id: entryId }, include: { event: true, session: true } });
|
||
const token = await writeOffer(prisma, entry, quantity, offerExpiry(entry.event.waitlistOfferHours, entry.session));
|
||
await notifyOffers([{ entryId, token }]);
|
||
return prisma.waitlistEntry.findUnique({ where: { id: entryId } });
|
||
}
|
||
|
||
/**
|
||
* Offers any free places on an event to the people waiting for them. Safe to call any
|
||
* time places might have freed up; runs one event at a time under an advisory lock.
|
||
* @returns {Promise<number>} offers made
|
||
*/
|
||
async function processWaitlist(eventId) {
|
||
const event = await prisma.event.findUnique({
|
||
where: { id: eventId },
|
||
include: { eventOptions: { include: { variants: true } }, sessions: true },
|
||
});
|
||
if (!event || !event.waitlistEnabled || !event.isActive || event.cashupStatus === 'closed') return 0;
|
||
if (new Date(event.endDate) < new Date()) return 0;
|
||
|
||
const made = [];
|
||
await prisma.$transaction(async (tx) => {
|
||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${'waitlist:' + eventId}))`;
|
||
const waiting = await tx.waitlistEntry.findMany({ where: { eventId, status: 'waiting' }, orderBy: { position: 'asc' } });
|
||
const lineKey = (w) => `${w.eventOptionId}::${w.variantId || ''}::${w.sessionId || ''}`;
|
||
const lines = [...new Map(waiting.map(w => [lineKey(w), w])).values()];
|
||
for (const sample of lines) {
|
||
const option = event.eventOptions.find(o => o.id === sample.eventOptionId);
|
||
const variant = sample.variantId ? (option?.variants || []).find(v => v.id === sample.variantId) : null;
|
||
if (!option || !lineOpen(event, sample.sessionId)) continue;
|
||
// An option switched between per-session and whole-course after people joined.
|
||
if (!!sample.sessionId !== isPickOption(event, option)) continue;
|
||
const free = await placesFree(event, option, variant, sample.sessionId, { db: tx });
|
||
if (free === null || free <= 0) continue;
|
||
const session = sample.sessionId ? event.sessions.find(s => s.id === sample.sessionId) : null;
|
||
const queue = waiting.filter(w => lineKey(w) === lineKey(sample));
|
||
for (const offer of pickOffers(queue, free, event.waitlistPartialOffers)) {
|
||
const token = await writeOffer(tx, { id: offer.entryId }, offer.quantity, offerExpiry(event.waitlistOfferHours, session));
|
||
made.push({ entryId: offer.entryId, token });
|
||
}
|
||
}
|
||
}, { timeout: 20000 });
|
||
await notifyOffers(made);
|
||
return made.length;
|
||
}
|
||
|
||
/** Marks lapsed offers as expired. @returns event ids that now have places to re-offer. */
|
||
async function expireOffers(now = new Date()) {
|
||
const lapsed = await prisma.waitlistEntry.findMany({ where: { status: 'offered', offerExpiresAt: { lte: now } }, select: { id: true, eventId: true } });
|
||
if (lapsed.length === 0) return [];
|
||
await prisma.waitlistEntry.updateMany({ where: { id: { in: lapsed.map(l => l.id) } }, data: { status: 'expired', offerTokenHash: null } });
|
||
return [...new Set(lapsed.map(l => l.eventId))];
|
||
}
|
||
|
||
/** Background job: expire lapsed offers, then re-offer free places everywhere waiting. */
|
||
async function runWaitlistJob() {
|
||
const expiredEvents = await expireOffers();
|
||
const waitingEvents = await prisma.waitlistEntry.findMany({ where: { status: 'waiting' }, distinct: ['eventId'], select: { eventId: true } });
|
||
const ids = new Set([...expiredEvents, ...waitingEvents.map(w => w.eventId)]);
|
||
let offers = 0;
|
||
for (const id of ids) offers += await processWaitlist(id).catch(() => 0);
|
||
return { expired: expiredEvents.length, offers };
|
||
}
|
||
|
||
/** Fire-and-forget re-offer after something freed places on an event. */
|
||
function queueWaitlist(eventId) {
|
||
if (!eventId) return;
|
||
processWaitlist(eventId).catch(e => console.warn('[waitlist] process failed:', e?.message || e));
|
||
}
|
||
|
||
/** A live offer from its claim token, or null. */
|
||
async function findOfferByToken(token) {
|
||
if (!token || typeof token !== 'string' || token.length > 100) return null;
|
||
return prisma.waitlistEntry.findUnique({
|
||
where: { offerTokenHash: hashToken(token) },
|
||
include: { event: { include: { sessions: true } }, eventOption: true, variant: true, session: true, user: { select: { id: true, name: true } } },
|
||
});
|
||
}
|
||
|
||
module.exports = {
|
||
ACTIVE,
|
||
waitlistError,
|
||
hashToken,
|
||
pickOffers,
|
||
placesFree,
|
||
lineOpen,
|
||
offerExpiry,
|
||
lineName,
|
||
makeOffer,
|
||
processWaitlist,
|
||
expireOffers,
|
||
runWaitlistJob,
|
||
queueWaitlist,
|
||
findOfferByToken,
|
||
};
|