/** * 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} 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(`

A place has opened up!

Hi ${user.name},

${line}

${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} 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, };