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>
1551 lines
61 KiB
JavaScript
1551 lines
61 KiB
JavaScript
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,
|
||
}; |