Supervisors can split an event's named attendees into allocation sets (rooms, small groups, buses) with capacity, gender and leader per group, using a drag-and-drop board or auto-allocate (keep bookings together or split them). Adds printable group and master lists (medical view audited), the group on tickets and in My Events, messaging by group, a "My groups" page for leaders, and an optional per-event gender question for named attendees. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2297 lines
98 KiB
JavaScript
2297 lines
98 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
|
|
const { emailTickets } = require('./ticketController');
|
|
const { hashPassword } = require('../config/auth');
|
|
const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue, refreshPricingForRegistration, attachComputedTotals, attachComputedTotalsToList } = require('../utils/pricing');
|
|
const { assertEventOpen } = require('../utils/cashupUtils');
|
|
const { logAdminAction } = require('../utils/adminAudit');
|
|
const { getClientIp } = require('../utils/requestUtils');
|
|
const { buildCouponLines, previewCoupon, applyCouponToRegistration, recalculateRegistrationDiscount, releaseRegistrationCoupons, describeCoupon } = require('../utils/coupons');
|
|
const { lineKey, validateAttendees, createAttendees, namedPlacesByKey } = require('../utils/attendees');
|
|
const { findAccountByContact } = require('../utils/accountMatch');
|
|
const { ensureConsentRequests } = require('../utils/consent');
|
|
const { takenCount } = require('../utils/stock');
|
|
const { findOfferByToken, queueWaitlist } = require('../utils/waitlist');
|
|
|
|
// Youth events: send parental consent requests for any new under-18 attendees. Runs in
|
|
// the background so a slow mail/WhatsApp send never holds up the registration.
|
|
function queueConsentRequests(registrationId) {
|
|
ensureConsentRequests(registrationId).catch(e => console.error('[consent] Failed to send requests:', e?.message || e));
|
|
}
|
|
|
|
// Named attendees for API responses, with whether their ticket has been issued/used.
|
|
const ATTENDEES_INCLUDE = {
|
|
orderBy: { order: 'asc' },
|
|
include: {
|
|
ticket: { select: { id: true, isUsed: true } },
|
|
// Latest consent request (youth events) — it decides check-in.
|
|
consentRequests: { orderBy: { createdAt: 'desc' }, take: 1, select: { id: true, status: true, respondedAt: true, lastSentAt: true, textVersion: true } },
|
|
},
|
|
};
|
|
|
|
const TICKET_DELIVERY = ['payer', 'each'];
|
|
|
|
/**
|
|
* Places each line will have once `requested` is added to `existingLines` — used to
|
|
* check attendee names against what's actually being bought.
|
|
*/
|
|
function placesAfter(existingLines, requested) {
|
|
const out = {};
|
|
for (const l of [...(existingLines || []), ...(requested || [])]) {
|
|
const k = lineKey(l.eventOptionId, l.variantId);
|
|
out[k] = (out[k] || 0) + Number(l.quantity || 0);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// Active coupon on a registration, for API responses (the discount itself is
|
|
// Registration.discountTotal).
|
|
const ACTIVE_COUPONS_INCLUDE = {
|
|
where: { releasedAt: null },
|
|
select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } },
|
|
};
|
|
|
|
/**
|
|
* Coupon lines for options that haven't been saved yet, so a code can be checked
|
|
* before anything is created. Mirrors buildCouponLines for one tranche per option.
|
|
*/
|
|
function pendingCouponLines(event, resolvedOptions) {
|
|
return buildCouponLines({
|
|
registrationOptions: resolvedOptions.map(o => ({
|
|
eventOptionId: o.eventOptionId,
|
|
variantId: o.variantId || null,
|
|
quantity: o.quantity,
|
|
eventOption: event.eventOptions.find(eo => eo.id === o.eventOptionId),
|
|
tranches: [{ quantity: o.quantity, priceSnapshot: o.priceSnapshot, appliedTierId: o.appliedTierId }],
|
|
})),
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Price one requested option for this buyer: early-bird tiers, then variant/option price,
|
|
* then the staff price for Staff and above. Returns { priceSnapshot, appliedTierId }.
|
|
*/
|
|
async function priceRequestedOption(eventOption, variantId, qty, buyer) {
|
|
try {
|
|
const resolved = variantId
|
|
? await resolveVariantTierPrice(eventOption, variantId, qty, buyer)
|
|
: await resolveOptionPrice(eventOption, qty, buyer);
|
|
return { priceSnapshot: resolved.price, appliedTierId: resolved.tierId };
|
|
} catch (e) {
|
|
const variant = variantId ? (eventOption.variants || []).find(v => v.id === variantId) : null;
|
|
const price = variant && variant.price !== null && variant.price !== undefined ? variant.price : eventOption.price;
|
|
return { priceSnapshot: Number(price || 0), appliedTierId: null };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check overall stock availability for an EventOption. Places held for live waitlist
|
|
* offers count as taken, except the offer being claimed (`excludeEntryId`).
|
|
* Returns { available: boolean, remaining: number|null }
|
|
*/
|
|
async function checkOptionStock(option, requestedQty, excludeEntryId = null) {
|
|
if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null };
|
|
const remaining = option.stockLimit - await takenCount({ eventOptionId: option.id }, { excludeEntryId });
|
|
return { available: remaining >= requestedQty, remaining };
|
|
}
|
|
|
|
/**
|
|
* Check stock availability for a specific variant (same rules as checkOptionStock).
|
|
*/
|
|
async function checkVariantStock(variant, requestedQty, excludeEntryId = null) {
|
|
if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null };
|
|
const remaining = variant.stockLimit - await takenCount({ eventOptionId: variant.eventOptionId, variantId: variant.id }, { excludeEntryId });
|
|
return { available: remaining >= requestedQty, remaining };
|
|
}
|
|
|
|
// @desc Create a new registration
|
|
// @route POST /api/registrations
|
|
// @access Private
|
|
const createRegistration = async (req, res) => {
|
|
try {
|
|
const { eventId, options, guestName, guestEmail, guestPhone, couponCode, attendees, ticketDelivery, waitlistToken } = req.body;
|
|
|
|
// Check if event exists
|
|
const canIncludeVariants = !!(prisma && prisma.optionVariant && typeof prisma.optionVariant.findMany === 'function');
|
|
const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function');
|
|
const optionInclude = canIncludeTiers
|
|
? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: true } : {}) }
|
|
: true;
|
|
const event = await prisma.event.findUnique({
|
|
where: { id: eventId },
|
|
include: { eventOptions: optionInclude ? { include: optionInclude } : true }
|
|
});
|
|
|
|
if (!event) {
|
|
res.status(404);
|
|
throw new Error('Event not found');
|
|
}
|
|
|
|
if (!event.isActive) {
|
|
res.status(400);
|
|
throw new Error('Event is not active');
|
|
}
|
|
|
|
if (event.cashupStatus === 'closed') {
|
|
res.status(400);
|
|
throw new Error('This event is closed and no longer accepting registrations.');
|
|
}
|
|
|
|
// A waitlist claim link: its token identifies the person and releases the places
|
|
// held for them. It works without logging in, the same way a consent link does.
|
|
let waitlistOffer = null;
|
|
if (waitlistToken) {
|
|
waitlistOffer = await findOfferByToken(waitlistToken);
|
|
if (!waitlistOffer || waitlistOffer.eventId !== eventId) { res.status(400); throw new Error('This waitlist link is not valid.'); }
|
|
if (waitlistOffer.status !== 'offered') { res.status(400); throw new Error(`This waitlist offer has ${waitlistOffer.status === 'claimed' ? 'already been used' : 'expired'}.`); }
|
|
if (new Date(waitlistOffer.offerExpiresAt) < new Date()) { res.status(400); throw new Error('This waitlist offer has expired.'); }
|
|
if (req.user && req.user.id !== waitlistOffer.userId) { res.status(403); throw new Error('This waitlist offer belongs to someone else.'); }
|
|
}
|
|
|
|
// Enforce auth unless event explicitly opts out
|
|
const eventRequiresAuth = event.requiresAuth !== false;
|
|
if (eventRequiresAuth && !req.user && !waitlistOffer) {
|
|
res.status(401);
|
|
throw new Error('Authentication required for this event');
|
|
}
|
|
|
|
// Unauthenticated guest registration is only permitted for free events
|
|
if (!req.user && !waitlistOffer) {
|
|
const totalDueCheck = (event.eventOptions || []).reduce((sum, eo) => sum + (eo.price || 0), 0);
|
|
// Also check against the requested options specifically
|
|
const requestedTotal = (options || []).reduce((sum, opt) => {
|
|
const eo = event.eventOptions.find(o => o.id === opt.eventOptionId);
|
|
return sum + ((eo?.price || 0) * (opt.quantity || 1));
|
|
}, 0);
|
|
if (requestedTotal > 0) {
|
|
res.status(400);
|
|
throw new Error('Guest registration is only available for free events. Please create an account to pay for tickets.');
|
|
}
|
|
}
|
|
|
|
// Resolve userId — authenticated or guest
|
|
let userId;
|
|
if (req.user) {
|
|
userId = req.user.id;
|
|
} else if (waitlistOffer) {
|
|
userId = waitlistOffer.userId;
|
|
} else {
|
|
// Guest registration: find existing user by email or create inactive placeholder
|
|
if (!guestEmail || !guestName) {
|
|
res.status(400);
|
|
throw new Error('Name and email are required for unauthenticated registration');
|
|
}
|
|
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.');
|
|
}
|
|
const existingGuest = match.user;
|
|
if (existingGuest) {
|
|
userId = existingGuest.id;
|
|
} else {
|
|
const newGuest = await prisma.user.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
name: guestName,
|
|
email: guestEmail,
|
|
password: '',
|
|
phoneNumber: guestPhone || null,
|
|
isActive: false,
|
|
updatedAt: new Date(),
|
|
}
|
|
});
|
|
userId = newGuest.id;
|
|
}
|
|
}
|
|
|
|
// Enforce registration cutoff for public registrations
|
|
const now = new Date();
|
|
const end = new Date(event.endDate);
|
|
const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null;
|
|
if ((deadline && now >= deadline) || now >= end) {
|
|
res.status(400);
|
|
throw new Error('Registration is closed for this event');
|
|
}
|
|
|
|
// Validate options
|
|
if (!options || !Array.isArray(options) || options.length === 0) {
|
|
res.status(400);
|
|
throw new Error('At least one option must be selected');
|
|
}
|
|
|
|
// Validate each option and resolve prices / check stock.
|
|
// Options are independent of each other, so resolve them concurrently. Errors are marked
|
|
// with `.isStockError` rather than relying on shared `res.statusCode` (which would race
|
|
// across concurrent iterations) — the outer catch always responds 400 regardless.
|
|
const resolvedOptions = await Promise.all(options.map(async (option) => {
|
|
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
|
if (!eventOption) {
|
|
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
|
|
}
|
|
if (!option.quantity || option.quantity < 1) {
|
|
throw new Error('Quantity must be at least 1');
|
|
}
|
|
|
|
const qty = option.quantity;
|
|
|
|
// Check overall option stock
|
|
try {
|
|
const stockCheck = await checkOptionStock(eventOption, qty, waitlistOffer?.id);
|
|
if (!stockCheck.available) {
|
|
const err = new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`);
|
|
err.isStockError = true;
|
|
throw err;
|
|
}
|
|
} catch (e) {
|
|
if (e.isStockError) throw e; // propagate stock errors
|
|
// If stock check function fails (pre-migration), continue without stock check
|
|
}
|
|
|
|
// Check variant stock (if a variant is selected)
|
|
let variantId = option.variantId || null;
|
|
let variantPrice = null;
|
|
if (variantId && canIncludeVariants) {
|
|
const variant = (eventOption.variants || []).find(v => v.id === variantId);
|
|
if (!variant) {
|
|
throw new Error(`Variant not found for option "${eventOption.name}"`);
|
|
}
|
|
try {
|
|
const vStock = await checkVariantStock(variant, qty, waitlistOffer?.id);
|
|
if (!vStock.available) {
|
|
const err = new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`);
|
|
err.isStockError = true;
|
|
throw err;
|
|
}
|
|
} catch (e) {
|
|
if (e.isStockError) throw e;
|
|
}
|
|
variantPrice = variant.price; // null = use option price
|
|
}
|
|
|
|
// Resolve price: variant-level tiers take priority, then option-level tiers, then
|
|
// base prices, then the staff price for Staff and above.
|
|
let priceSnapshot = null;
|
|
let appliedTierId = null;
|
|
if (canIncludeTiers) {
|
|
const buyer = req.user || (waitlistOffer ? await prisma.user.findUnique({ where: { id: waitlistOffer.userId }, select: { id: true, role: true } }) : null);
|
|
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, qty, buyer));
|
|
} else {
|
|
priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null;
|
|
}
|
|
if (priceSnapshot === null) priceSnapshot = eventOption.price;
|
|
|
|
return { ...option, variantId, appliedTierId, priceSnapshot };
|
|
}));
|
|
|
|
// Check a coupon code before anything is saved, so a bad code doesn't leave a
|
|
// half-made registration behind. It's applied (and re-checked under a lock) below.
|
|
if (couponCode) {
|
|
const buyer = req.user ? { id: req.user.id, role: req.user.role, email: req.user.email } : null;
|
|
await previewCoupon({ code: couponCode, eventId, user: buyer, lines: pendingCouponLines(event, resolvedOptions) });
|
|
}
|
|
|
|
// Check for existing non-cancelled registration for this user+event → merge instead
|
|
const existingReg = await prisma.registration.findFirst({
|
|
where: { userId, eventId, status: { not: 'cancelled' } },
|
|
include: { registrationOptions: true }
|
|
});
|
|
|
|
// Attendee names are checked against the places the registration will have after
|
|
// this purchase, counting names already given on an existing registration.
|
|
const cleanAttendees = validateAttendees(
|
|
event,
|
|
placesAfter(existingReg?.registrationOptions, resolvedOptions),
|
|
attendees,
|
|
existingReg ? await namedPlacesByKey(existingReg.id) : {}
|
|
);
|
|
if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) {
|
|
res.status(400);
|
|
throw new Error("ticketDelivery must be 'payer' or 'each'");
|
|
}
|
|
|
|
let registration;
|
|
let isNewRegistration = false;
|
|
if (existingReg) {
|
|
// Merge: add a new price tranche per requested option into the existing registration.
|
|
// Never overwrite an existing row's priceSnapshot/quantity in place — that would blend
|
|
// tickets bought at different early-bird prices into a single (wrong) price. Each
|
|
// purchase gets its own tranche; the RegistrationOption row stays a maintained aggregate.
|
|
for (const opt of resolvedOptions) {
|
|
// Match on eventOptionId + variantId for correct row
|
|
const existing = existingReg.registrationOptions.find(
|
|
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
|
|
);
|
|
if (existing) {
|
|
await prisma.$transaction([
|
|
prisma.registrationOptionTranche.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
registrationOptionId: existing.id,
|
|
quantity: opt.quantity,
|
|
priceSnapshot: opt.priceSnapshot,
|
|
appliedTierId: opt.appliedTierId || null,
|
|
}
|
|
}),
|
|
prisma.registrationOption.update({
|
|
where: { id: existing.id },
|
|
data: { quantity: existing.quantity + opt.quantity, priceSnapshot: opt.priceSnapshot, appliedTierId: opt.appliedTierId || null }
|
|
})
|
|
]);
|
|
} else {
|
|
await prisma.registrationOption.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
registrationId: existingReg.id,
|
|
eventOptionId: opt.eventOptionId,
|
|
quantity: opt.quantity,
|
|
variantId: opt.variantId || null,
|
|
appliedTierId: opt.appliedTierId || null,
|
|
priceSnapshot: opt.priceSnapshot,
|
|
tranches: {
|
|
create: [{
|
|
id: uuidv4(),
|
|
quantity: opt.quantity,
|
|
priceSnapshot: opt.priceSnapshot,
|
|
appliedTierId: opt.appliedTierId || null,
|
|
}]
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
// Only downgrade from paid if the newly added items actually cost something
|
|
const newItemsCost = resolvedOptions.reduce((sum, opt) => {
|
|
return sum + ((opt.priceSnapshot || 0) * (opt.quantity || 0));
|
|
}, 0);
|
|
if (existingReg.status === 'paid' && newItemsCost > 0) {
|
|
await prisma.registration.update({ where: { id: existingReg.id }, data: { status: 'partial_paid', updatedAt: new Date() } });
|
|
} else {
|
|
await prisma.registration.update({ where: { id: existingReg.id }, data: { updatedAt: new Date() } });
|
|
}
|
|
registration = await prisma.registration.findUnique({
|
|
where: { id: existingReg.id },
|
|
include: {
|
|
registrationOptions: { include: { eventOption: true, tranches: true } },
|
|
event: true,
|
|
user: { select: { id: true, name: true, email: true, phoneNumber: true } }
|
|
}
|
|
});
|
|
} else {
|
|
isNewRegistration = true;
|
|
// Create new registration
|
|
const registrationId = uuidv4();
|
|
registration = await prisma.registration.create({
|
|
data: {
|
|
id: registrationId,
|
|
userId,
|
|
eventId,
|
|
updatedAt: new Date(),
|
|
registrationOptions: {
|
|
create: resolvedOptions.map(option => ({
|
|
id: uuidv4(),
|
|
eventOptionId: option.eventOptionId,
|
|
quantity: option.quantity,
|
|
variantId: option.variantId || null,
|
|
appliedTierId: option.appliedTierId || null,
|
|
priceSnapshot: option.priceSnapshot,
|
|
tranches: {
|
|
create: [{
|
|
id: uuidv4(),
|
|
quantity: option.quantity,
|
|
priceSnapshot: option.priceSnapshot,
|
|
appliedTierId: option.appliedTierId || null,
|
|
}]
|
|
}
|
|
}))
|
|
}
|
|
},
|
|
include: {
|
|
registrationOptions: { include: { eventOption: true, tranches: true } },
|
|
event: true,
|
|
user: { select: { id: true, name: true, email: true, phoneNumber: true } }
|
|
}
|
|
});
|
|
}
|
|
|
|
if (cleanAttendees.length > 0) {
|
|
await createAttendees(registration.id, cleanAttendees);
|
|
queueConsentRequests(registration.id);
|
|
}
|
|
if (waitlistOffer) {
|
|
await prisma.waitlistEntry.update({
|
|
where: { id: waitlistOffer.id },
|
|
data: { status: 'claimed', claimedAt: new Date(), registrationId: registration.id, offerTokenHash: null },
|
|
});
|
|
}
|
|
if (ticketDelivery) {
|
|
await prisma.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
|
|
}
|
|
|
|
// Apply the coupon (or re-work an existing one over the merged items).
|
|
let couponError = null;
|
|
if (!isNewRegistration) await recalculateRegistrationDiscount(registration.id);
|
|
if (couponCode) {
|
|
try {
|
|
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode });
|
|
} catch (e) {
|
|
// Lost a race for the last use since the check above — keep the registration.
|
|
if (!e.isCouponError) throw e;
|
|
couponError = e.message;
|
|
}
|
|
}
|
|
if (couponCode || !isNewRegistration || cleanAttendees.length > 0 || ticketDelivery) {
|
|
registration = await prisma.registration.findUnique({
|
|
where: { id: registration.id },
|
|
include: {
|
|
registrationOptions: { include: { eventOption: true, tranches: true } },
|
|
event: true,
|
|
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
|
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
|
attendees: ATTENDEES_INCLUDE,
|
|
}
|
|
});
|
|
}
|
|
|
|
// If total due is zero, mark as paid and generate/update tickets if no required form
|
|
let freeTicketMockReq = null, freeTicketMockRes = null;
|
|
try {
|
|
const totalDue = computeRegistrationTotalDue(registration, new Date());
|
|
if (totalDue === 0) {
|
|
// Mark paid (no-op if already paid)
|
|
if (registration.status !== 'paid') {
|
|
await prisma.registration.update({ where: { id: registration.id }, data: { status: 'paid', updatedAt: new Date() } });
|
|
}
|
|
// check if event has a required form
|
|
let hasRequiredForm = false;
|
|
try {
|
|
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } });
|
|
hasRequiredForm = !!(form && form.isRequired);
|
|
} catch {}
|
|
if (!hasRequiredForm) {
|
|
// Same generator as paid registrations: a ticket per named attendee, and one
|
|
// shared quantity ticket for the rest of each line.
|
|
await generateTicketsForRegistration(registration.id);
|
|
// Capture for chained email — tickets sent after registration confirmation
|
|
freeTicketMockReq = { user: { id: registration.userId }, body: { registrationId: registration.id } };
|
|
freeTicketMockRes = { status: () => freeTicketMockRes, json: () => {} };
|
|
}
|
|
|
|
// reflect status change in the returned object
|
|
registration.status = 'paid';
|
|
}
|
|
} catch (e) {
|
|
// ignore errors in auto-generation path; manual generation remains available
|
|
}
|
|
|
|
// Fire-and-forget: registration email first, then tickets (guarantees order)
|
|
const { sendRegistrationEmails, sendRegistrationUpdatedEmails } = require('../utils/notifications');
|
|
const _regId = registration.id;
|
|
const _freeTicketReq = freeTicketMockReq;
|
|
const _freeTicketRes = freeTicketMockRes;
|
|
(async () => {
|
|
try {
|
|
if (isNewRegistration) {
|
|
await sendRegistrationEmails(_regId);
|
|
} else {
|
|
await sendRegistrationUpdatedEmails(_regId);
|
|
}
|
|
} catch (e) { console.error('Failed to send registration emails:', e); }
|
|
if (_freeTicketReq) {
|
|
try { await emailTickets(_freeTicketReq, _freeTicketRes); } catch (e) { console.error('Error emailing tickets for free registration:', e); }
|
|
}
|
|
})();
|
|
|
|
res.status(201).json({ ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) });
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Get all registrations
|
|
// @route GET /api/registrations
|
|
// @access Private/Admin
|
|
const getRegistrations = async (req, res) => {
|
|
try {
|
|
const include = {
|
|
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
|
attendees: ATTENDEES_INCLUDE,
|
|
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
|
registrationOptions: {
|
|
include: {
|
|
eventOption: { include: { earlyBirdTiers: true } },
|
|
variant: { select: { id: true, name: true, price: true } },
|
|
tranches: true,
|
|
}
|
|
},
|
|
event: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
phoneNumber: true
|
|
}
|
|
}
|
|
};
|
|
|
|
// Pagination is opt-in via ?page/?limit to keep existing callers (which expect a plain
|
|
// array of every registration) working unchanged; callers that pass either param get back
|
|
// the { data, total, page, limit, pages } shape used by /api/payments and /api/users.
|
|
if (typeof req.query.page === 'undefined' && typeof req.query.limit === 'undefined') {
|
|
const registrations = await prisma.registration.findMany({ include });
|
|
return res.json(attachComputedTotalsToList(registrations));
|
|
}
|
|
|
|
const page = Math.max(1, parseInt(req.query.page) || 1);
|
|
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100));
|
|
const skip = (page - 1) * limit;
|
|
|
|
const [registrations, total] = await prisma.$transaction([
|
|
prisma.registration.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
|
|
prisma.registration.count()
|
|
]);
|
|
|
|
res.json({ data: attachComputedTotalsToList(registrations), total, page, limit, pages: Math.ceil(total / limit) });
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Get user registrations
|
|
// @route GET /api/registrations/myregistrations
|
|
// @access Private
|
|
const getUserRegistrations = async (req, res) => {
|
|
try {
|
|
const showPast = String(req.query.showPast || '').toLowerCase() === '1' || String(req.query.showPast || '').toLowerCase() === 'true';
|
|
const now = new Date();
|
|
const whereClause = showPast ? { userId: req.user.id } : {
|
|
userId: req.user.id,
|
|
status: { not: 'cancelled' },
|
|
event: { endDate: { gte: now }, cashupStatus: { not: 'closed' } }
|
|
};
|
|
|
|
// Keep pending/partial-paid registrations' prices current before serving them — an
|
|
// early-bird tier can expire while items sit unpaid in someone's registration, and
|
|
// without this the dashboard would keep showing a price that was never actually locked
|
|
// in by a payment, indefinitely, until the user happens to attempt a payment.
|
|
const staleCandidates = await prisma.registration.findMany({
|
|
where: { ...whereClause, status: { in: ['pending', 'partial_paid'] } },
|
|
select: { id: true }
|
|
});
|
|
if (staleCandidates.length > 0) {
|
|
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
|
|
}
|
|
|
|
const registrations = await prisma.registration.findMany({
|
|
where: whereClause,
|
|
include: {
|
|
registrationOptions: {
|
|
include: {
|
|
eventOption: { include: { earlyBirdTiers: true } },
|
|
variant: { select: { id: true, name: true, price: true } },
|
|
tranches: true,
|
|
}
|
|
},
|
|
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
|
attendees: ATTENDEES_INCLUDE,
|
|
formResponses: { select: { id: true, attendeeId: true } },
|
|
// Nest the event's form so the frontend can tell whether attendee forms are
|
|
// required without a separate GET /api/events/:id per registration.
|
|
event: { include: { form: { include: { fields: true } } } },
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
phoneNumber: true
|
|
}
|
|
},
|
|
payments: true,
|
|
},
|
|
orderBy: { createdAt: 'desc' }
|
|
});
|
|
|
|
res.json(attachComputedTotalsToList(registrations));
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Get registration by ID
|
|
// @route GET /api/registrations/:id
|
|
// @access Private
|
|
const getRegistrationById = async (req, res) => {
|
|
try {
|
|
const include = {
|
|
registrationOptions: {
|
|
include: {
|
|
eventOption: { include: { earlyBirdTiers: true } },
|
|
variant: { select: { id: true, name: true, price: true } },
|
|
tickets: true,
|
|
tranches: true
|
|
}
|
|
},
|
|
event: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
phoneNumber: true
|
|
}
|
|
},
|
|
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
|
formResponses: { include: { answers: true } },
|
|
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
|
attendees: ATTENDEES_INCLUDE,
|
|
};
|
|
|
|
let registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
|
|
|
|
if (!registration) {
|
|
res.status(404);
|
|
throw new Error('Registration not found');
|
|
}
|
|
|
|
// See getUserRegistrations — keep an unpaid/partially-paid registration's price current
|
|
// whenever it's viewed, not just at payment time.
|
|
if (registration.status === 'pending' || registration.status === 'partial_paid') {
|
|
await refreshPricingForRegistration(registration.id).catch(() => {});
|
|
registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include });
|
|
}
|
|
|
|
// Guests (no auth) can view by knowing the registrationId (UUID = unguessable)
|
|
// Authenticated users must be the owner, staff+, or a named attendee on it
|
|
const isOwnerOrStaffViewer = req.user && (registration.userId === req.user.id || ['admin', 'supervisor', 'staff'].includes(req.user.role));
|
|
const isAttendeeViewer = req.user && (registration.attendees || []).some(a => a.userId === req.user.id);
|
|
if (req.user && !isOwnerOrStaffViewer && !isAttendeeViewer) {
|
|
res.status(403);
|
|
throw new Error('Not authorized to view this registration');
|
|
}
|
|
// Only the payer and staff see attendees' contact details.
|
|
if (!isOwnerOrStaffViewer) {
|
|
registration.attendees = (registration.attendees || []).map(a => ({ ...a, email: null, phone: null }));
|
|
}
|
|
|
|
res.json(attachComputedTotals(registration));
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Update registration status
|
|
// @route PUT /api/registrations/:id
|
|
// @access Private/Admin
|
|
const updateRegistrationStatus = async (req, res) => {
|
|
try {
|
|
const { status } = req.body;
|
|
|
|
const registration = await prisma.registration.findUnique({
|
|
where: { id: req.params.id }
|
|
});
|
|
|
|
if (!registration) {
|
|
res.status(404);
|
|
throw new Error('Registration not found');
|
|
}
|
|
|
|
await assertEventOpen(registration.eventId, res);
|
|
|
|
// If downgrading from paid to not-paid, ensure no tickets were used and delete unused tickets
|
|
if (registration.status === 'paid' && status !== 'paid') {
|
|
const tickets = await prisma.ticket.findMany({
|
|
where: { registrationOption: { registrationId: req.params.id } },
|
|
include: { usages: true }
|
|
});
|
|
const hasUsed = tickets.some(t => t.isUsed || (t.usages && t.usages.length > 0));
|
|
if (hasUsed) {
|
|
res.status(400);
|
|
throw new Error('Cannot change registration from paid because one or more tickets have been used');
|
|
}
|
|
// Delete all unused tickets
|
|
await prisma.ticket.deleteMany({
|
|
where: {
|
|
registrationOption: { registrationId: req.params.id },
|
|
isUsed: false
|
|
}
|
|
});
|
|
}
|
|
|
|
const updatedRegistration = await prisma.registration.update({
|
|
where: { id: req.params.id },
|
|
data: {
|
|
status,
|
|
updatedAt: new Date()
|
|
},
|
|
include: {
|
|
registrationOptions: {
|
|
include: {
|
|
eventOption: true
|
|
}
|
|
},
|
|
event: true,
|
|
user: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
email: true,
|
|
phoneNumber: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// This is the staff-only status-change endpoint (separate from the owner-facing
|
|
// DELETE /:id cancel route), so any transition into 'cancelled' here is always a
|
|
// staff-initiated cancellation — log it the same way DELETE /:id does, so both
|
|
// paths land under the one 'registration_cancelled' filter in the audit log.
|
|
if (status === 'cancelled' && registration.status !== 'cancelled') {
|
|
await releaseRegistrationCoupons(req.params.id);
|
|
queueWaitlist(registration.eventId);
|
|
logAdminAction({
|
|
actorId: req.user.id,
|
|
actorRole: req.user.role,
|
|
action: 'registration_cancelled',
|
|
targetType: 'Registration',
|
|
targetId: req.params.id,
|
|
metadata: { registrationOwnerId: registration.userId, previousStatus: registration.status },
|
|
ip: getClientIp(req),
|
|
});
|
|
}
|
|
|
|
// Generate tickets and email them when status is manually set to 'paid' by staff
|
|
if (status === 'paid') {
|
|
(async () => {
|
|
try {
|
|
await generateTicketsForRegistration(req.params.id);
|
|
const mockReq = { user: { id: updatedRegistration.userId }, body: { registrationId: req.params.id } };
|
|
const mockRes = { status: () => mockRes, json: () => {} };
|
|
await emailTickets(mockReq, mockRes);
|
|
} catch (e) {
|
|
console.error('[updateRegistrationStatus] Failed to generate/email tickets:', e?.message || e);
|
|
}
|
|
})();
|
|
}
|
|
|
|
res.json(updatedRegistration);
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Cancel registration
|
|
// @route DELETE /api/registrations/:id
|
|
// @access Private
|
|
const cancelRegistration = async (req, res) => {
|
|
try {
|
|
const registration = await prisma.registration.findUnique({
|
|
where: { id: req.params.id },
|
|
include: { event: { select: { endDate: true } } }
|
|
});
|
|
|
|
if (!registration) {
|
|
res.status(404);
|
|
throw new Error('Registration not found');
|
|
}
|
|
|
|
// Check if user is authorized to cancel this registration
|
|
if (registration.userId !== req.user.id && req.user.role !== 'admin') {
|
|
res.status(403);
|
|
throw new Error('Not authorized to cancel this registration');
|
|
}
|
|
|
|
// If event is in the past, block self-cancellation (mirrors the edit-block above)
|
|
if (req.user.role !== 'admin' && registration.event?.endDate && new Date(registration.event.endDate).getTime() < Date.now()) {
|
|
res.status(400);
|
|
throw new Error('This event has already ended; registration can no longer be cancelled');
|
|
}
|
|
|
|
await assertEventOpen(registration.eventId, res);
|
|
|
|
// Check if registration can be cancelled
|
|
if (registration.status === 'cancelled') {
|
|
res.status(400);
|
|
throw new Error('Registration is already cancelled');
|
|
}
|
|
|
|
// Non-admins cannot cancel a registration that has payments against it
|
|
if (req.user.role !== 'admin') {
|
|
const paymentCount = await prisma.payment.count({
|
|
where: { registrationId: registration.id, amount: { gt: 0 } }
|
|
});
|
|
if (paymentCount > 0) {
|
|
res.status(403);
|
|
throw new Error('This registration has payments recorded against it and cannot be self-cancelled. Please contact the organisation for assistance.');
|
|
}
|
|
}
|
|
|
|
const updatedRegistration = await prisma.registration.update({
|
|
where: { id: req.params.id },
|
|
data: {
|
|
status: 'cancelled',
|
|
updatedAt: new Date()
|
|
}
|
|
});
|
|
await releaseRegistrationCoupons(req.params.id);
|
|
queueWaitlist(registration.eventId);
|
|
|
|
// Only log when staff cancels on someone else's behalf — a routine self-service
|
|
// cancellation isn't an admin action worth cluttering the audit trail with.
|
|
if (registration.userId !== req.user.id) {
|
|
logAdminAction({
|
|
actorId: req.user.id,
|
|
actorRole: req.user.role,
|
|
action: 'registration_cancelled',
|
|
targetType: 'Registration',
|
|
targetId: req.params.id,
|
|
metadata: { registrationOwnerId: registration.userId },
|
|
ip: getClientIp(req),
|
|
});
|
|
}
|
|
|
|
res.json({ message: 'Registration cancelled', registration: updatedRegistration });
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Get registrations by event
|
|
// @route GET /api/registrations/event/:eventId
|
|
// @access Private/Admin
|
|
const getRegistrationsByEvent = async (req, res) => {
|
|
try {
|
|
const { search } = req.query;
|
|
|
|
// See getUserRegistrations — keep pending/partial-paid registrations' prices current
|
|
// before serving them, rather than only at payment time.
|
|
const staleCandidates = await prisma.registration.findMany({
|
|
where: { eventId: req.params.eventId, status: { in: ['pending', 'partial_paid'] } },
|
|
select: { id: true }
|
|
});
|
|
if (staleCandidates.length > 0) {
|
|
await Promise.all(staleCandidates.map(r => refreshPricingForRegistration(r.id).catch(() => {})));
|
|
}
|
|
|
|
let registrations = await prisma.registration.findMany({
|
|
where: { eventId: req.params.eventId },
|
|
include: {
|
|
registrationOptions: {
|
|
include: {
|
|
eventOption: { include: { earlyBirdTiers: true } },
|
|
variant: { select: { id: true, name: true, price: true } },
|
|
tickets: true,
|
|
tranches: true,
|
|
}
|
|
},
|
|
payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } },
|
|
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } },
|
|
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
|
attendees: ATTENDEES_INCLUDE,
|
|
},
|
|
orderBy: { createdAt: 'asc' }
|
|
});
|
|
|
|
if (search) {
|
|
const q = String(search).toLowerCase();
|
|
registrations = registrations.filter(r =>
|
|
String(r.user?.name || '').toLowerCase().includes(q) ||
|
|
String(r.user?.email || '').toLowerCase().includes(q) ||
|
|
String(r.user?.phoneNumber || '').toLowerCase().includes(q)
|
|
);
|
|
}
|
|
|
|
res.json(attachComputedTotalsToList(registrations));
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Create a new registration for non existing user
|
|
// @route POST /api/registrations/manual
|
|
// @access Private/Supervisor
|
|
const createManualRegistration = async (req, res) => {
|
|
let userRecord;
|
|
try {
|
|
const { eventId, options, user, notificationPreference: prefFromBody, skipActivationNotice, couponCode, attendees, ticketDelivery } = req.body;
|
|
|
|
if (!eventId || !options || !user || !user.name || (!user.email && !user.phoneNumber)) {
|
|
res.status(400);
|
|
throw new Error('Missing required fields: eventId, options, user.name, and at least user.email or user.phoneNumber');
|
|
}
|
|
|
|
//Check event
|
|
const event = await prisma.event.findUnique({
|
|
where: {id: eventId},
|
|
include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } },
|
|
});
|
|
|
|
if (!event) {
|
|
res.status(404);
|
|
throw new Error('Event not found');
|
|
}
|
|
|
|
if (!event.isActive) {
|
|
res.status(400);
|
|
throw new Error('Event is not active');
|
|
}
|
|
|
|
await assertEventOpen(eventId, res);
|
|
|
|
// Validate options
|
|
if (!Array.isArray(options) || options.length === 0) {
|
|
res.status(400);
|
|
throw new Error('At least one option must be selected');
|
|
}
|
|
|
|
// Validate each option up front, before any account is created or changed —
|
|
// including stock, which staff registrations previously skipped.
|
|
for (const option of options) {
|
|
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
|
if (!eventOption) {
|
|
res.status(400);
|
|
throw new Error(`Option with ID ${option.eventOptionId} not found for this event`);
|
|
}
|
|
if (!option.quantity || option.quantity < 1) {
|
|
res.status(400);
|
|
throw new Error('Quantity must be at least 1');
|
|
}
|
|
const variant = option.variantId ? (eventOption.variants || []).find(v => v.id === option.variantId) : null;
|
|
const oStock = await checkOptionStock(eventOption, option.quantity);
|
|
const vStock = variant ? await checkVariantStock(variant, option.quantity) : { available: true };
|
|
if (!oStock.available || !vStock.available) {
|
|
res.status(400);
|
|
const left = Math.max(0, Math.min(oStock.remaining ?? Infinity, vStock.remaining ?? Infinity));
|
|
throw new Error(`"${eventOption.name}${variant ? ` (${variant.name})` : ''}" is sold out or does not have enough stock (${left} remaining). Raise the stock limit to add more.`);
|
|
}
|
|
}
|
|
|
|
// Pricing (appliedTierId + priceSnapshot) depends on who the attendee is — Staff and
|
|
// above get the staff price — so it runs once the account is resolved below.
|
|
const priceManualOptions = (attendee) => Promise.all(options.map(async (option) => {
|
|
const eventOption = event.eventOptions.find(eo => eo.id === option.eventOptionId);
|
|
const variantId = option.variantId || null;
|
|
const priced = await priceRequestedOption(eventOption, variantId, option.quantity, attendee);
|
|
return { ...option, variantId, ...priced };
|
|
}));
|
|
|
|
// Resolve or create a user
|
|
let userId;
|
|
|
|
const { normalizeZAPhone } = require('../utils/whatsapp');
|
|
const phone = normalizeZAPhone(user.phoneNumber) || (user.phoneNumber || '').replace(/\D/g, '');
|
|
const hasValidEmail = !!(user.email && typeof user.email === 'string' && user.email.includes('@'));
|
|
|
|
// Derive notification preference: explicit choice wins; otherwise infer from available contact info
|
|
const validPrefs = ['email', 'whatsapp', 'both'];
|
|
const derivedPref = (prefFromBody && validPrefs.includes(prefFromBody))
|
|
? prefFromBody
|
|
: (hasValidEmail && phone ? 'both' : phone ? 'whatsapp' : 'email');
|
|
|
|
// Always search by email AND/OR phone.
|
|
// Resolve each channel independently (rather than a single findFirst with an OR
|
|
// across both) so that an email belonging to one account and a phone number
|
|
// belonging to a *different* account can never be silently collapsed into
|
|
// whichever record happens to match first — that would let a registration
|
|
// hijack or corrupt someone else's account. Also try the alternate phone
|
|
// format (27xxx ↔ 0xxx) so both representations match.
|
|
const phoneAlt = phone && phone.startsWith('27') ? '0' + phone.slice(2) : (phone && phone.length === 9 ? '27' + phone : null);
|
|
const emailUser = hasValidEmail
|
|
? await prisma.user.findUnique({ where: { email: user.email } })
|
|
: null;
|
|
const phoneUser = phone
|
|
? await prisma.user.findFirst({ where: { OR: [{ phoneNumber: phone }, ...(phoneAlt ? [{ phoneNumber: phoneAlt }] : [])] } })
|
|
: null;
|
|
|
|
if (emailUser && phoneUser && emailUser.id !== phoneUser.id) {
|
|
res.status(409);
|
|
throw new Error(
|
|
`This email and phone number belong to two different existing accounts (${emailUser.name} vs ${phoneUser.name}). Please verify the visitor's details before registering.`
|
|
);
|
|
}
|
|
|
|
const existingUser = emailUser || phoneUser || null;
|
|
|
|
if (existingUser) {
|
|
userId = existingUser.id;
|
|
const updateData = {};
|
|
// The kiosk shows the operator a diff of name/email/phone against the matched
|
|
// account and requires explicit confirmation before submitting, so any
|
|
// difference reaching this point is an already-confirmed correction — apply
|
|
// it as a full overwrite rather than only filling in blanks.
|
|
if (user.name && user.name.trim() && user.name.trim() !== existingUser.name) {
|
|
updateData.name = user.name.trim();
|
|
}
|
|
if (phone && phone !== existingUser.phoneNumber) {
|
|
updateData.phoneNumber = phone;
|
|
}
|
|
if (hasValidEmail && existingUser.email !== user.email) {
|
|
updateData.email = user.email;
|
|
}
|
|
|
|
// Update preference only when the caller explicitly specified one, but validate it
|
|
// against the contact info that will actually be on the account after this update —
|
|
// a stale "whatsapp"/"both" preference must not survive a phone number being
|
|
// removed, nor "email" survive an email being cleared in favor of a real phone.
|
|
if (prefFromBody && validPrefs.includes(prefFromBody)) {
|
|
const { isValidZAPhone } = require('../utils/whatsapp');
|
|
const finalPhone = updateData.phoneNumber !== undefined ? updateData.phoneNumber : existingUser.phoneNumber;
|
|
const finalEmail = updateData.email !== undefined ? updateData.email : existingUser.email;
|
|
const finalEmailValid = !!(finalEmail && !finalEmail.endsWith('@guest.local'));
|
|
let candidatePref = prefFromBody;
|
|
if ((candidatePref === 'whatsapp' || candidatePref === 'both') && !isValidZAPhone(finalPhone)) {
|
|
candidatePref = finalEmailValid ? 'email' : candidatePref;
|
|
}
|
|
if (candidatePref === 'email' && !finalEmailValid && isValidZAPhone(finalPhone)) {
|
|
candidatePref = 'whatsapp';
|
|
}
|
|
updateData.notificationPreference = candidatePref;
|
|
}
|
|
|
|
if (Object.keys(updateData).length > 0) {
|
|
await prisma.user.update({ where: { id: userId }, data: updateData }).catch(() => {});
|
|
}
|
|
} else {
|
|
const suppliedPassword = typeof user.password === 'string' && user.password.trim().length >= 6
|
|
? user.password.trim()
|
|
: null;
|
|
|
|
if (hasValidEmail && suppliedPassword) {
|
|
// Caller supplied their own password (the self-service kiosk, where the
|
|
// visitor sets it themselves on the spot) — activate immediately, since
|
|
// there's nothing left for them to do via an activation link.
|
|
const hashed = await hashPassword(suppliedPassword);
|
|
const created = await prisma.user.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
name: user.name,
|
|
email: user.email,
|
|
password: hashed,
|
|
phoneNumber: phone || null,
|
|
isActive: true,
|
|
notificationPreference: derivedPref,
|
|
updatedAt: new Date(),
|
|
}
|
|
});
|
|
userId = created.id;
|
|
} else {
|
|
// New account: uses the real email if a valid one was given, otherwise a
|
|
// guest.local placeholder (phone-only registration). Always created inactive
|
|
// with a random password — the visitor activates it themselves via the link
|
|
// sent immediately below (email or WhatsApp), unless the caller explicitly
|
|
// opted out of that nudge (e.g. a self-service visitor who declined to
|
|
// create an account at all).
|
|
const placeholderEmail = hasValidEmail
|
|
? user.email
|
|
: `guest+${uuidv4().slice(0, 8)}@guest.local`;
|
|
const hashed = await hashPassword(uuidv4());
|
|
const created = await prisma.user.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
name: user.name,
|
|
email: placeholderEmail,
|
|
password: hashed,
|
|
phoneNumber: phone || null,
|
|
isActive: false,
|
|
notificationPreference: derivedPref,
|
|
updatedAt: new Date(),
|
|
}
|
|
});
|
|
userId = created.id;
|
|
if (!skipActivationNotice) {
|
|
const { sendActivationLink } = require('./userController');
|
|
sendActivationLink(created);
|
|
}
|
|
}
|
|
}
|
|
|
|
let couponError = null;
|
|
const attendee = await prisma.user.findUnique({ where: { id: userId }, select: { id: true, role: true, email: true } });
|
|
const resolvedManualOptions = await priceManualOptions(attendee);
|
|
|
|
// Check the coupon against this attendee before saving anything.
|
|
if (couponCode) {
|
|
await previewCoupon({ code: couponCode, eventId, user: attendee, lines: pendingCouponLines(event, resolvedManualOptions) });
|
|
}
|
|
|
|
// Merge into existing non-cancelled registration if one exists, otherwise create new
|
|
const existingReg = await prisma.registration.findFirst({
|
|
where: { userId, eventId, status: { not: 'cancelled' } },
|
|
include: { registrationOptions: true }
|
|
});
|
|
|
|
const cleanAttendees = validateAttendees(
|
|
event,
|
|
placesAfter(existingReg?.registrationOptions, resolvedManualOptions),
|
|
attendees,
|
|
existingReg ? await namedPlacesByKey(existingReg.id) : {}
|
|
);
|
|
if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) {
|
|
res.status(400);
|
|
throw new Error("ticketDelivery must be 'payer' or 'each'");
|
|
}
|
|
|
|
let registration;
|
|
let isNewRegistration = false;
|
|
|
|
if (existingReg) {
|
|
// Add a new price tranche per requested option into the existing registration (all in
|
|
// parallel) — never overwrite an existing row's priceSnapshot/quantity in place, or
|
|
// tickets bought at different early-bird prices would blend into a single wrong price.
|
|
await Promise.all(resolvedManualOptions.map(opt => {
|
|
const existing = existingReg.registrationOptions.find(
|
|
ro => ro.eventOptionId === opt.eventOptionId && (ro.variantId || null) === (opt.variantId || null)
|
|
);
|
|
if (existing) {
|
|
return prisma.$transaction([
|
|
prisma.registrationOptionTranche.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
registrationOptionId: existing.id,
|
|
quantity: opt.quantity,
|
|
priceSnapshot: opt.priceSnapshot,
|
|
appliedTierId: opt.appliedTierId || null,
|
|
}
|
|
}),
|
|
prisma.registrationOption.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
quantity: existing.quantity + opt.quantity,
|
|
priceSnapshot: opt.priceSnapshot,
|
|
appliedTierId: opt.appliedTierId || null,
|
|
}
|
|
})
|
|
]);
|
|
}
|
|
return prisma.registrationOption.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
registrationId: existingReg.id,
|
|
eventOptionId: opt.eventOptionId,
|
|
quantity: opt.quantity,
|
|
variantId: opt.variantId || null,
|
|
appliedTierId: opt.appliedTierId || null,
|
|
priceSnapshot: opt.priceSnapshot,
|
|
tranches: {
|
|
create: [{
|
|
id: uuidv4(),
|
|
quantity: opt.quantity,
|
|
priceSnapshot: opt.priceSnapshot,
|
|
appliedTierId: opt.appliedTierId || null,
|
|
}]
|
|
}
|
|
}
|
|
});
|
|
}));
|
|
// Re-work any coupon over the merged items, and apply a new one, before the status
|
|
// is worked out from the totals.
|
|
await recalculateRegistrationDiscount(existingReg.id);
|
|
if (couponCode) {
|
|
try {
|
|
await applyCouponToRegistration({ registrationId: existingReg.id, code: couponCode, appliedById: req.user.id });
|
|
} catch (e) {
|
|
if (!e.isCouponError) throw e;
|
|
couponError = e.message;
|
|
}
|
|
}
|
|
|
|
// Recompute status based on totals across ALL current options and payments
|
|
const freshForStatus = await prisma.registration.findUnique({
|
|
where: { id: existingReg.id },
|
|
include: {
|
|
registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, tranches: true } },
|
|
payments: true,
|
|
}
|
|
});
|
|
const freshTotalDue = computeRegistrationTotalDue(freshForStatus, new Date());
|
|
const freshTotalPaid = (freshForStatus?.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
|
|
let newExistingStatus;
|
|
if (freshTotalDue === 0 || freshTotalPaid >= freshTotalDue) newExistingStatus = 'paid';
|
|
else if (freshTotalPaid > 0) newExistingStatus = 'partial_paid';
|
|
else newExistingStatus = existingReg.status === 'cancelled' ? 'pending' : (existingReg.status || 'pending');
|
|
await prisma.registration.update({ where: { id: existingReg.id }, data: { status: newExistingStatus, updatedAt: new Date() } });
|
|
|
|
registration = await prisma.registration.findUnique({
|
|
where: { id: existingReg.id },
|
|
include: {
|
|
registrationOptions: { include: { eventOption: true, tranches: true } },
|
|
event: true,
|
|
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
|
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
|
},
|
|
});
|
|
registration.status = newExistingStatus;
|
|
} else {
|
|
isNewRegistration = true;
|
|
const registrationId = uuidv4();
|
|
registration = await prisma.registration.create({
|
|
data: {
|
|
id: registrationId,
|
|
userId,
|
|
eventId,
|
|
updatedAt: new Date(),
|
|
registrationOptions: {
|
|
create: resolvedManualOptions.map(option => ({
|
|
id: uuidv4(),
|
|
eventOptionId: option.eventOptionId,
|
|
quantity: option.quantity,
|
|
variantId: option.variantId || null,
|
|
appliedTierId: option.appliedTierId || null,
|
|
priceSnapshot: option.priceSnapshot,
|
|
tranches: {
|
|
create: [{
|
|
id: uuidv4(),
|
|
quantity: option.quantity,
|
|
priceSnapshot: option.priceSnapshot,
|
|
appliedTierId: option.appliedTierId || null,
|
|
}]
|
|
}
|
|
})),
|
|
},
|
|
},
|
|
include: {
|
|
registrationOptions: { include: { eventOption: true, tranches: true } },
|
|
event: true,
|
|
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
|
},
|
|
});
|
|
if (couponCode) {
|
|
try {
|
|
await applyCouponToRegistration({ registrationId: registration.id, code: couponCode, appliedById: req.user.id });
|
|
} catch (e) {
|
|
if (!e.isCouponError) throw e;
|
|
couponError = e.message;
|
|
}
|
|
registration = await prisma.registration.findUnique({
|
|
where: { id: registration.id },
|
|
include: {
|
|
registrationOptions: { include: { eventOption: true, tranches: true } },
|
|
event: true,
|
|
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
|
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
const registrationId = registration.id;
|
|
|
|
if (cleanAttendees.length > 0) {
|
|
await createAttendees(registrationId, cleanAttendees);
|
|
queueConsentRequests(registrationId);
|
|
}
|
|
if (ticketDelivery) await prisma.registration.update({ where: { id: registrationId }, data: { ticketDelivery } });
|
|
|
|
if (couponCode && !couponError) {
|
|
logAdminAction({
|
|
actorId: req.user.id,
|
|
actorRole: req.user.role,
|
|
action: 'coupon_applied_manual',
|
|
targetType: 'Registration',
|
|
targetId: registrationId,
|
|
metadata: { code: String(couponCode).trim().toUpperCase(), discount: registration.discountTotal, forUserId: userId },
|
|
ip: getClientIp(req),
|
|
});
|
|
}
|
|
|
|
// Auto mark paid and generate tickets if free and no required form (new registrations only)
|
|
let hasRequiredForm = false;
|
|
try {
|
|
const form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId } });
|
|
hasRequiredForm = !!(form && form.isRequired);
|
|
} catch {}
|
|
|
|
// Auto-mark paid and generate tickets for free registrations (new OR existing that became free)
|
|
if (registration.status !== 'paid') try {
|
|
const totalDue = computeRegistrationTotalDue(registration, new Date());
|
|
if (totalDue === 0) {
|
|
await prisma.registration.update({ where: { id: registration.id }, data: { status: 'paid', updatedAt: new Date() } });
|
|
registration.status = 'paid';
|
|
}
|
|
} catch {}
|
|
|
|
// Generate/sync tickets for all paid registrations that have no required form
|
|
if (registration.status === 'paid' && !hasRequiredForm) try {
|
|
await generateTicketsForRegistration(registration.id);
|
|
} catch {}
|
|
|
|
// For paid registrations, generate a Yoco checkout link to include in the email
|
|
let yocoPaymentUrl = null;
|
|
if (registration.status !== 'paid') {
|
|
try {
|
|
const { createRegistrationCheckoutInternal } = require('./paymentController');
|
|
const baseUrl = process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001';
|
|
const checkout = await createRegistrationCheckoutInternal(registrationId, userId, {
|
|
successUrl: `${baseUrl}/payment/success`,
|
|
cancelUrl: `${baseUrl}/payment/cancel`,
|
|
failureUrl: `${baseUrl}/payment/failure`,
|
|
});
|
|
yocoPaymentUrl = checkout.redirectUrl || null;
|
|
} catch (e) {
|
|
console.warn('Could not create Yoco checkout for self-service email:', e?.message || e);
|
|
}
|
|
}
|
|
|
|
// Fire-and-forget: registration email first, then tickets (guarantees order)
|
|
const { sendSelfServiceRegistrationEmails, sendRegistrationUpdatedEmails } = require('../utils/notifications');
|
|
const _ssRegId = registrationId;
|
|
const _ssUserId = userId;
|
|
const _ssPaymentUrl = yocoPaymentUrl;
|
|
const _ssFormRequired = hasRequiredForm;
|
|
const _ssIsNew = isNewRegistration;
|
|
const _ssNotifPref = derivedPref;
|
|
const _ssShouldEmailTickets = registration.status === 'paid' && !hasRequiredForm;
|
|
(async () => {
|
|
try {
|
|
if (_ssIsNew) {
|
|
await sendSelfServiceRegistrationEmails(_ssRegId, { paymentUrl: _ssPaymentUrl, formRequired: _ssFormRequired });
|
|
} else {
|
|
await sendRegistrationUpdatedEmails(_ssRegId);
|
|
}
|
|
} catch (e) { console.error('Failed to send registration email:', e); }
|
|
if (_ssShouldEmailTickets) {
|
|
const mockReq = { user: { id: _ssUserId }, body: { registrationId: _ssRegId, channel: _ssNotifPref } };
|
|
const mockRes = { status: () => mockRes, json: () => {} };
|
|
try { await emailTickets(mockReq, mockRes); } catch (e) { console.error('Failed to email tickets after free self-service registration:', e); }
|
|
}
|
|
})();
|
|
|
|
logAdminAction({
|
|
actorId: req.user.id,
|
|
actorRole: req.user.role,
|
|
action: 'registration_created_manual',
|
|
targetType: 'Registration',
|
|
targetId: registrationId,
|
|
metadata: { eventId: registration.eventId, forUserId: userId },
|
|
ip: getClientIp(req),
|
|
});
|
|
|
|
return res.status(201).json({ ...attachComputedTotals(registration), ...(couponError ? { couponError } : {}) });
|
|
} catch (error) {
|
|
console.error(error);
|
|
return res.status(400).json({message: error.message});
|
|
}
|
|
};
|
|
|
|
// @desc Update registration options (add/remove items) with constraints
|
|
// @route PUT /api/registrations/:id/options
|
|
// @access Private (owner or staff/admin)
|
|
const updateRegistrationOptions = async (req, res) => {
|
|
try {
|
|
const registrationId = req.params.id;
|
|
const userId = req.user.id;
|
|
const { options } = req.body; // [{ eventOptionId, quantity }]
|
|
|
|
if (!Array.isArray(options)) {
|
|
res.status(400);
|
|
throw new Error('Options must be an array');
|
|
}
|
|
|
|
// Load registration with relations
|
|
const registration = await prisma.registration.findUnique({
|
|
where: { id: registrationId },
|
|
include: {
|
|
registrationOptions: {
|
|
include: {
|
|
tickets: true,
|
|
tranches: true,
|
|
eventOption: { include: { earlyBirdTiers: true, variants: true } }
|
|
}
|
|
},
|
|
event: { include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } } },
|
|
payments: true,
|
|
user: { select: { id: true, role: true, email: true } },
|
|
}
|
|
});
|
|
|
|
if (!registration) {
|
|
res.status(404);
|
|
throw new Error('Registration not found');
|
|
}
|
|
|
|
// Authorization: owner or admin/supervisor/staff
|
|
if (
|
|
registration.userId !== userId &&
|
|
req.user.role !== 'admin' &&
|
|
req.user.role !== 'supervisor' &&
|
|
req.user.role !== 'staff'
|
|
) {
|
|
res.status(403);
|
|
throw new Error('Not authorized to edit this registration');
|
|
}
|
|
|
|
// If event is in the past, block edit
|
|
if (registration.event?.endDate && new Date(registration.event.endDate).getTime() < Date.now()) {
|
|
res.status(400);
|
|
throw new Error('This event has already ended; registration cannot be edited');
|
|
}
|
|
|
|
// registration.event is already loaded above, so check cashupStatus directly here
|
|
// rather than re-fetching via assertEventOpen.
|
|
if (registration.event?.cashupStatus === 'closed') {
|
|
res.status(400);
|
|
throw new Error('This event is closed. Reopen it (admin only) before making changes.');
|
|
}
|
|
|
|
// Validate provided options: must belong to same event and quantities >=1
|
|
if (options.length === 0) {
|
|
res.status(400);
|
|
throw new Error('At least one option must be provided');
|
|
}
|
|
|
|
const validOptionIds = new Set((registration.event?.eventOptions || []).map(eo => eo.id));
|
|
for (const opt of options) {
|
|
if (!opt || !opt.eventOptionId || !Number.isInteger(opt.quantity) || opt.quantity < 1) {
|
|
res.status(400);
|
|
throw new Error('Each option must include a valid eventOptionId and quantity >= 1');
|
|
}
|
|
if (!validOptionIds.has(opt.eventOptionId)) {
|
|
res.status(400);
|
|
throw new Error('One or more options do not belong to this event');
|
|
}
|
|
}
|
|
|
|
// Compute totalPaid
|
|
const totalPaid = (registration.payments || []).reduce((sum, p) => sum + (p.amount || 0), 0);
|
|
|
|
// Merge duplicate entries for the same eventOptionId+variantId (defensive; normal
|
|
// callers send one entry per option/variant combo).
|
|
const mergedOptionsMap = new Map();
|
|
for (const opt of options) {
|
|
const key = `${opt.eventOptionId}::${opt.variantId || ''}`;
|
|
if (mergedOptionsMap.has(key)) {
|
|
mergedOptionsMap.get(key).quantity += opt.quantity;
|
|
} else {
|
|
mergedOptionsMap.set(key, { ...opt });
|
|
}
|
|
}
|
|
const mergedOptions = Array.from(mergedOptionsMap.values());
|
|
|
|
// Group existing registrationOptions by eventOptionId::variantId so tickets that
|
|
// have already been issued are never deleted, only ever updated in place. Computed
|
|
// before pricing resolution because pricing now depends on whether a quantity is
|
|
// increasing (and by how much) — an unchanged or reduced quantity must never
|
|
// re-price tickets already locked in at an earlier price (see tranche design).
|
|
const oldByKey = new Map();
|
|
for (const ro of registration.registrationOptions) {
|
|
const key = `${ro.eventOptionId}::${ro.variantId || ''}`;
|
|
if (!oldByKey.has(key)) oldByKey.set(key, []);
|
|
oldByKey.get(key).push(ro);
|
|
}
|
|
|
|
// A legacy row (created before the tranche migration) has no tranches — fall back to
|
|
// its own priceSnapshot/quantity as a single implicit tranche for totals purposes.
|
|
const trancheSum = (tranches) => (tranches || []).reduce((s, t) => s + Number(t.quantity || 0) * Number(t.priceSnapshot || 0), 0);
|
|
const rowsTotal = (rows) => rows.reduce((sum, ro) => {
|
|
const tranches = ro.tranches || [];
|
|
if (tranches.length > 0) return sum + trancheSum(tranches);
|
|
return sum + Number(ro.quantity || 0) * Number(ro.priceSnapshot ?? ro.eventOption?.price ?? 0);
|
|
}, 0);
|
|
// Remove `qtyToRemove` units from a set of tranches, newest-first (LIFO) — mirrors the
|
|
// ticket-floor invariant below: issued tickets always map to the oldest tranches, so the
|
|
// newest (least-committed) tranches are the ones trimmed first on a quantity decrease.
|
|
const planLIFORemoval = (tranches, qtyToRemove) => {
|
|
const sorted = [...tranches].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
|
let remaining = qtyToRemove;
|
|
const ops = [];
|
|
const kept = [];
|
|
for (const t of sorted) {
|
|
if (remaining <= 0) { kept.push(t); continue; }
|
|
if (t.quantity <= remaining) {
|
|
remaining -= t.quantity;
|
|
ops.push({ type: 'delete', trancheId: t.id });
|
|
} else {
|
|
const newQuantity = t.quantity - remaining;
|
|
remaining = 0;
|
|
ops.push({ type: 'update', trancheId: t.id, newQuantity });
|
|
kept.push({ ...t, quantity: newQuantity });
|
|
}
|
|
}
|
|
return { kept, ops };
|
|
};
|
|
|
|
// Resolve pricing for each incoming option (variant-aware, with stock check) — but only
|
|
// for the delta being newly added. Unchanged or decreasing quantities never re-resolve.
|
|
const eventOptionsMap = new Map((registration.event?.eventOptions || []).map(eo => [eo.id, eo]));
|
|
const resolvedUpdateOptions = [];
|
|
let newTotalDue = 0;
|
|
for (const opt of mergedOptions) {
|
|
const key = `${opt.eventOptionId}::${opt.variantId || ''}`;
|
|
const existingRows = oldByKey.get(key) || [];
|
|
const existingQty = existingRows.reduce((sum, ro) => sum + (ro.quantity || 0), 0);
|
|
const delta = (opt.quantity || 0) - existingQty;
|
|
const existingTotal = rowsTotal(existingRows);
|
|
|
|
const eventOption = eventOptionsMap.get(opt.eventOptionId);
|
|
const variantId = opt.variantId || null;
|
|
let priceSnapshot = null;
|
|
let appliedTierId = null;
|
|
let removalPlan = null;
|
|
|
|
if (delta > 0) {
|
|
// Adding places needs stock, like a new registration does.
|
|
const oStock = await checkOptionStock(eventOption, delta);
|
|
const variant = variantId ? (eventOption.variants || []).find(v => v.id === variantId) : null;
|
|
const vStock = variant ? await checkVariantStock(variant, delta) : { available: true };
|
|
if (!oStock.available || !vStock.available) {
|
|
res.status(400);
|
|
const left = Math.max(0, Math.min(oStock.remaining ?? Infinity, vStock.remaining ?? Infinity));
|
|
throw new Error(`Only ${left} more "${eventOption.name}${variant ? ` (${variant.name})` : ''}" available.`);
|
|
}
|
|
({ priceSnapshot, appliedTierId } = await priceRequestedOption(eventOption, variantId, delta, registration.user));
|
|
newTotalDue += existingTotal + delta * (priceSnapshot || 0);
|
|
} else if (delta < 0) {
|
|
const existingTranches = existingRows.flatMap(ro => ro.tranches || []);
|
|
removalPlan = planLIFORemoval(existingTranches, -delta);
|
|
newTotalDue += trancheSum(removalPlan.kept);
|
|
} else {
|
|
newTotalDue += existingTotal;
|
|
}
|
|
|
|
resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId, existingRows, existingQty, delta, removalPlan });
|
|
}
|
|
|
|
if (newTotalDue < totalPaid) {
|
|
res.status(400);
|
|
throw new Error('Cannot reduce items below the amount already paid');
|
|
}
|
|
|
|
// Per-item floor: a ticket is only ever created once a registration is paid, and it is
|
|
// never deleted or shrunk — only grown. So an option can never be reduced (or removed)
|
|
// below the quantity of any ticket already issued for it.
|
|
const newKeys = new Set(resolvedUpdateOptions.map(opt => `${opt.eventOptionId}::${opt.variantId || ''}`));
|
|
const newQtyByKey = new Map(resolvedUpdateOptions.map(opt => [`${opt.eventOptionId}::${opt.variantId || ''}`, opt.quantity || 0]));
|
|
for (const [key, rows] of oldByKey) {
|
|
const ticketQty = rows.reduce((sum, ro) => sum + (ro.tickets || []).reduce((s, t) => s + (t.quantity || 0), 0), 0);
|
|
if (ticketQty <= 0) continue;
|
|
const newQty = newQtyByKey.get(key) || 0;
|
|
if (newQty < ticketQty) {
|
|
res.status(400);
|
|
throw new Error(`Cannot reduce "${rows[0].eventOption?.name || 'item'}" below the ${ticketQty} ticket(s) already issued`);
|
|
}
|
|
}
|
|
|
|
// Differential update: update matching options in place, create genuinely new ones,
|
|
// and only delete options that were removed entirely. The floor check above guarantees
|
|
// a removed key never has tickets — the guard below turns that into a hard invariant
|
|
// instead of an assumption: tickets are never deleted.
|
|
await prisma.$transaction(async (tx) => {
|
|
for (const [key, rows] of oldByKey) {
|
|
if (newKeys.has(key)) continue;
|
|
const ticketedRows = rows.filter(ro => (ro.tickets || []).length > 0);
|
|
if (ticketedRows.length > 0) {
|
|
throw new Error(`Cannot remove "${rows[0].eventOption?.name || 'item'}" — tickets have already been issued for it`);
|
|
}
|
|
await tx.registrationOption.deleteMany({ where: { id: { in: rows.map(r => r.id) } } });
|
|
}
|
|
|
|
for (const opt of resolvedUpdateOptions) {
|
|
const key = `${opt.eventOptionId}::${opt.variantId || ''}`;
|
|
const existingRows = oldByKey.get(key);
|
|
if (existingRows && existingRows.length > 0) {
|
|
const [primary, ...dupes] = existingRows;
|
|
|
|
// Consolidate dupes into primary first: move their tranches and tickets rather
|
|
// than discarding them, so price history (and money) survives duplicate cleanup.
|
|
for (const dup of dupes) {
|
|
if ((dup.tranches || []).length > 0) {
|
|
await tx.registrationOptionTranche.updateMany({ where: { registrationOptionId: dup.id }, data: { registrationOptionId: primary.id } });
|
|
}
|
|
if ((dup.tickets || []).length > 0) {
|
|
await tx.ticket.updateMany({ where: { registrationOptionId: dup.id }, data: { registrationOptionId: primary.id } });
|
|
}
|
|
await tx.registrationOption.delete({ where: { id: dup.id } });
|
|
}
|
|
|
|
if (opt.delta > 0) {
|
|
// Increase: a new tranche for the delta at the freshly-resolved price — never
|
|
// overwrite the existing tranches' locked-in prices.
|
|
await tx.registrationOptionTranche.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
registrationOptionId: primary.id,
|
|
quantity: opt.delta,
|
|
priceSnapshot: opt.priceSnapshot,
|
|
appliedTierId: opt.appliedTierId || null,
|
|
}
|
|
});
|
|
await tx.registrationOption.update({
|
|
where: { id: primary.id },
|
|
data: { quantity: opt.quantity, appliedTierId: opt.appliedTierId || null, priceSnapshot: opt.priceSnapshot }
|
|
});
|
|
} else if (opt.delta < 0 && opt.removalPlan) {
|
|
// Decrease: trim tranches newest-first (LIFO); issued tickets always map to the
|
|
// oldest tranches, and the floor check above already guarantees this never dips
|
|
// below issued-ticket quantity.
|
|
for (const op of opt.removalPlan.ops) {
|
|
if (op.type === 'delete') {
|
|
await tx.registrationOptionTranche.delete({ where: { id: op.trancheId } });
|
|
} else {
|
|
await tx.registrationOptionTranche.update({ where: { id: op.trancheId }, data: { quantity: op.newQuantity } });
|
|
}
|
|
}
|
|
const newest = [...opt.removalPlan.kept].sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt))[0];
|
|
await tx.registrationOption.update({
|
|
where: { id: primary.id },
|
|
data: {
|
|
quantity: opt.quantity,
|
|
appliedTierId: newest ? (newest.appliedTierId || null) : null,
|
|
priceSnapshot: newest ? newest.priceSnapshot : null,
|
|
}
|
|
});
|
|
} else {
|
|
// Unchanged quantity: only reflect dupe-consolidation in the aggregate; leave
|
|
// the locked-in price/tier untouched.
|
|
await tx.registrationOption.update({
|
|
where: { id: primary.id },
|
|
data: { quantity: opt.quantity }
|
|
});
|
|
}
|
|
} else {
|
|
await tx.registrationOption.create({
|
|
data: {
|
|
id: uuidv4(),
|
|
registrationId,
|
|
eventOptionId: opt.eventOptionId,
|
|
quantity: opt.quantity,
|
|
variantId: opt.variantId || null,
|
|
appliedTierId: opt.appliedTierId || null,
|
|
priceSnapshot: opt.priceSnapshot,
|
|
tranches: {
|
|
create: [{
|
|
id: uuidv4(),
|
|
quantity: opt.quantity,
|
|
priceSnapshot: opt.priceSnapshot,
|
|
appliedTierId: opt.appliedTierId || null,
|
|
}]
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Names on places that no longer exist go too (newest first). Tickets for them
|
|
// are tidied up by generateTicketsForRegistration; the floor check above already
|
|
// stops anything going below tickets issued.
|
|
await tx.attendee.deleteMany({ where: { registrationId, registrationOptionId: null } });
|
|
const lines = await tx.registrationOption.findMany({ where: { registrationId }, include: { attendees: { orderBy: { order: 'desc' } } } });
|
|
for (const line of lines) {
|
|
const extra = (line.attendees || []).length - (line.quantity || 0);
|
|
if (extra > 0) {
|
|
await tx.attendee.deleteMany({ where: { id: { in: line.attendees.slice(0, extra).map(a => a.id) } } });
|
|
}
|
|
}
|
|
|
|
// Coupon: percentage/comp codes follow the new items, fixed codes keep their amount.
|
|
const discount = await recalculateRegistrationDiscount(registrationId, tx);
|
|
const dueAfterDiscount = Math.max(0, newTotalDue - discount);
|
|
if (dueAfterDiscount < totalPaid - 0.001) {
|
|
throw new Error('Cannot reduce items below the amount already paid');
|
|
}
|
|
|
|
// Update updatedAt and potentially status based on payments vs due
|
|
let newStatus = registration.status;
|
|
if (totalPaid >= dueAfterDiscount) newStatus = 'paid';
|
|
else if (totalPaid > 0) newStatus = 'partial_paid';
|
|
else newStatus = 'pending';
|
|
|
|
await tx.registration.update({
|
|
where: { id: registrationId },
|
|
data: { updatedAt: new Date(), status: newStatus }
|
|
});
|
|
});
|
|
|
|
// Return updated registration
|
|
const updated = await prisma.registration.findUnique({
|
|
where: { id: registrationId },
|
|
include: {
|
|
registrationOptions: { include: { eventOption: true, tranches: true } },
|
|
event: true,
|
|
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
|
|
payments: true,
|
|
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
|
attendees: ATTENDEES_INCLUDE,
|
|
}
|
|
});
|
|
|
|
const { sendRegistrationUpdatedEmails } = require('../utils/notifications');
|
|
sendRegistrationUpdatedEmails(registrationId).catch(e => console.error('Failed to send registration updated emails:', e));
|
|
if (resolvedUpdateOptions.some(o => o.delta < 0) || oldByKey.size > newKeys.size) queueWaitlist(registration.eventId);
|
|
|
|
// If registration is now paid, regenerate tickets and send them
|
|
if (updated.status === 'paid') {
|
|
(async () => {
|
|
try {
|
|
await generateTicketsForRegistration(registrationId);
|
|
const mockReq = { user: { id: updated.userId }, body: { registrationId } };
|
|
const mockRes = { status: () => mockRes, json: () => {} };
|
|
await emailTickets(mockReq, mockRes);
|
|
} catch (e) {
|
|
console.error('[updateRegistrationOptions] Failed to generate/send tickets:', e?.message || e);
|
|
}
|
|
})();
|
|
}
|
|
|
|
return res.json(attachComputedTotals(updated));
|
|
} catch (error) {
|
|
return res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Submit form responses for a registration
|
|
// @route POST /api/registrations/:id/forms/responses
|
|
// @access Private
|
|
const submitFormResponses = async (req, res) => {
|
|
try {
|
|
const registrationId = req.params.id;
|
|
const userId = req.user?.id || null;
|
|
const { responses } = req.body; // [{ answers: { fieldId: value } }]
|
|
|
|
// load registration with options and event form
|
|
const registration = await prisma.registration.findUnique({
|
|
where: { id: registrationId },
|
|
include: {
|
|
registrationOptions: { include: { eventOption: true, tranches: true } },
|
|
event: true,
|
|
}
|
|
});
|
|
if (!registration) { res.status(404); throw new Error('Registration not found'); }
|
|
// Guests (no auth) may submit by knowing the registrationId; authenticated users must be
|
|
// owner, staff+, or submitting their own form as a named attendee
|
|
const ownAttendeeOnly = Array.isArray(responses) && responses.length > 0 && req.user
|
|
&& (await prisma.attendee.count({ where: { registrationId, userId, id: { in: responses.map(r => r?.attendeeId).filter(Boolean) } } })) === responses.length;
|
|
if (req.user && registration.userId !== userId && !['admin','supervisor','staff'].includes(req.user.role) && !ownAttendeeOnly) {
|
|
res.status(403); throw new Error('Not authorized');
|
|
}
|
|
|
|
// fetch form
|
|
let form = null;
|
|
try {
|
|
form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId }, include: { fields: true } });
|
|
} catch (e) {}
|
|
if (!form) {
|
|
return res.json({ message: 'No form for this event. Nothing to submit.' });
|
|
}
|
|
|
|
const fields = (form.fields || []).sort((a,b) => (a.order||0)-(b.order||0));
|
|
const answerableFields = fields.filter(f => f.type !== 'statement' && f.type !== 'paragraph');
|
|
|
|
const mainTickets = registration.registrationOptions.filter(ro => ro.eventOption?.isMainTicket).reduce((s, ro) => s + (ro.quantity || 0), 0);
|
|
const toSubmit = Array.isArray(responses) ? responses : [];
|
|
|
|
if (toSubmit.length === 0) { res.status(400); throw new Error('No responses provided'); }
|
|
if (toSubmit.length > mainTickets) { res.status(400); throw new Error(`Too many forms submitted. Expected at most ${mainTickets}`); }
|
|
|
|
// A response can be for a named attendee (group tickets); each attendee answers once.
|
|
const attendeeIds = toSubmit.map(r => r?.attendeeId).filter(Boolean);
|
|
if (attendeeIds.length > 0) {
|
|
const known = await prisma.attendee.findMany({ where: { id: { in: attendeeIds }, registrationId }, include: { formResponses: { select: { id: true } } } });
|
|
if (known.length !== new Set(attendeeIds).size) { res.status(400); throw new Error('Unknown attendee for this registration'); }
|
|
const answered = known.find(a => a.formResponses.length > 0);
|
|
if (answered) { res.status(400); throw new Error(`${answered.name}'s form has already been submitted`); }
|
|
}
|
|
|
|
// Create responses and answers
|
|
const created = [];
|
|
for (const resp of toSubmit) {
|
|
const respId = uuidv4();
|
|
const fr = await prisma.formResponse.create({ data: { id: respId, registrationId, attendeeId: resp?.attendeeId || null } });
|
|
const ansMap = resp?.answers || {};
|
|
for (const f of answerableFields) {
|
|
const raw = ansMap[f.id];
|
|
// If field required at field-level, enforce non-empty
|
|
if (f.isRequired && (raw === undefined || raw === null || String(raw).trim() === '')) {
|
|
res.status(400);
|
|
throw new Error(`Missing answer for: ${f.label}`);
|
|
}
|
|
if (raw !== undefined && raw !== null && String(raw).length > 0) {
|
|
await prisma.formAnswer.create({ data: { id: uuidv4(), responseId: respId, fieldId: f.id, value: String(raw) } });
|
|
}
|
|
}
|
|
created.push(fr);
|
|
}
|
|
|
|
// After submitting responses, if registration is already paid, generate and email tickets now.
|
|
let generatedTickets = [];
|
|
try {
|
|
const regAfter = await prisma.registration.findUnique({ where: { id: registrationId } });
|
|
if (regAfter && regAfter.status === 'paid') {
|
|
generatedTickets = await generateTicketsForRegistration(registrationId);
|
|
if (generatedTickets && generatedTickets.length > 0) {
|
|
try {
|
|
const mockReq = { user: { id: regAfter.userId }, body: { registrationId } };
|
|
const mockRes = { status: () => mockRes, json: () => {} };
|
|
await emailTickets(mockReq, mockRes);
|
|
} catch (emailErr) {
|
|
console.error('Post-form ticket email failed:', emailErr?.message || emailErr);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('Post-form ticket generation attempt failed:', e?.message || e);
|
|
}
|
|
|
|
res.status(201).json({ message: 'Responses submitted', count: created.length, generatedTickets: (generatedTickets && generatedTickets.length) ? generatedTickets : undefined });
|
|
} catch (error) {
|
|
res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Replace (edit) form responses for a registration (staff/admin/supervisor)
|
|
// @route PUT /api/registrations/:id/forms/responses
|
|
// @access Private/Staff
|
|
const replaceFormResponses = async (req, res) => {
|
|
try {
|
|
const registrationId = req.params.id;
|
|
const { responses } = req.body; // [{ answers: { fieldId: value } }]
|
|
|
|
// Authorization: staff or above only for replacing existing responses
|
|
if (!['admin','supervisor','staff'].includes(req.user.role)) {
|
|
res.status(403);
|
|
throw new Error('Not authorized');
|
|
}
|
|
|
|
// Load registration and form
|
|
const registration = await prisma.registration.findUnique({
|
|
where: { id: registrationId },
|
|
include: {
|
|
registrationOptions: { include: { eventOption: true, tranches: true } },
|
|
event: true,
|
|
}
|
|
});
|
|
if (!registration) { res.status(404); throw new Error('Registration not found'); }
|
|
|
|
let form = null;
|
|
try {
|
|
form = await prisma.eventForm.findUnique({ where: { eventId: registration.eventId }, include: { fields: true } });
|
|
} catch {}
|
|
if (!form) { return res.json({ message: 'No form for this event. Nothing to save.' }); }
|
|
|
|
const fields = (form.fields || []).sort((a,b) => (a.order||0)-(b.order||0));
|
|
const answerableFields = fields.filter(f => f.type !== 'statement' && f.type !== 'paragraph');
|
|
const mainTickets = registration.registrationOptions.filter(ro => ro.eventOption?.isMainTicket).reduce((s, ro) => s + (ro.quantity || 0), 0);
|
|
const toSubmit = Array.isArray(responses) ? responses : [];
|
|
|
|
if (toSubmit.length === 0) { res.status(400); throw new Error('No responses provided'); }
|
|
if (toSubmit.length > mainTickets) { res.status(400); throw new Error(`Too many forms submitted. Expected at most ${mainTickets}`); }
|
|
|
|
// Replace transactionally
|
|
await prisma.$transaction(async (tx) => {
|
|
// Delete existing answers and responses for this registration
|
|
const existing = await tx.formResponse.findMany({ where: { registrationId }, select: { id: true } });
|
|
const ids = existing.map(e => e.id);
|
|
if (ids.length > 0) {
|
|
await tx.formAnswer.deleteMany({ where: { responseId: { in: ids } } });
|
|
await tx.formResponse.deleteMany({ where: { id: { in: ids } } });
|
|
}
|
|
// Create new set
|
|
for (const resp of toSubmit) {
|
|
const respId = uuidv4();
|
|
await tx.formResponse.create({ data: { id: respId, registrationId } });
|
|
const ansMap = resp?.answers || {};
|
|
for (const f of answerableFields) {
|
|
const raw = ansMap[f.id];
|
|
if (f.isRequired && (raw === undefined || raw === null || String(raw).trim() === '')) {
|
|
res.status(400);
|
|
throw new Error(`Missing answer for: ${f.label}`);
|
|
}
|
|
if (raw !== undefined && raw !== null && String(raw).length > 0) {
|
|
await tx.formAnswer.create({ data: { id: uuidv4(), responseId: respId, fieldId: f.id, value: String(raw) } });
|
|
}
|
|
}
|
|
}
|
|
// Touch registration updatedAt
|
|
await tx.registration.update({ where: { id: registrationId }, data: { updatedAt: new Date() } });
|
|
});
|
|
|
|
// Attempt ticket generation and email if already paid
|
|
try {
|
|
const regAfter = await prisma.registration.findUnique({ where: { id: registrationId }, include: { user: { select: { id: true } } } });
|
|
if (regAfter && regAfter.status === 'paid') {
|
|
const newTickets = await generateTicketsForRegistration(registrationId);
|
|
if (newTickets && newTickets.length > 0) {
|
|
try {
|
|
const mockReq = { user: { id: regAfter.userId }, body: { registrationId } };
|
|
const mockRes = { status: () => mockRes, json: () => {} };
|
|
await emailTickets(mockReq, mockRes);
|
|
} catch (emailErr) {
|
|
console.error('Error emailing tickets after form replacement:', emailErr);
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
return res.json({ message: 'Responses saved' });
|
|
} catch (error) {
|
|
return res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Get saved draft for attendee forms for a registration (owner or staff)
|
|
// @route GET /api/registrations/:id/forms/draft
|
|
// @access Private
|
|
const getFormDraft = async (req, res) => {
|
|
try {
|
|
const registrationId = req.params.id;
|
|
const userId = req.user?.id || null;
|
|
|
|
// Ensure Prisma Client has the FormDraft model (migration + generate applied)
|
|
const hasFormDraftRead = !!(prisma && prisma.formDraft && typeof prisma.formDraft.findUnique === 'function');
|
|
if (!hasFormDraftRead) {
|
|
return res.status(400).json({
|
|
message: 'Draft storage is not available on the server.',
|
|
hint: 'Apply Prisma migrations and regenerate the Prisma Client, then restart the server.',
|
|
next: [
|
|
'cd backend',
|
|
'npx prisma migrate dev -n add-form-draft',
|
|
'npx prisma generate',
|
|
'restart the backend server'
|
|
]
|
|
});
|
|
}
|
|
|
|
// Load registration to verify access
|
|
const registration = await prisma.registration.findUnique({ where: { id: registrationId } });
|
|
if (!registration) { res.status(404); throw new Error('Registration not found'); }
|
|
if (req.user && registration.userId !== userId && !['admin','supervisor','staff'].includes(req.user.role)) {
|
|
res.status(403); throw new Error('Not authorized');
|
|
}
|
|
|
|
// Find draft. If staff/admin, we still scope the draft to the registration owner (so they can resume later).
|
|
const ownerUserId = registration.userId;
|
|
const draft = await prisma.formDraft.findUnique({ where: { registrationId_userId: { registrationId, userId: ownerUserId } } });
|
|
if (!draft) return res.json({ data: {}, updatedAt: null });
|
|
return res.json({ data: draft.data || {}, updatedAt: draft.updatedAt });
|
|
} catch (error) {
|
|
return res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Save draft for attendee forms for a registration (owner or staff). Does not mark as completed.
|
|
// @route PUT /api/registrations/:id/forms/draft
|
|
// @access Private
|
|
const saveFormDraft = async (req, res) => {
|
|
try {
|
|
const registrationId = req.params.id;
|
|
const userId = req.user?.id || null;
|
|
const { data } = req.body; // JSON object mapping attendeeIndex -> { fieldId: value }
|
|
|
|
if (typeof data !== 'object' || data == null) {
|
|
res.status(400); throw new Error('Invalid draft payload');
|
|
}
|
|
|
|
// Ensure Prisma Client has the FormDraft model (migration + generate applied)
|
|
const hasFormDraftWrite = !!(prisma && prisma.formDraft && typeof prisma.formDraft.upsert === 'function');
|
|
if (!hasFormDraftWrite) {
|
|
return res.status(400).json({
|
|
message: 'Draft storage is not available on the server.',
|
|
hint: 'Apply Prisma migrations and regenerate the Prisma Client, then restart the server.',
|
|
next: [
|
|
'cd backend',
|
|
'npx prisma migrate dev -n add-form-draft',
|
|
'npx prisma generate',
|
|
'restart the backend server'
|
|
]
|
|
});
|
|
}
|
|
|
|
// Load registration to verify access
|
|
const registration = await prisma.registration.findUnique({ where: { id: registrationId } });
|
|
if (!registration) { res.status(404); throw new Error('Registration not found'); }
|
|
if (req.user && registration.userId !== userId && !['admin','supervisor','staff'].includes(req.user.role)) {
|
|
res.status(403); throw new Error('Not authorized');
|
|
}
|
|
|
|
const ownerUserId = registration.userId;
|
|
|
|
// Upsert by (registrationId, userId) unique composite
|
|
const saved = await prisma.formDraft.upsert({
|
|
where: { registrationId_userId: { registrationId, userId: ownerUserId } },
|
|
update: { data, updatedAt: new Date() },
|
|
create: { registrationId, userId: ownerUserId, data },
|
|
});
|
|
|
|
return res.json({ message: 'Draft saved', updatedAt: saved.updatedAt });
|
|
} catch (error) {
|
|
return res.status(400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Re-send the current invoice PDF for one of the caller's own registrations
|
|
// @route POST /api/registrations/:id/send-invoice
|
|
// @access Private
|
|
const sendInvoice = async (req, res) => {
|
|
try {
|
|
const { channel } = req.body || {};
|
|
const { sendInvoiceToUser } = require('../utils/notifications');
|
|
await sendInvoiceToUser(req.params.id, req.user.id, channel);
|
|
res.json({ message: channel === 'whatsapp' ? 'Invoice sent to WhatsApp.' : 'Invoice emailed.' });
|
|
} catch (error) {
|
|
res.status(error.statusCode || 400).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* After a discount changes, bring the status in line with the new total. A registration
|
|
* that a coupon has made fully paid gets its tickets, as any paid registration does.
|
|
*/
|
|
async function syncStatusAfterDiscount(registrationId) {
|
|
const reg = await prisma.registration.findUnique({
|
|
where: { id: registrationId },
|
|
include: { registrationOptions: { include: { eventOption: true, tranches: true } }, payments: true },
|
|
});
|
|
const due = computeRegistrationTotalDue(reg, new Date());
|
|
const paid = (reg.payments || []).reduce((s, p) => s + (p.amount || 0), 0);
|
|
const status = paid >= due - 0.001 ? 'paid' : paid > 0 ? 'partial_paid' : 'pending';
|
|
if (status !== reg.status) {
|
|
await prisma.registration.update({ where: { id: registrationId }, data: { status, updatedAt: new Date() } });
|
|
}
|
|
if (status === 'paid' && reg.status !== 'paid') {
|
|
(async () => {
|
|
try {
|
|
let hasRequiredForm = false;
|
|
try {
|
|
const form = await prisma.eventForm.findUnique({ where: { eventId: reg.eventId } });
|
|
hasRequiredForm = !!(form && form.isRequired);
|
|
} catch {}
|
|
if (hasRequiredForm) return;
|
|
await generateTicketsForRegistration(registrationId);
|
|
const mockReq = { user: { id: reg.userId }, body: { registrationId } };
|
|
const mockRes = { status: () => mockRes, json: () => {} };
|
|
await emailTickets(mockReq, mockRes);
|
|
} catch (e) {
|
|
console.error('[coupon] Failed to issue tickets after discount:', e?.message || e);
|
|
}
|
|
})();
|
|
}
|
|
return status;
|
|
}
|
|
|
|
const isStaffRole = (role) => ['admin', 'supervisor', 'staff'].includes(role);
|
|
|
|
// @desc Apply a coupon code to an existing registration
|
|
// @route POST /api/registrations/:id/coupon
|
|
// @access Private (owner, or staff applying it on someone's behalf)
|
|
const applyCoupon = async (req, res) => {
|
|
try {
|
|
const { code } = req.body || {};
|
|
const registration = await prisma.registration.findUnique({
|
|
where: { id: req.params.id },
|
|
include: { event: { select: { endDate: true, cashupStatus: true } } },
|
|
});
|
|
if (!registration) { res.status(404); throw new Error('Registration not found'); }
|
|
const byStaff = registration.userId !== req.user.id;
|
|
if (byStaff && !isStaffRole(req.user.role)) { res.status(403); throw new Error('Not authorized to change this registration'); }
|
|
if (registration.status === 'cancelled') { res.status(400); throw new Error('This registration is cancelled.'); }
|
|
if (registration.event?.cashupStatus === 'closed') { res.status(400); throw new Error('This event is closed.'); }
|
|
if (registration.event?.endDate && new Date(registration.event.endDate) < new Date()) {
|
|
res.status(400); throw new Error('This event has already ended.');
|
|
}
|
|
|
|
const { coupon, discount } = await applyCouponToRegistration({
|
|
registrationId: registration.id,
|
|
code,
|
|
appliedById: byStaff ? req.user.id : null,
|
|
});
|
|
await syncStatusAfterDiscount(registration.id);
|
|
|
|
if (byStaff) {
|
|
logAdminAction({
|
|
actorId: req.user.id,
|
|
actorRole: req.user.role,
|
|
action: 'coupon_applied_manual',
|
|
targetType: 'Registration',
|
|
targetId: registration.id,
|
|
metadata: { code: coupon.code, discount, forUserId: registration.userId },
|
|
ip: getClientIp(req),
|
|
});
|
|
}
|
|
|
|
const { sendRegistrationUpdatedEmails } = require('../utils/notifications');
|
|
sendRegistrationUpdatedEmails(registration.id).catch(e => console.error('Failed to send registration updated emails:', e));
|
|
|
|
const updated = await prisma.registration.findUnique({
|
|
where: { id: registration.id },
|
|
include: {
|
|
registrationOptions: { include: { eventOption: true, variant: { select: { id: true, name: true, price: true } }, tranches: true } },
|
|
payments: true,
|
|
couponRedemptions: ACTIVE_COUPONS_INCLUDE,
|
|
},
|
|
});
|
|
res.json({ ...attachComputedTotals(updated), message: `Code ${coupon.code} applied — ${describeCoupon(coupon)}.` });
|
|
} catch (error) {
|
|
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Remove the coupon from a registration (the use is freed)
|
|
// @route DELETE /api/registrations/:id/coupon
|
|
// @access Private/Staff
|
|
const removeCoupon = async (req, res) => {
|
|
try {
|
|
const registration = await prisma.registration.findUnique({ where: { id: req.params.id } });
|
|
if (!registration) { res.status(404); throw new Error('Registration not found'); }
|
|
await assertEventOpen(registration.eventId, res);
|
|
|
|
// Removing a discount raises the total, which can take a paid registration back to
|
|
// unpaid. Same rule as a staff status change: refuse once a ticket has been scanned,
|
|
// otherwise withdraw the unused tickets until it's paid again.
|
|
const tickets = await prisma.ticket.findMany({
|
|
where: { registrationOption: { registrationId: registration.id } },
|
|
include: { usages: true },
|
|
});
|
|
if (registration.status === 'paid' && tickets.some(t => t.isUsed || t.usages.length > 0)) {
|
|
res.status(400);
|
|
throw new Error('A ticket on this registration has already been scanned, so its code can\'t be removed.');
|
|
}
|
|
|
|
const released = await releaseRegistrationCoupons(registration.id);
|
|
if (!released) { res.status(400); throw new Error('No code is applied to this registration.'); }
|
|
const status = await syncStatusAfterDiscount(registration.id);
|
|
if (registration.status === 'paid' && status !== 'paid') {
|
|
await prisma.ticket.deleteMany({ where: { registrationOption: { registrationId: registration.id }, isUsed: false } });
|
|
}
|
|
|
|
logAdminAction({
|
|
actorId: req.user.id,
|
|
actorRole: req.user.role,
|
|
action: 'coupon_applied_manual',
|
|
targetType: 'Registration',
|
|
targetId: registration.id,
|
|
metadata: { removed: true, forUserId: registration.userId },
|
|
ip: getClientIp(req),
|
|
});
|
|
res.json({ message: 'Code removed.' });
|
|
} catch (error) {
|
|
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
const isOwnerOrStaff = (registration, user) =>
|
|
registration.userId === user.id || ['admin', 'supervisor', 'staff'].includes(user.role);
|
|
|
|
// @desc Replace the named attendees on a registration (and who gets the tickets)
|
|
// @route PUT /api/registrations/:id/attendees
|
|
// @access Private (owner or staff)
|
|
// Body: { attendees: [{ id?, eventOptionId, variantId?, name, email?, phone? }], ticketDelivery? }
|
|
// Attendees left out are removed — refused if their ticket has been scanned.
|
|
const updateAttendees = async (req, res) => {
|
|
try {
|
|
const { attendees = [], ticketDelivery } = req.body || {};
|
|
const registration = await prisma.registration.findUnique({
|
|
where: { id: req.params.id },
|
|
include: {
|
|
registrationOptions: true,
|
|
attendees: { include: { ticket: { include: { usages: true } } } },
|
|
event: { include: { eventOptions: true } },
|
|
},
|
|
});
|
|
if (!registration) { res.status(404); throw new Error('Registration not found'); }
|
|
if (!isOwnerOrStaff(registration, req.user)) { res.status(403); throw new Error('Not authorized to edit this registration'); }
|
|
if (registration.status === 'cancelled') { res.status(400); throw new Error('This registration is cancelled.'); }
|
|
if (registration.event?.cashupStatus === 'closed') { res.status(400); throw new Error('This event is closed.'); }
|
|
if (registration.event?.endDate && new Date(registration.event.endDate) < new Date()) {
|
|
res.status(400); throw new Error('This event has already ended.');
|
|
}
|
|
if (ticketDelivery !== undefined && !TICKET_DELIVERY.includes(ticketDelivery)) {
|
|
res.status(400); throw new Error("ticketDelivery must be 'payer' or 'each'");
|
|
}
|
|
|
|
const clean = validateAttendees(registration.event, placesAfter(registration.registrationOptions, []), attendees);
|
|
const keepIds = new Set(attendees.map(a => a?.id).filter(Boolean));
|
|
const existingById = new Map(registration.attendees.map(a => [a.id, a]));
|
|
for (const id of keepIds) if (!existingById.has(id)) { res.status(400); throw new Error('Unknown attendee for this registration'); }
|
|
const removed = registration.attendees.filter(a => !keepIds.has(a.id));
|
|
const usedRemoved = removed.find(a => a.ticket && (a.ticket.isUsed || a.ticket.usages.length > 0));
|
|
if (usedRemoved) { res.status(400); throw new Error(`${usedRemoved.name}'s ticket has already been scanned, so they can't be removed.`); }
|
|
|
|
const toCreate = [];
|
|
await prisma.$transaction(async (tx) => {
|
|
if (removed.length) await tx.attendee.deleteMany({ where: { id: { in: removed.map(a => a.id) } } });
|
|
for (let i = 0; i < attendees.length; i++) {
|
|
const a = attendees[i];
|
|
const c = clean[i];
|
|
if (a.id) {
|
|
const prev = existingById.get(a.id);
|
|
const contactChanged = prev.email !== c.email || prev.phone !== c.phone;
|
|
const match = contactChanged && (c.email || c.phone) ? await findAccountByContact({ email: c.email, phone: c.phone }, tx) : null;
|
|
await tx.attendee.update({
|
|
where: { id: a.id },
|
|
data: {
|
|
name: c.name, email: c.email, phone: c.phone,
|
|
dateOfBirth: c.dateOfBirth, gender: c.gender, parentName: c.parentName, parentEmail: c.parentEmail, parentPhone: c.parentPhone,
|
|
...(contactChanged ? { userId: match?.user?.id || null } : {}),
|
|
},
|
|
});
|
|
} else {
|
|
toCreate.push(c);
|
|
}
|
|
}
|
|
if (toCreate.length) await createAttendees(registration.id, toCreate, tx);
|
|
if (ticketDelivery) await tx.registration.update({ where: { id: registration.id }, data: { ticketDelivery } });
|
|
});
|
|
|
|
queueConsentRequests(registration.id);
|
|
|
|
// Paid registrations: re-sync tickets and send any new ones.
|
|
if (registration.status === 'paid') {
|
|
const created = await generateTicketsForRegistration(registration.id);
|
|
if (created.length > 0) {
|
|
const mockReq = { user: { id: registration.userId }, body: { ticketIds: created.map(t => t.id) } };
|
|
const mockRes = { status: () => mockRes, json: () => {} };
|
|
emailTickets(mockReq, mockRes).catch(e => console.error('[attendees] Failed to send new tickets:', e?.message || e));
|
|
}
|
|
}
|
|
|
|
const updated = await prisma.registration.findUnique({
|
|
where: { id: registration.id },
|
|
select: { id: true, ticketDelivery: true, attendees: ATTENDEES_INCLUDE },
|
|
});
|
|
res.json(updated);
|
|
} catch (error) {
|
|
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
// @desc Send an attendee a link to fill in their own attendee form
|
|
// @route POST /api/registrations/:id/attendees/:attendeeId/send-form-link
|
|
// @access Private (owner or staff)
|
|
const sendAttendeeFormLink = async (req, res) => {
|
|
try {
|
|
const attendee = await prisma.attendee.findFirst({
|
|
where: { id: req.params.attendeeId, registrationId: req.params.id },
|
|
include: { registration: { include: { event: true, user: { select: { id: true, name: true } } } } },
|
|
});
|
|
if (!attendee) { res.status(404); throw new Error('Attendee not found'); }
|
|
if (!isOwnerOrStaff(attendee.registration, req.user)) { res.status(403); throw new Error('Not authorized'); }
|
|
if (!attendee.email && !attendee.phone) { res.status(400); throw new Error(`Add an email or phone number for ${attendee.name} first.`); }
|
|
|
|
const { sendAttendeeFormLinkMessage } = require('../utils/notifications');
|
|
const sent = await sendAttendeeFormLinkMessage(attendee);
|
|
res.json({ message: sent.length ? `Form link sent to ${attendee.name} by ${sent.join(' and ')}.` : 'The form link could not be sent.' });
|
|
} catch (error) {
|
|
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
updateAttendees,
|
|
sendAttendeeFormLink,
|
|
applyCoupon,
|
|
removeCoupon,
|
|
createRegistration,
|
|
getRegistrations,
|
|
getUserRegistrations,
|
|
getRegistrationById,
|
|
updateRegistrationStatus,
|
|
cancelRegistration,
|
|
getRegistrationsByEvent,
|
|
createManualRegistration,
|
|
updateRegistrationOptions,
|
|
submitFormResponses,
|
|
replaceFormResponses,
|
|
getFormDraft,
|
|
saveFormDraft,
|
|
sendInvoice,
|
|
}; |