Events can take a waitlist for sold-out lines. When places free up (cancellation, smaller booking, higher stock limit) the next person is offered them with a claim link and the places are held for a set time; unclaimed offers expire and move on, with optional partial offers. Stock is now counted in one place (utils/stock.js), including held places, and manual registrations and booking edits are now stock-checked. Adds a supervisor waitlist page, My Events waitlists, and the daily summary count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
196 lines
9.6 KiB
JavaScript
196 lines
9.6 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { safeErrorMessage } = require('../utils/errorUtils');
|
|
const { findAccountByContact } = require('../utils/accountMatch');
|
|
const { remainingFor } = require('../utils/stock');
|
|
const { ACTIVE, makeOffer, queueWaitlist, findOfferByToken } = require('../utils/waitlist');
|
|
|
|
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 } },
|
|
};
|
|
|
|
// @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?, quantity, guestName?, guestEmail?, guestPhone? }
|
|
const joinWaitlist = async (req, res) => {
|
|
try {
|
|
const { eventId, eventOptionId, variantId = 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 } } } });
|
|
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'); }
|
|
|
|
const free = await remainingFor(option, variant);
|
|
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, 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, userId, quantity: qty, position: (last._max.position || 0) + 1 },
|
|
include: ENTRY_INCLUDE,
|
|
});
|
|
const ahead = await prisma.waitlistEntry.count({ where: { eventId, eventOptionId, variantId, 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.event.startDate,
|
|
eventOptionId: entry.eventOptionId,
|
|
optionName: entry.eventOption.name,
|
|
variantId: entry.variantId,
|
|
variantName: entry.variant?.name || null,
|
|
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 } });
|
|
if (!entry) { res.status(404); throw new Error('Waitlist entry not found'); }
|
|
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 };
|