const prisma = require('../config/db'); const { v4: uuidv4 } = require('uuid'); const axios = require("axios"); const { generateTicketsForRegistration } = require('../utils/ticketUtils'); const { emailTickets } = require('./ticketController'); const { hashPassword } = require('../config/auth'); const { resolveOptionPrice, resolveVariantTierPrice, computeRegistrationTotalDue } = require('../utils/pricing'); const { assertEventOpen } = require('../utils/cashupUtils'); /** * Check overall stock availability for an EventOption. * Returns { available: boolean, remaining: number|null } */ async function checkOptionStock(option, requestedQty) { if (!option.stockLimit || option.stockLimit === 0) return { available: true, remaining: null }; const soldAgg = await prisma.registrationOption.aggregate({ where: { eventOptionId: option.id, registration: { status: { not: 'cancelled' } } }, _sum: { quantity: true } }); const sold = soldAgg._sum?.quantity || 0; const remaining = option.stockLimit - sold; return { available: remaining >= requestedQty, remaining }; } /** * Check stock availability for a specific variant. */ async function checkVariantStock(variant, requestedQty) { if (!variant.stockLimit || variant.stockLimit === 0) return { available: true, remaining: null }; const soldAgg = await prisma.registrationOption.aggregate({ where: { variantId: variant.id, registration: { status: { not: 'cancelled' } } }, _sum: { quantity: true } }); const sold = soldAgg._sum?.quantity || 0; const remaining = variant.stockLimit - sold; 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 } = 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.'); } // Enforce auth unless event explicitly opts out const eventRequiresAuth = event.requiresAuth !== false; if (eventRequiresAuth && !req.user) { res.status(401); throw new Error('Authentication required for this event'); } // Unauthenticated guest registration is only permitted for free events if (!req.user) { 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 { // 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 existingGuest = await prisma.user.findUnique({ where: { email: guestEmail } }); 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 const resolvedOptions = []; 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 qty = option.quantity; // Check overall option stock try { const stockCheck = await checkOptionStock(eventOption, qty); if (!stockCheck.available) { res.status(400); throw new Error(`"${eventOption.name}" is sold out or does not have enough stock (${stockCheck.remaining ?? 0} remaining).`); } } catch (e) { if (res.statusCode !== 200) 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) { res.status(400); throw new Error(`Variant not found for option "${eventOption.name}"`); } try { const vStock = await checkVariantStock(variant, qty); if (!vStock.available) { res.status(400); throw new Error(`Variant "${variant.name}" is sold out (${vStock.remaining ?? 0} remaining).`); } } catch (e) { if (res.statusCode !== 200) throw e; } variantPrice = variant.price; // null = use option price } // Resolve price: variant-level tiers take priority, then option-level tiers, then base prices let priceSnapshot = null; let appliedTierId = null; if (canIncludeTiers) { try { if (variantId) { // Try variant-specific early-bird tier first const variantResolved = await resolveVariantTierPrice(eventOption, variantId, qty); if (variantResolved.tierId) { priceSnapshot = variantResolved.price; appliedTierId = variantResolved.tierId; } else { // No variant tier — use the variant's own price (may be null → falls back to option price below) priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null; } } else { // No variant — use option-level early-bird tier const resolved = await resolveOptionPrice(eventOption, qty); priceSnapshot = resolved.price; appliedTierId = resolved.tierId; } } catch (e) { priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : eventOption.price; } } else { priceSnapshot = variantPrice !== null && variantPrice !== undefined ? variantPrice : null; } if (priceSnapshot === null) priceSnapshot = eventOption.price; resolvedOptions.push({ ...option, variantId, appliedTierId, priceSnapshot }); } // 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 } }); let registration; let isNewRegistration = false; if (existingReg) { // Merge: upsert each requested option into the existing registration 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.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, } }); } } // 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 } }, 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, })) } }, include: { registrationOptions: { include: { eventOption: true } }, event: true, user: { select: { id: true, name: true, email: true, phoneNumber: true } } } }); } // If total due is zero, mark as paid and generate/update tickets if no required form let freeTicketMockReq = null, freeTicketMockRes = null; try { const totalDue = (registration.registrationOptions || []).reduce((sum, ro) => sum + ((ro.priceSnapshot ?? ro.eventOption?.price ?? 0) * (ro.quantity || 0)), 0); 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) { // Batch-fetch existing tickets then create/update in parallel (1 read + N parallel writes vs N×2 sequential) const optionIds = registration.registrationOptions.map(o => o.id); const existingTickets = await prisma.ticket.findMany({ where: { registrationOptionId: { in: optionIds } } }); const ticketByOptionId = Object.fromEntries(existingTickets.map(t => [t.registrationOptionId, t])); await Promise.all(registration.registrationOptions.map(option => { const exists = ticketByOptionId[option.id]; if (exists) { const newQty = option.quantity || 1; if (newQty > (exists.quantity || 1)) { return prisma.ticket.update({ where: { id: exists.id }, data: { quantity: newQty, updatedAt: new Date() } }); } return Promise.resolve(); } return prisma.ticket.create({ data: { id: uuidv4(), qrCode: uuidv4(), registrationOptionId: option.id, userId: registration.userId, eventId: registration.eventId, quantity: option.quantity || 1, updatedAt: new Date(), } }); })); // 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(registration); } 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 registrations = await prisma.registration.findMany({ 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 } }, } }, event: true, user: { select: { id: true, name: true, email: true, phoneNumber: true } } } }); res.json(registrations); } 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' } } }; const registrations = await prisma.registration.findMany({ where: whereClause, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: { select: { id: true, name: true, price: 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 } } }, orderBy: { createdAt: 'desc' } }); res.json(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 registration = await prisma.registration.findUnique({ where: { id: req.params.id }, include: { registrationOptions: { include: { eventOption: { include: { earlyBirdTiers: true } }, variant: { select: { id: true, name: true, price: true } }, tickets: 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 } } } }); if (!registration) { res.status(404); throw new Error('Registration not found'); } // Guests (no auth) can view by knowing the registrationId (UUID = unguessable) // Authenticated users must be the owner or staff+ if (req.user && registration.userId !== req.user.id && req.user.role !== 'admin' && req.user.role !== 'supervisor' && req.user.role !== 'staff') { res.status(403); throw new Error('Not authorized to view this registration'); } res.json(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 } } } }); // 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() } }); 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; 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, } }, payments: { include: { recordedBy: { select: { id: true, name: true, email: true } } } }, user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } } }, 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(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, guestOnly, notificationPreference: prefFromBody } = 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'); } // Resolve pricing for each option (appliedTierId + priceSnapshot) const resolvedManualOptions = []; 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 variantId = option.variantId || null; let priceSnapshot = null; let appliedTierId = null; try { if (variantId) { const variantResolved = await resolveVariantTierPrice(eventOption, variantId, option.quantity); if (variantResolved.tierId) { priceSnapshot = variantResolved.price; appliedTierId = variantResolved.tierId; } else { const variant = (eventOption.variants || []).find(v => v.id === variantId); priceSnapshot = (variant && variant.price !== null && variant.price !== undefined) ? Number(variant.price) : Number(eventOption.price || 0); } } else { const resolved = await resolveOptionPrice(eventOption, option.quantity); priceSnapshot = resolved.price; appliedTierId = resolved.tierId; } } catch (e) { priceSnapshot = Number(eventOption.price || 0); } resolvedManualOptions.push({ ...option, variantId, appliedTierId, priceSnapshot }); } // 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 regardless of guestOnly. // 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 if (!guestOnly && hasValidEmail) { // Create a real active account (non-guest with email) try { const password = 'Hope123'; const response = await axios.post( `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:5000'}/api/users`, { name: user.name, email: user.email, password, phoneNumber: phone || null } ); const createdUser = response.data.user || response.data; if (!createdUser?.id) { res.status(400); throw new Error('User creation failed: No user ID returned'); } userId = createdUser.id; // Set derived preference on the new account await prisma.user.update({ where: { id: userId }, data: { notificationPreference: derivedPref } }).catch(() => {}); } catch (userErr) { res.status(400); throw new Error(`Failed to create user: ${userErr.response?.data?.message || userErr.message}`); } } else { // Guest path: phone-only, guestOnly=true, or no valid email 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; } // 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 } }); let registration; let isNewRegistration = false; if (existingReg) { // Upsert each requested option into the existing registration (all in parallel) 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.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, } }); })); // 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 } } } }, 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 } }, event: true, user: { select: { id: true, name: true, email: true, phoneNumber: true } }, }, }); 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, })), }, }, include: { registrationOptions: { include: { eventOption: true } }, event: true, user: { select: { id: true, name: true, email: true, phoneNumber: true } }, }, }); } const registrationId = registration.id; // 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 = (registration.registrationOptions || []).reduce((sum, ro) => sum + ((ro.priceSnapshot ?? ro.eventOption?.price ?? 0) * (ro.quantity || 0)), 0); 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); } } })(); return res.status(201).json(registration); } 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, eventOption: { include: { earlyBirdTiers: true, variants: true } } } }, event: { include: { eventOptions: { include: { earlyBirdTiers: true, variants: true } } } }, payments: 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()); // Resolve pricing for each incoming option (variant-aware, with stock check) const eventOptionsMap = new Map((registration.event?.eventOptions || []).map(eo => [eo.id, eo])); const resolvedUpdateOptions = []; for (const opt of mergedOptions) { const eventOption = eventOptionsMap.get(opt.eventOptionId); const variantId = opt.variantId || null; let priceSnapshot = null; let appliedTierId = null; try { if (variantId) { const variantResolved = await resolveVariantTierPrice(eventOption, variantId, opt.quantity); priceSnapshot = variantResolved.price; appliedTierId = variantResolved.tierId; } else { const resolved = await resolveOptionPrice(eventOption, opt.quantity); priceSnapshot = resolved.price; appliedTierId = resolved.tierId; } } catch (e) { priceSnapshot = Number(eventOption?.price || 0); } resolvedUpdateOptions.push({ ...opt, variantId, priceSnapshot, appliedTierId }); } const newTotalDue = resolvedUpdateOptions.reduce((sum, opt) => sum + (opt.priceSnapshot || 0) * (opt.quantity || 0), 0); if (newTotalDue < totalPaid) { res.status(400); throw new Error('Cannot reduce items below the amount already paid'); } // Group existing registrationOptions by eventOptionId::variantId so tickets that // have already been issued are never deleted, only ever updated in place. 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); } // 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; await tx.registrationOption.update({ where: { id: primary.id }, data: { quantity: opt.quantity, appliedTierId: opt.appliedTierId || null, priceSnapshot: opt.priceSnapshot, } }); for (const dup of dupes) { 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 } }); } } 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, } }); } } // Update updatedAt and potentially status based on payments vs due let newStatus = registration.status; if (totalPaid >= newTotalDue) 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 } }, event: true, user: { select: { id: true, name: true, email: true, phoneNumber: true } }, payments: true, } }); const { sendRegistrationUpdatedEmails } = require('../utils/notifications'); sendRegistrationUpdatedEmails(registrationId).catch(e => console.error('Failed to send registration updated emails:', e)); // 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(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 } }, 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 or staff+ if (req.user && registration.userId !== userId && !['admin','supervisor','staff'].includes(req.user.role)) { 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}`); } // Create responses and answers const created = []; for (const resp of toSubmit) { const respId = uuidv4(); const fr = await prisma.formResponse.create({ data: { id: respId, registrationId } }); 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 } }, 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 }); } }; module.exports = { createRegistration, getRegistrations, getUserRegistrations, getRegistrationById, updateRegistrationStatus, cancelRegistration, getRegistrationsByEvent, createManualRegistration, updateRegistrationOptions, submitFormResponses, replaceFormResponses, getFormDraft, saveFormDraft, };