Files
hope-events/backend/src/controllers/registrationController.js
T
joshuaandClaude Sonnet 5 0de3f4be7d Fix financial double-counting, rebuild cashup accountability, and redesign the Reports page
Financial correctness (donation-leg model):
- Donations are no longer mutated when assigned to a registration; assignment now
  creates an immutable "leg" record referencing the original donation instead.
- Fixed several places where money was double-counted once a donation was partially
  or fully assigned (Payments, Revenue summary, Cashup reconciliation, Finance
  report, Profit report, Master Orders, Revenue Detailed).
- Payments now record who recorded them (recordedBy), separate from who they're for.

Cashup:
- Per-user cash denomination counting (optional, any time) replaces the single
  event-wide manual entry; the event's cash actual is the live sum of these counts.
- New "Payment accountability by staff member" breakdown across all methods, and a
  read-only "Report" tab that opens automatically once an event is closed.

Reports page redesign:
- New shell: sidebar of universal filters (events, date range, past/inactive/closed
  toggles), searchable/categorized report grid, and a popup viewer with
  Print/Email/Excel/WhatsApp actions plus an in-app Reporting Guide.
- Visual pass: colored stat tiles and bar charts on most reports, matching mockups.
- PDF exports (download/Print/Email/WhatsApp) now share a branded design mirroring
  the web report — colored header, stat tiles, bar chart, highlighted totals.
- Excel export now produces a styled .xlsx (via exceljs) instead of a plain CSV.
- Master Orders' "Donations made" table is now included in every export channel.

Bug fixes discovered while testing exports:
- Report emails now go through the shared, DB-configurable mail utility instead of
  a one-off transporter that ignored Site Settings SMTP config.
- WhatsApp report sends now surface the actual WAWP API error and auto-recover a
  disconnected session, instead of a bare axios status-code message.

Also: Admin-editable notification preference, richer Admin Registrations dashboard,
{{payment.link}} placeholder for Email/WhatsApp Attendees, and background
email/WhatsApp attendee sending.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-04 14:52:54 +02:00

1551 lines
61 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
};