Files
hope-events/backend/src/controllers/registrationController.js
T
joshuaandClaude Opus 5.5 686616cc18 Require acceptance for ticket transfers; privacy and legal updates
Transfers now send the new holder a link (to the email/phone typed) and
only complete once they accept; they can decline and the sender can
cancel. No response reveals whether contact details belong to an
account, and attendee account links are no longer sent to payers.
Terms of Use and Privacy Policy are updated for all features since
1.12, with a full cookie policy and a "Cookie settings" control. The
/docs route list now covers every mounted route, and account
activation auto-login is fixed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CP62L9JxHomA36aYP1ekCe
2026-10-19 18:46:15 +02:00

2303 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, analyticsSessionId } = 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); }
}
})();
// Funnel: this registration came from a tracked (cookie-consented) visit.
if (analyticsSessionId) {
const { recordSubmit } = require('../utils/analytics');
recordSubmit(registration, analyticsSessionId).catch(e => console.warn('[analytics] submit not recorded:', e?.message || 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 && !req.readOnly) {
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') && !req.readOnly) {
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 && !req.readOnly) {
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, attendees: updated.attendees.map(({ userId, ...a }) => a) });
} 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,
};