const prisma = require('../config/db'); const { v4: uuidv4 } = require('uuid'); const { safeErrorMessage } = require('../utils/errorUtils'); const { findAccountByContact } = require('../utils/accountMatch'); const { ACTIVE, makeOffer, queueWaitlist, findOfferByToken, placesFree, lineName } = require('../utils/waitlist'); const { activeSessions, isPickOption, sessionLabel } = require('../utils/sessions'); const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) }); const ENTRY_INCLUDE = { event: { select: { id: true, title: true, startDate: true, picture: true } }, eventOption: { select: { id: true, name: true } }, variant: { select: { id: true, name: true } }, session: { select: { id: true, label: true, startsAt: true, endsAt: true } }, }; // @desc Join the waitlist for a sold-out option // @route POST /api/waitlist // @access Public (login needed when the event requires it) // Body: { eventId, eventOptionId, variantId?, sessionId?, quantity, guestName?, guestEmail?, guestPhone? } // sessionId: required for a "pick sessions" option (one session per entry), not allowed otherwise. const joinWaitlist = async (req, res) => { try { const { eventId, eventOptionId, variantId = null, sessionId = null, quantity, guestName, guestEmail, guestPhone } = req.body || {}; const qty = parseInt(quantity, 10); if (!eventId || !eventOptionId || !(qty >= 1)) { res.status(400); throw new Error('eventId, eventOptionId and a quantity of at least 1 are required'); } const event = await prisma.event.findUnique({ where: { id: eventId }, include: { eventOptions: { include: { variants: true } }, sessions: true } }); if (!event || !event.isActive) { res.status(404); throw new Error('Event not found'); } if (!event.waitlistEnabled) { res.status(400); throw new Error('This event does not have a waitlist.'); } if (event.cashupStatus === 'closed' || new Date(event.endDate) < new Date()) { res.status(400); throw new Error('This event is no longer taking registrations.'); } const option = event.eventOptions.find(o => o.id === eventOptionId); if (!option) { res.status(400); throw new Error('Unknown ticket option for this event'); } const variant = variantId ? (option.variants || []).find(v => v.id === variantId) : null; if (variantId && !variant) { res.status(400); throw new Error('Unknown variant for this option'); } if (isPickOption(event, option)) { if (!sessionId) { res.status(400); throw new Error(`Choose which session of "${option.name}" you want to wait for.`); } const session = activeSessions(event.sessions).find(s => s.id === sessionId); if (!session) { res.status(400); throw new Error('That session isn\'t available.'); } if (new Date(session.startsAt) <= new Date()) { res.status(400); throw new Error(`${sessionLabel(session, event.sessions)} has already started.`); } } else if (sessionId) { res.status(400); throw new Error(`"${option.name}" covers every session — no session to choose.`); } const free = await placesFree(event, option, variant, sessionId); if (free === null || free >= qty) { res.status(400); throw new Error('There are places available — you can register now.'); } // Who's joining let userId = req.user?.id; if (!userId) { if (event.requiresAuth !== false) { res.status(401); throw new Error('Log in to join the waitlist for this event.'); } if (!guestName || !guestEmail) { res.status(400); throw new Error('Name and email are required to join the waitlist'); } const match = await findAccountByContact({ email: guestEmail, phone: guestPhone }); if (match.conflict) { res.status(409); throw new Error('This email and phone number belong to two different accounts. Please log in, or check your details.'); } userId = match.user?.id || (await prisma.user.create({ data: { id: uuidv4(), name: String(guestName).trim(), email: String(guestEmail).trim(), password: '', phoneNumber: guestPhone || null, isActive: false, updatedAt: new Date() }, })).id; } const existing = await prisma.waitlistEntry.findFirst({ where: { userId, eventOptionId, variantId, sessionId, status: { in: ACTIVE } } }); if (existing) { res.status(409); throw new Error('You\'re already on the waitlist for this.'); } const last = await prisma.waitlistEntry.aggregate({ where: { eventId }, _max: { position: true } }); const entry = await prisma.waitlistEntry.create({ data: { eventId, eventOptionId, variantId, sessionId, userId, quantity: qty, position: (last._max.position || 0) + 1 }, include: ENTRY_INCLUDE, }); const ahead = await prisma.waitlistEntry.count({ where: { eventId, eventOptionId, variantId, sessionId, status: 'waiting', position: { lt: entry.position } } }); res.status(201).json({ ...entry, ahead, message: `You're on the waitlist${ahead ? ` — ${ahead} ahead of you` : ''}. We'll email or WhatsApp you if a place opens up.` }); } catch (error) { fail(res, error); } }; // @desc The caller's waitlist entries // @route GET /api/waitlist/mine // @access Private const myWaitlist = async (req, res) => { try { const entries = await prisma.waitlistEntry.findMany({ where: { userId: req.user.id, status: { in: ACTIVE }, event: { endDate: { gte: new Date() } } }, include: ENTRY_INCLUDE, orderBy: { createdAt: 'desc' }, }); res.json(entries); } catch (error) { fail(res, error); } }; // @desc Leave the waitlist (an open offer is passed on) // @route DELETE /api/waitlist/:id // @access Private (owner) const leaveWaitlist = async (req, res) => { try { const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } }); if (!entry || entry.userId !== req.user.id) { res.status(404); throw new Error('Waitlist entry not found'); } if (!ACTIVE.includes(entry.status)) { res.status(400); throw new Error('You\'re no longer on this waitlist.'); } await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { status: 'left', offerTokenHash: null } }); if (entry.status === 'offered') queueWaitlist(entry.eventId); res.json({ message: 'You\'ve left the waitlist.' }); } catch (error) { fail(res, error); } }; // @desc What a claim link offers // @route GET /api/waitlist/claim/:token // @access Public (the token is the credential) const claimInfo = async (req, res) => { try { const entry = await findOfferByToken(req.params.token); if (!entry) { res.status(404); throw new Error('This waitlist link is not valid or has already been used.'); } const expired = entry.status !== 'offered' || new Date(entry.offerExpiresAt) < new Date(); res.json({ status: expired ? 'expired' : 'offered', eventId: entry.eventId, eventTitle: entry.event.title, eventStart: entry.session?.startsAt || entry.event.startDate, eventOptionId: entry.eventOptionId, optionName: entry.eventOption.name, variantId: entry.variantId, variantName: entry.variant?.name || null, sessionId: entry.sessionId, sessionName: entry.session ? sessionLabel(entry.session, entry.event.sessions) : null, sessionStart: entry.session?.startsAt || null, lineName: lineName(entry), quantity: entry.offeredQuantity, requested: entry.quantity, expiresAt: entry.offerExpiresAt, name: entry.user?.name, }); } catch (error) { fail(res, error); } }; // @desc The waitlist for an event // @route GET /api/waitlist/events/:eventId // @access Private/Staff const eventWaitlist = async (req, res) => { try { const entries = await prisma.waitlistEntry.findMany({ where: { eventId: req.params.eventId, ...(req.query.all === 'true' ? {} : { status: { in: [...ACTIVE, 'claimed', 'expired'] } }) }, include: { ...ENTRY_INCLUDE, user: { select: { id: true, name: true, email: true, phoneNumber: true } } }, orderBy: { position: 'asc' }, }); res.json(entries); } catch (error) { fail(res, error); } }; // @desc Offer a place to someone on the waitlist now (even without free stock) // @route POST /api/waitlist/:id/offer // @access Private/Supervisor const offerManually = async (req, res) => { try { const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id }, include: { session: true } }); if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); } if (entry.session && (entry.session.cancelledAt || new Date(entry.session.startsAt) <= new Date())) { res.status(400); throw new Error(`The session they're waiting for has ${entry.session.cancelledAt ? 'been cancelled' : 'already started'}.`); } if (!['waiting', 'expired'].includes(entry.status)) { res.status(400); throw new Error(`This person is ${entry.status === 'offered' ? 'already holding an offer' : `no longer waiting (${entry.status})`}.`); } const qty = Math.max(1, Math.min(entry.quantity, parseInt(req.body?.quantity, 10) || entry.quantity)); const updated = await makeOffer(entry.id, qty); res.json({ ...updated, message: `Offered ${qty} place(s); the claim link has been sent.` }); } catch (error) { fail(res, error); } }; // @desc Move someone up or down the queue // @route POST /api/waitlist/:id/move { direction: 'up' | 'down' } // @access Private/Supervisor const moveEntry = async (req, res) => { try { const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } }); if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); } const up = req.body?.direction === 'up'; const neighbour = await prisma.waitlistEntry.findFirst({ where: { eventId: entry.eventId, status: 'waiting', position: up ? { lt: entry.position } : { gt: entry.position } }, orderBy: { position: up ? 'desc' : 'asc' }, }); if (!neighbour) return res.json({ message: 'Already at the ' + (up ? 'front.' : 'back.') }); await prisma.$transaction([ prisma.waitlistEntry.update({ where: { id: entry.id }, data: { position: neighbour.position } }), prisma.waitlistEntry.update({ where: { id: neighbour.id }, data: { position: entry.position } }), ]); res.json({ message: 'Moved.' }); } catch (error) { fail(res, error); } }; // @desc Take someone off the waitlist (an open offer is passed on) // @route POST /api/waitlist/:id/remove // @access Private/Supervisor const removeEntry = async (req, res) => { try { const entry = await prisma.waitlistEntry.findUnique({ where: { id: req.params.id } }); if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); } if (!ACTIVE.includes(entry.status)) { res.status(400); throw new Error('This person is not on the waitlist any more.'); } await prisma.waitlistEntry.update({ where: { id: entry.id }, data: { status: 'removed', offerTokenHash: null } }); if (entry.status === 'offered') queueWaitlist(entry.eventId); res.json({ message: 'Removed from the waitlist.' }); } catch (error) { fail(res, error); } }; module.exports = { joinWaitlist, myWaitlist, leaveWaitlist, claimInfo, eventWaitlist, offerManually, moveEntry, removeEntry };