- 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>
214 lines
11 KiB
JavaScript
214 lines
11 KiB
JavaScript
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 };
|