Files
hope-events/backend/src/controllers/ticketController.js
T
joshuaandClaude Opus 5.5 39558577f7 Add multi-day events (frontend), attendance register and certificates
Event wizard: one date or multiple sessions with a repeat-pattern builder,
and a new Attendance step (late joining and pricing, check-in window,
session reminders, certificates with preview). Public pages show the
schedule and late prices; the scanner picks today's session; new
attendance register page, My Certificates, and a public verify page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:53:39 +02:00

1157 lines
48 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 { safeErrorMessage } = require('../utils/errorUtils');
const { generateTicketsForRegistration } = require('../utils/ticketUtils');
const { assertRegistrationEventOpen } = require('../utils/cashupUtils');
// @desc Generate tickets for a registration
// @route POST /api/tickets/generate
// @access Private/Admin
const generateTickets = async (req, res) => {
try {
const { registrationId } = req.body;
if (!registrationId) {
res.status(400);
throw new Error('registrationId is required');
}
await assertRegistrationEventOpen(registrationId, res);
// Delegate fully to the shared utility which handles deduplication/consolidation
const newTickets = await generateTicketsForRegistration(registrationId);
// Fetch the final canonical ticket set (one per option)
const options = await prisma.registrationOption.findMany({ where: { registrationId } });
const tickets = await prisma.ticket.findMany({
where: { registrationOptionId: { in: options.map(o => o.id) } },
include: {
registrationOption: { include: { eventOption: true } },
user: true,
event: true
},
orderBy: { createdAt: 'asc' }
});
res.status(201).json({
message: `${newTickets.length} ticket(s) generated, ${tickets.length} total`,
tickets
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Get all tickets
// @route GET /api/tickets
// @access Private/Admin
const getTickets = async (req, res) => {
try {
const page = Math.max(1, parseInt(req.query.page) || 1);
const limit = Math.min(200, Math.max(1, parseInt(req.query.limit) || 100));
const skip = (page - 1) * limit;
const include = {
registrationOption: {
include: {
eventOption: true,
registration: {
include: { user: { select: { id: true, name: true, email: true, phoneNumber: true } } }
}
}
},
event: true,
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
};
const [tickets, total] = await prisma.$transaction([
prisma.ticket.findMany({ include, orderBy: { createdAt: 'desc' }, skip, take: limit }),
prisma.ticket.count()
]);
res.json({ data: tickets, total, page, limit, pages: Math.ceil(total / limit) });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Get user tickets
// @route GET /api/tickets/mytickets
// @access Private
const getUserTickets = async (req, res) => {
try {
// Tickets this user paid for, plus named tickets someone else bought for them.
const tickets = await prisma.ticket.findMany({
where: {
OR: [{ userId: req.user.id }, { holderUserId: req.user.id }],
registrationOption: { registration: { status: { not: 'cancelled' } } }
},
include: {
registrationOption: {
include: {
eventOption: true,
variant: true,
registration: true
}
},
attendee: {
select: {
id: true, name: true,
allocations: { where: { set: { showOnTicket: true } }, select: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } },
},
},
user: { select: { id: true, name: true } },
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
transferRequests: { where: { status: 'pending' }, select: { id: true, toName: true, expiresAt: true } },
usages: {
include: {
scannedBy: {
select: {
id: true,
name: true,
email: true
}
}
}
}
}
});
res.json(tickets);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Get ticket by ID
// @route GET /api/tickets/:id
// @access Private
const getTicketById = async (req, res) => {
try {
const ticket = await prisma.ticket.findUnique({
where: { id: req.params.id },
include: {
registrationOption: {
include: {
eventOption: true,
registration: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
phoneNumber: true
}
}
}
}
}
},
event: true,
user: {
select: {
id: true,
name: true,
email: true,
phoneNumber: true
}
},
usages: {
include: {
scannedBy: {
select: {
id: true,
name: true,
email: true
}
}
}
}
}
});
if (!ticket) {
res.status(404);
throw new Error('Ticket not found');
}
// Check if user is authorized to view this ticket
if (ticket.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 ticket');
}
res.json(ticket);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Get ticket by QR code
// @route GET /api/tickets/qr/:qrCode
// @access Private/Staff
const getTicketByQrCode = async (req, res) => {
try {
const ticket = await prisma.ticket.findUnique({
where: { qrCode: req.params.qrCode },
include: {
registrationOption: {
include: {
eventOption: true,
registration: {
include: {
user: {
select: {
id: true,
name: true,
email: true,
phoneNumber: true
}
}
}
}
}
},
event: true,
user: {
select: {
id: true,
name: true,
email: true,
phoneNumber: true
}
},
usages: {
include: {
scannedBy: {
select: {
id: true,
name: true,
email: true
}
}
}
}
}
});
if (!ticket) {
res.status(404);
throw new Error('Ticket not found');
}
res.json(ticket);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Lightweight ticket preview for the scan-confirm flow (only fields the confirm modal needs)
// @route GET /api/tickets/scan-preview/:qrCode
// @access Private/Staff
const getScanPreview = async (req, res) => {
try {
// Single JOIN query instead of 6 sequential Prisma round-trips (critical for remote DBs)
const rows = await prisma.$queryRaw`
SELECT
t.id,
t."qrCode",
t.quantity,
t."isUsed",
t."eventId",
ro.id AS "registrationOptionId",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
ov.id AS "variantId",
ov.name AS "variantName",
e.id AS "evId",
e.title AS "eventTitle",
e."scheduleType" AS "scheduleType",
u.id AS "userId",
u.name AS "userName",
a.name AS "attendeeName",
COALESCE(
json_agg(
json_build_object(
'quantityRedeemed', tu."quantityRedeemed",
'scannedAt', tu."scannedAt",
'sessionId', tu."sessionId"
)
) FILTER (WHERE tu.id IS NOT NULL),
'[]'::json
) AS usages
FROM "Ticket" t
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
LEFT JOIN "OptionVariant" ov ON ov.id = ro."variantId"
LEFT JOIN "Event" e ON e.id = t."eventId"
LEFT JOIN "User" u ON u.id = t."userId"
LEFT JOIN "Attendee" a ON a.id = t."attendeeId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${req.params.qrCode}
GROUP BY t.id, ro.id, eo.id, ov.id, e.id, u.id, a.id
`;
if (!rows || rows.length === 0) {
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
const transfer = await findTransferByOldCode(req.params.qrCode);
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
res.status(404);
throw new Error('Ticket not found');
}
const r = rows[0];
const { consentStatusForTickets } = require('../utils/consent');
const consentStatus = (await consentStatusForTickets([r.id])).get(r.id) || 'not_required';
const usages = r.usages || [];
// Multi-day events: check-in is per session, so what's left is counted for the
// session this scan is for (the one picked, or today's).
let remaining = Number(r.quantity) - usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
let session = null;
let sessionError = null;
if (r.scheduleType === 'sessions') {
const sessionsUtil = require('../utils/sessions');
const schedule = await sessionsUtil.eventSchedule(r.eventId);
const found = sessionsUtil.scanSession(schedule, schedule.sessions, req.query.sessionId || null);
sessionError = found.error;
if (found.session) {
session = { id: found.session.id, label: sessionsUtil.sessionLabel(found.session, schedule.sessions) };
remaining = Number(r.quantity) - sessionsUtil.redeemedForSession(usages, found.session.id);
} else {
remaining = 0;
}
}
res.json({
id: r.id,
quantity: Number(r.quantity),
isUsed: r.isUsed,
eventId: r.eventId,
consentStatus,
remaining,
session,
sessionError,
registrationOption: {
id: r.registrationOptionId,
eventOption: { id: r.eventOptionId, name: r.eventOptionName },
variant: r.variantId ? { id: r.variantId, name: r.variantName } : null,
},
event: { id: r.evId, title: r.eventTitle },
user: { id: r.userId, name: r.userName },
attendee: r.attendeeName ? { name: r.attendeeName } : null,
usages
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Scan ticket (mark as used, with optional partial quantity redemption)
// @route POST /api/tickets/scan/:qrCode
// @access Private/Staff
const scanTicket = async (req, res) => {
try {
// Single JOIN query — avoids multiple sequential round-trips to the remote DB
const rows = await prisma.$queryRaw`
SELECT
t.id,
t.quantity,
t."isUsed",
t."eventId",
e.title AS "eventTitle",
e."scheduleType" AS "scheduleType",
eo.id AS "eventOptionId",
eo.name AS "eventOptionName",
eo."isMainTicket" AS "isMainTicket",
COALESCE(
json_agg(
json_build_object(
'id', tu.id,
'quantityRedeemed', tu."quantityRedeemed",
'scannedAt', tu."scannedAt",
'sessionId', tu."sessionId"
)
) FILTER (WHERE tu.id IS NOT NULL),
'[]'::json
) AS usages
FROM "Ticket" t
LEFT JOIN "Event" e ON e.id = t."eventId"
LEFT JOIN "RegistrationOption" ro ON ro.id = t."registrationOptionId"
LEFT JOIN "EventOption" eo ON eo.id = ro."eventOptionId"
LEFT JOIN "TicketUsage" tu ON tu."ticketId" = t.id
WHERE t."qrCode" = ${req.params.qrCode}
GROUP BY t.id, e.id, eo.id
`;
if (!rows || rows.length === 0) {
const { findTransferByOldCode, transferredMessage } = require('../utils/ticketTransfer');
const transfer = await findTransferByOldCode(req.params.qrCode);
if (transfer) return res.status(410).json({ message: transferredMessage(transfer), code: 'TICKET_TRANSFERRED' });
res.status(404);
throw new Error('Ticket not found');
}
const r = rows[0];
const ticket = {
id: r.id,
quantity: Number(r.quantity),
isUsed: r.isUsed,
eventId: r.eventId,
event: { title: r.eventTitle },
registrationOption: { eventOption: { id: r.eventOptionId, name: r.eventOptionName, isMainTicket: r.isMainTicket } },
usages: r.usages || []
};
// Optional server-side guard: ensure ticket belongs to the requested event when provided
const providedEventId = String((req.query?.eventId || req.body?.eventId) || '').trim();
if (providedEventId && ticket.eventId !== providedEventId) {
return res.status(400).json({ message: 'Ticket belongs to a different event', ticket });
}
// Section checks used to happen only in the browser; enforce them here too.
const sectionId = String((req.query?.sectionId || req.body?.sectionId) || '').trim() || null;
if (sectionId) {
const section = await prisma.section.findUnique({ where: { id: sectionId }, include: { allowedOptions: true } });
if (!section || section.eventId !== ticket.eventId) {
return res.status(400).json({ message: 'Section not found for this event', ticket });
}
if (!section.allowedOptions.some(o => o.eventOptionId === r.eventOptionId)) {
return res.status(400).json({ message: 'This ticket is not valid for this section', ticket });
}
}
// A section (or the whole event) locked to another device for offline scanning
// can't be scanned online — those scans would be invisible to the offline device.
const { blockingLock } = require('../utils/scanLocks');
const lock = await blockingLock({ eventId: ticket.eventId, optionId: r.eventOptionId }, { sectionId, deviceId: req.body?.deviceId || null });
if (lock) {
const scope = lock.section ? `"${lock.section.name}"` : 'This event';
return res.status(423).json({ message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.`, code: 'SCAN_LOCKED' });
}
// Multi-day events: the check-in is for one session (the one picked, or today's), and
// what's left is counted per session.
let session = null;
let sessionsUtil = null;
let schedule = null;
if (r.scheduleType === 'sessions') {
sessionsUtil = require('../utils/sessions');
schedule = await sessionsUtil.eventSchedule(ticket.eventId);
const found = sessionsUtil.scanSession(schedule, schedule.sessions, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null);
if (!found.session) return res.status(400).json({ message: found.error, code: 'NO_SESSION', ticket });
session = found.session;
}
// Compute how many have already been redeemed
const totalRedeemed = session
? sessionsUtil.redeemedForSession(ticket.usages, session.id)
: (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const remaining = (ticket.quantity || 1) - totalRedeemed;
if (remaining <= 0) {
if (session) {
const last = (ticket.usages || []).filter(u => u.sessionId === session.id).sort((a, b) => new Date(b.scannedAt) - new Date(a.scannedAt))[0];
const at = last ? new Date(last.scannedAt).toLocaleTimeString('en-ZA', { timeZone: sessionsUtil.TIMEZONE, hour: '2-digit', minute: '2-digit' }) : '';
return res.status(403).json({
message: `Already checked in for ${sessionsUtil.sessionLabel(session, schedule.sessions)}${at ? ` at ${at}` : ''}`,
ticket,
remaining: 0
});
}
return res.status(403).json({
message: 'Ticket has already been fully used',
ticket,
remaining: 0
});
}
// Youth events: no check-in without parental consent, unless a supervisor overrides.
const { consentStatusForTickets } = require('../utils/consent');
const consentStatus = (await consentStatusForTickets([ticket.id])).get(ticket.id) || 'not_required';
if (!['not_required', 'given'].includes(consentStatus)) {
const canOverride = ['admin', 'supervisor'].includes(req.user?.role);
if (!(req.body?.overrideConsent === true && canOverride)) {
const why = consentStatus === 'declined' ? 'Parental consent was declined' : consentStatus === 'pending' ? 'Parental consent is still pending' : 'Parental consent is missing';
return res.status(403).json({ message: `${why} — this ticket can't be checked in.`, code: 'CONSENT_REQUIRED', consentStatus, canOverride, ticket });
}
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'consent_override',
targetType: 'Ticket', targetId: ticket.id,
metadata: { consentStatus, eventId: ticket.eventId },
ip: getClientIp(req),
});
}
// Determine how many to redeem this scan (defaults to all remaining)
const requestedQty = parseInt(req.body?.qty || req.body?.quantity) || remaining;
const qtyToRedeem = Math.min(Math.max(1, requestedQty), remaining);
const newTotalRedeemed = totalRedeemed + qtyToRedeem;
const newRemaining = (ticket.quantity || 1) - newTotalRedeemed;
const fullyUsed = newRemaining <= 0;
// Run the usage insert and ticket status update in parallel — they don't depend on each other
const [ticketUsage] = await Promise.all([
prisma.ticketUsage.create({
data: {
id: uuidv4(),
ticketId: ticket.id,
scannedById: req.user.id,
quantityRedeemed: qtyToRedeem,
sessionId: session ? session.id : null,
}
}),
// A multi-session ticket is never "used up" — it's checked in again next session.
prisma.ticket.update({
where: { id: ticket.id },
data: { isUsed: session ? false : fullyUsed, updatedAt: new Date() }
})
]);
// Only Main Tickets trigger a check-in notification (add-on tickets scanned via
// the QR camera flow are unaffected)
if (ticket.registrationOption?.eventOption?.isMainTicket) {
require('../utils/notifications')
.sendCheckInEmails(ticket.id, qtyToRedeem, newTotalRedeemed, newRemaining)
.catch(e => console.error('Failed to send check-in notification:', e));
}
const sessionPart = session ? ` for ${sessionsUtil.sessionLabel(session, schedule.sessions)}` : '';
res.json({
message: `Ticket scanned successfully${sessionPart} (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null,
ticketUsage,
ticket,
qtyRedeemed: qtyToRedeem,
totalRedeemed: newTotalRedeemed,
remaining: newRemaining,
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Get tickets by event
// @route GET /api/tickets/event/:eventId
// @access Private/Staff
const getTicketsByEvent = async (req, res) => {
try {
const limit = Math.min(1000, Math.max(1, parseInt(req.query.limit) || 1000));
const tickets = await prisma.ticket.findMany({
where: { eventId: req.params.eventId },
include: {
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { id: true } } } },
user: { select: { id: true, name: true, email: true, phoneNumber: true } },
attendee: { select: { id: true, name: true, email: true, phone: true } },
event: { select: { id: true, title: true, startDate: true, requiresParentalConsent: true, allowTicketTransfer: true, collectGender: true, scheduleType: true, sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' }, select: { id: true, label: true, startsAt: true, endsAt: true } } } },
usages: { include: { scannedBy: { select: { id: true, name: true, email: true } } } }
},
orderBy: { createdAt: 'desc' },
take: limit
});
res.json(tickets);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Mark tickets as email sent
// @route PUT /api/tickets/email-sent
// @access Private/Admin
const markTicketsAsEmailSent = async (req, res) => {
try {
const { ticketIds } = req.body;
if (!ticketIds || !Array.isArray(ticketIds) || ticketIds.length === 0) {
res.status(400);
throw new Error('Ticket IDs are required');
}
// Update tickets
await prisma.ticket.updateMany({
where: {
id: {
in: ticketIds
}
},
data: {
emailSent: true,
updatedAt: new Date()
}
});
res.json({ message: `${ticketIds.length} tickets marked as email sent` });
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Email (and WhatsApp if preference set) tickets to user
// @route POST /api/tickets/email
// @access Private
// Optional body param: channel ('email'|'whatsapp'|'both') — overrides user's notification preference for this request
const emailTickets = async (req, res) => {
try {
// If caller specifies an explicit channel, build a minimal userOverride that forces that preference
const { channel } = req.body || {};
let userOverride = null;
if (channel && ['email', 'whatsapp', 'both'].includes(channel)) {
// Load base user data then override the preference
const userId = req.user?.id;
if (userId) {
const baseUser = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, name: true, phoneNumber: true, notificationPreference: true }
});
if (baseUser) {
userOverride = { ...baseUser, notificationPreference: channel };
}
}
}
return await emailTicketsInternal(req, res, userOverride);
} catch (error) {
console.error('Error emailing tickets:', error);
res.status(error.statusCode || 400).json({ message: error.message });
}
};
// @desc Get recent ticket scans
// @route GET /api/tickets/scans/recent
// @access Private/Staff
const getRecentScans = async (req, res) => {
try {
const limit = Math.max(1, Math.min(parseInt(req.query.limit) || 10, 100));
const eventId = req.query.eventId || undefined;
const scans = await prisma.ticketUsage.findMany({
where: eventId ? { ticket: { eventId } } : undefined,
orderBy: { scannedAt: 'desc' },
take: limit,
select: {
id: true,
scannedAt: true,
quantityRedeemed: true,
scannedBy: { select: { id: true, name: true } },
ticket: {
select: {
id: true,
event: { select: { title: true } },
registrationOption: { select: { eventOption: { select: { name: true } } } }
}
}
}
});
res.json(scans);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Get scan statistics
// @route GET /api/tickets/scans/stats
// @access Private/Staff
const getScanStats = async (req, res) => {
try {
const eventId = req.query.eventId || undefined;
const startOfDay = new Date();
startOfDay.setHours(0, 0, 0, 0);
// Total scans today (optionally by event)
const whereBase = {
scannedAt: { gte: startOfDay },
...(eventId ? { ticket: { eventId } } : {})
};
const [totalToday, myToday, lastHour] = await Promise.all([
prisma.ticketUsage.count({ where: whereBase }),
prisma.ticketUsage.count({ where: { ...whereBase, scannedById: req.user.id } }),
prisma.ticketUsage.count({
where: {
...(eventId ? { ticket: { eventId } } : {}),
scannedAt: { gte: new Date(Date.now() - 60 * 60 * 1000) }
}
})
]);
// Per-scanner breakdown today
const byStaff = await prisma.ticketUsage.groupBy({
by: ['scannedById'],
where: whereBase,
_count: { _all: true }
});
const staffIds = byStaff.map(b => b.scannedById);
const staffUsers = staffIds.length > 0 ? await prisma.user.findMany({
where: { id: { in: staffIds } },
select: { id: true, name: true }
}) : [];
const nameMap = Object.fromEntries(staffUsers.map(u => [u.id, u.name]));
res.json({
totalToday,
myToday,
lastHour,
byStaff: byStaff.map(b => ({ scannedById: b.scannedById, name: nameMap[b.scannedById] || 'Staff', count: b._count._all }))
});
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Send tickets to a specific phone/email (staff override for at-the-door)
// @route POST /api/tickets/send-to
// @access Private/Staff
const sendTicketsTo = async (req, res) => {
try {
const { registrationId, ticketIds, channel, overridePhone, overrideEmail } = req.body;
if (!registrationId && (!ticketIds || !Array.isArray(ticketIds) || ticketIds.length === 0)) {
res.status(400); throw new Error('registrationId or ticketIds required');
}
if (!channel || !['email','whatsapp','both'].includes(channel)) {
res.status(400); throw new Error('channel must be email, whatsapp, or both');
}
// Determine which user owns the tickets to get their default contact info
let ownerUserId;
if (registrationId) {
const reg = await prisma.registration.findUnique({ where: { id: registrationId }, select: { userId: true } });
if (!reg) { res.status(404); throw new Error('Registration not found'); }
ownerUserId = reg.userId;
} else {
const firstTicket = await prisma.ticket.findUnique({ where: { id: ticketIds[0] }, select: { userId: true } });
if (!firstTicket) { res.status(404); throw new Error('Ticket not found'); }
ownerUserId = firstTicket.userId;
}
// Use a mock req that targets the ticket owner; override contact details in user object via closure
const originalUser = await prisma.user.findUnique({
where: { id: ownerUserId },
select: { id: true, email: true, name: true, phoneNumber: true, notificationPreference: true }
});
if (!originalUser) { res.status(404); throw new Error('Ticket owner not found'); }
// Build a virtual user with override contact details
const virtualUser = {
...originalUser,
email: overrideEmail || originalUser.email,
phoneNumber: overridePhone || originalUser.phoneNumber,
// Force the preference to match the requested channel
notificationPreference: channel,
};
// Reuse emailTickets logic via mock request but with virtual user
// We build a minimal mock and call the internal flow directly
const mockBody = registrationId ? { registrationId } : { ticketIds };
const mockReq = { user: { id: ownerUserId }, body: mockBody, _virtualUser: virtualUser };
const mockRes = { status: () => mockRes, json: (body) => { res.json(body); } };
// Call emailTickets with the virtual user override
await emailTicketsInternal(mockReq, mockRes, virtualUser);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
// @desc Offer a ticket to someone else: sends them a link to accept (nothing changes until they do)
// @route POST /api/tickets/:id/transfer { name, email?, phone?, dateOfBirth?, gender?, parentName?, parentEmail?, parentPhone? }
// @access Private (payer or holder; supervisors always)
const transferTicketHandler = async (req, res) => {
try {
const { requestTransfer } = require('../utils/ticketTransfer');
const { name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone } = req.body || {};
const { request, sent } = await requestTransfer({ ticketId: req.params.id, actor: req.user, name, email, phone, dateOfBirth, gender, parentName, parentEmail, parentPhone });
res.status(201).json({
message: sent.length
? `We've sent ${request.toName} a link by ${sent.join(' and ')} to accept the ticket. It moves to them once they accept — until then it's still yours.`
: `The transfer is waiting for ${request.toName} to accept, but the link couldn't be sent. Check the email or phone number.`,
requestId: request.id,
expiresAt: request.expiresAt,
});
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
// @desc Withdraw a transfer that hasn't been accepted yet
// @route DELETE /api/tickets/:id/transfer
// @access Private (payer or holder; supervisors)
const cancelTransferHandler = async (req, res) => {
try {
const { cancelTransfer } = require('../utils/ticketTransfer');
await cancelTransfer(req.params.id, req.user);
res.json({ message: 'Transfer cancelled — the ticket is still yours.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
}
};
// @desc What's on offer behind a transfer link
// @route GET /api/tickets/transfer-offer/:token
// @access Public (link holder)
const getTransferOffer = async (req, res) => {
try {
const { findRequestByToken } = require('../utils/ticketTransfer');
const r = await findRequestByToken(req.params.token);
if (!r) return res.status(404).json({ message: 'This transfer link isn’t valid.' });
const t = r.ticket;
res.json({
status: r.status, toName: r.toName, expiresAt: r.expiresAt,
fromName: (t.attendee?.name || t.user?.name || r.requestedBy.name || '').split(' ')[0],
event: t.event,
option: t.registrationOption.variant ? `${t.registrationOption.eventOption.name} — ${t.registrationOption.variant.name}` : t.registrationOption.eventOption.name,
});
} catch (error) {
res.status(400).json({ message: safeErrorMessage(error) });
}
};
// @route POST /api/tickets/transfer-offer/:token/decline
const declineTransferOffer = async (req, res) => {
try {
const { declineTransfer } = require('../utils/ticketTransfer');
await declineTransfer(req.params.token);
res.json({ message: 'You declined the ticket. Nothing else will happen.' });
} catch (error) {
res.status(error.statusCode || 400).json({ message: safeErrorMessage(error) });
}
};
// @desc Accept a transferred ticket — the transfer happens now
// @route POST /api/tickets/transfer-offer/:token/accept
// @access Public (link holder)
const acceptTransferOffer = async (req, res) => {
try {
const { acceptTransfer } = require('../utils/ticketTransfer');
const r = await acceptTransfer(req.params.token);
const actor = r.request.requestedBy;
const { logAdminAction } = require('../utils/adminAudit');
const { getClientIp } = require('../utils/requestUtils');
logAdminAction({
actorId: actor.id, actorRole: actor.role, action: 'ticket_transferred', targetType: 'Ticket', targetId: r.target.id,
metadata: { eventId: r.event.id, registrationId: r.registrationId, from: r.fromHolderName, to: r.attendee.name, toUserId: r.toUser.id, split: !r.transfer.oldQrCode, acceptedVia: 'link' },
ip: getClientIp(req),
});
// Tell the new holder: activation link for a new account, their ticket, and
// anything they need to do themselves (consent, attendee form).
if (r.accountCreated) {
const { sendActivationLink } = require('./userController');
sendActivationLink(r.toUser);
}
const a = r.attendee;
const hasEmail = !!(a.email && a.email.includes('@'));
const recipient = { id: r.toUser.id, name: a.name, email: hasEmail ? a.email : null, phoneNumber: a.phone || null, notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp' };
const silent = { status: () => silent, json: () => silent };
emailTicketsInternal({ user: { id: r.target.userId }, body: { ticketIds: [r.target.id] }, _noSplit: true }, silent, recipient)
.catch(e => console.error('[transfer] ticket delivery failed:', e?.message || e));
if (r.needsConsent) {
const { createConsentRequest } = require('../utils/consent');
createConsentRequest(a, r.event).catch(e => console.error('[transfer] consent request failed:', e?.message || e));
}
const form = await prisma.eventForm.findUnique({ where: { eventId: r.event.id }, include: { fields: { select: { id: true } } } });
if (form && form.fields.length > 0) {
const { sendAttendeeFormLinkMessage } = require('../utils/notifications');
const full = await prisma.attendee.findUnique({ where: { id: a.id }, include: { registration: { include: { event: true, user: { select: { id: true, name: true } } } } } });
sendAttendeeFormLinkMessage(full).catch(() => {});
}
res.json({
message: `The ticket is yours! We've sent it to you${r.needsConsent ? ', and asked your parent or guardian for consent' : ''}.${r.accountCreated ? ' We have also sent you a link to activate your account, where you can see your tickets.' : ''}`,
ticketId: r.target.id,
});
} catch (error) {
res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) });
}
};
// @desc Transfer history of a ticket
// @route GET /api/tickets/:id/transfers
// @access Private (payer or holder; staff)
const getTicketTransfers = async (req, res) => {
try {
const ticket = await prisma.ticket.findUnique({ where: { id: req.params.id }, select: { userId: true, holderUserId: true } });
if (!ticket) { res.status(404); throw new Error('Ticket not found'); }
const staffRole = ['admin', 'supervisor', 'staff'].includes(req.user.role);
if (!staffRole && ticket.userId !== req.user.id && ticket.holderUserId !== req.user.id) { res.status(403); throw new Error('Not authorized'); }
const transfers = await prisma.ticketTransfer.findMany({
where: { ticketId: req.params.id },
select: { id: true, fromHolderName: true, toName: true, createdAt: true, byUser: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
});
res.json(transfers);
} catch (error) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
}
};
/** "Room: Cabin 3 · Bus: Bus 2" for the sets shown on tickets, or ''. */
function allocationLabel(attendee) {
return [...(attendee?.allocations || [])]
.sort((a, b) => (a.set?.order || 0) - (b.set?.order || 0))
.map(a => `${a.set.name}: ${a.group.name}`)
.join(' · ');
}
// One ticket card on the PDF, matching the printed design in frontend/src/lib/ticketPrint.ts.
function drawTicketCard(doc, { x, y, w, h }, ticket, qrDataUrl, buyerName) {
const pad = 12; const radius = 9;
const innerW = w - pad * 2;
const gray700 = '#374151'; const gray500 = '#6b7280';
doc.save();
doc.roundedRect(x, y + 1.5, w, h, radius).fillOpacity(0.06).fill('#000000');
doc.restore();
doc.save();
doc.roundedRect(x, y, w, h, radius).lineWidth(0.75).fillAndStroke('#ffffff', '#e5e7eb');
doc.restore();
// Event title, with the date on the right of the same line
let cy = y + pad;
const eventDate = ticket.event.startDate ? new Date(ticket.event.startDate) : null;
const dateStr = require('../utils/sessions').ticketDateText(ticket.event)
|| (eventDate ? new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate) : '');
doc.font('Helvetica').fontSize(9);
const dateW = dateStr ? doc.widthOfString(dateStr) : 0;
if (dateStr) doc.fillColor(gray500).text(dateStr, x + w - pad - dateW, cy + 1, { width: dateW, lineBreak: false });
const titleW = innerW - (dateStr ? dateW + 8 : 0);
doc.font('Helvetica-Bold').fontSize(11).fillColor('#111827');
doc.text(ticket.event.title, x + pad, cy, { width: titleW });
cy += doc.heightOfString(ticket.event.title, { width: titleW }) + 4;
// Ticket type
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
const type = `${ticket.registrationOption.eventOption.name}${variantSuffix}`;
doc.font('Helvetica').fontSize(10).fillColor(gray700).text(type, x + pad, cy, { width: innerW });
cy += doc.heightOfString(type, { width: innerW }) + 2;
// Name (named ticket) or quantity, value in bold
doc.font('Helvetica').fontSize(9).fillColor(gray700)
.text(ticket.attendee ? 'Name: ' : 'Qty: ', x + pad, cy, { continued: true })
.font('Helvetica-Bold').text(ticket.attendee ? ticket.attendee.name : String(ticket.quantity || 1));
cy += 12;
const groupLabel = allocationLabel(ticket.attendee);
if (groupLabel) {
doc.font('Helvetica').fontSize(9).fillColor(gray500).text(groupLabel, x + pad, cy, { width: innerW });
cy += doc.heightOfString(groupLabel, { width: innerW }) + 2;
}
// Footer lines are measured first so the QR code takes the remaining space
const footer = [`Ticket ID: ${ticket.id}`, ...(buyerName ? [`Purchased by: ${buyerName}`] : [])];
doc.font('Helvetica').fontSize(8.5);
const footerH = footer.reduce((sum, line) => sum + doc.heightOfString(line, { width: innerW }) + 2, 0);
const qrSpace = (y + h - pad - footerH) - (cy + 6) - 6;
const qrSize = Math.max(60, Math.min(130, qrSpace));
const qrY = cy + 6 + Math.max(0, (qrSpace - qrSize) / 2);
doc.image(qrDataUrl, x + (w - qrSize) / 2, qrY, { width: qrSize, height: qrSize });
let fy = y + h - pad - footerH;
doc.fillColor(gray500);
for (const line of footer) {
doc.text(line, x + pad, fy, { width: innerW, align: 'center' });
fy += doc.heightOfString(line, { width: innerW }) + 2;
}
doc.fillColor('#000000');
}
// Internal helper used by both emailTickets and sendTicketsTo
async function emailTicketsInternal(req, res, userOverride) {
const { ticketIds, registrationId } = req.body;
const userId = req.user.id;
const user = userOverride || await prisma.user.findUnique({
where: { id: userId },
select: { email: true, name: true, phoneNumber: true, notificationPreference: true }
});
if (!user) { res.status(404); throw new Error('User not found'); }
const pref = user.notificationPreference || 'email';
const noValidEmail = !user.email || user.email.endsWith('@guest.local');
// For users without a valid email, WhatsApp is used as fallback regardless of preference
const canSendWA = !!user.phoneNumber && ((pref === 'whatsapp' || pref === 'both') || noValidEmail);
// Only block if neither channel can deliver
if (noValidEmail && !canSendWA) {
return res.json({ message: 'No valid contact details on file — ticket delivery skipped.' });
}
const ticketInclude = {
event: { include: { sessions: { where: { cancelledAt: null }, orderBy: { startsAt: 'asc' } } } },
registrationOption: { include: { eventOption: true, variant: true, registration: { select: { ticketDelivery: true } } } },
attendee: { include: { allocations: { where: { set: { showOnTicket: true } }, include: { group: { select: { name: true } }, set: { select: { name: true, order: true } } } } } },
user: { select: { name: true } },
usages: true,
};
let tickets;
if (registrationId) {
const registration = await prisma.registration.findUnique({
where: { id: registrationId, userId }
});
if (!registration) { res.status(404); throw new Error('Registration not found or does not belong to you'); }
const registrationOptions = await prisma.registrationOption.findMany({ where: { registrationId } });
tickets = await prisma.ticket.findMany({
where: { registrationOptionId: { in: registrationOptions.map(o => o.id) }, userId },
include: ticketInclude,
orderBy: { createdAt: 'asc' }
});
} else {
// The payer, or the named holder of a group ticket, can have it sent.
tickets = await prisma.ticket.findMany({
where: { id: { in: ticketIds }, OR: [{ userId }, { holderUserId: userId }] },
include: ticketInclude,
orderBy: { createdAt: 'asc' }
});
}
// Group tickets set to "send each person their own": attendees with contact details
// get theirs directly; the rest (and attendees without contact details) go to the
// payer below. Not when the caller asked for a specific recipient or channel.
if (!userOverride && !req._noSplit) {
const own = tickets.filter(t => t.attendee && (t.attendee.email || t.attendee.phone)
&& t.registrationOption?.registration?.ticketDelivery === 'each');
for (const t of own) {
const a = t.attendee;
const hasEmail = !!(a.email && a.email.includes('@'));
const recipient = {
id: a.userId || null,
name: a.name,
email: hasEmail ? a.email : null,
phoneNumber: a.phone || null,
notificationPreference: hasEmail && a.phone ? 'both' : hasEmail ? 'email' : 'whatsapp',
};
const silent = { status: () => silent, json: () => silent };
try {
await emailTicketsInternal({ user: { id: userId }, body: { ticketIds: [t.id] }, _noSplit: true }, silent, recipient);
} catch (e) {
console.error(`[tickets] Failed to send ${a.name}'s ticket:`, e?.message || e);
}
}
const sentIds = new Set(own.map(t => t.id));
tickets = tickets.filter(t => !sentIds.has(t.id));
if (tickets.length === 0 && own.length > 0) {
return res.json({ message: `${own.length} ticket(s) sent to the people they're for`, ticketIds: own.map(t => t.id) });
}
}
// Dedup by registrationOptionId — except named tickets, which are one per person.
{
const seen = new Map(); const deduped = [];
for (const t of tickets) {
const key = t.attendeeId ? `attendee:${t.attendeeId}` : t.registrationOptionId;
if (!seen.has(key)) { seen.set(key, t); deduped.push(t); }
else {
const existing = seen.get(key);
if ((existing.usages||[]).length === 0 && (t.usages||[]).length > 0) {
seen.set(key, t); deduped[deduped.indexOf(existing)] = t;
}
}
}
tickets = deduped;
}
if (tickets.length === 0) { res.status(404); throw new Error('No valid tickets found'); }
// Generate PDF
const PDFDocument = require('pdfkit');
const QRCode = require('qrcode');
const fs = require('fs');
const path = require('path');
const tempFilePath = path.join(__dirname, '..', '..', 'temp', `tickets-${userId}-${Date.now()}.pdf`);
const tempDir = path.join(__dirname, '..', '..', 'temp');
if (!fs.existsSync(tempDir)) fs.mkdirSync(tempDir, { recursive: true });
const doc = new PDFDocument({ size: 'A4', margin: 20 });
const writeStream = fs.createWriteStream(tempFilePath);
doc.pipe(writeStream);
// Same card design as the printed tickets (frontend/src/lib/ticketPrint.ts): rounded white
// cards with a light border and soft shadow, two columns, three rows per page.
const margin = 20; const gap = 12;
const ticketsPerRow = 2; const ticketsPerColumn = 3;
const ticketWidth = (doc.page.width - margin * 2 - gap * (ticketsPerRow - 1)) / ticketsPerRow;
const ticketHeight = (doc.page.height - margin * 2 - gap * (ticketsPerColumn - 1)) / ticketsPerColumn;
const qrCodes = await Promise.all(tickets.map(ticket => new Promise((resolve, reject) => {
QRCode.toDataURL(ticket.qrCode, { margin: 1, width: 400 }, (err, url) => err ? reject(err) : resolve({ ticketId: ticket.id, qrDataUrl: url }));
})));
const qrCodeMap = qrCodes.reduce((map, item) => { map[item.ticketId] = item.qrDataUrl; return map; }, {});
let ticketIndex = 0;
for (const ticket of tickets) {
const slot = ticketIndex % (ticketsPerRow * ticketsPerColumn);
const x = margin + (slot % ticketsPerRow) * (ticketWidth + gap);
const y = margin + Math.floor(slot / ticketsPerRow) * (ticketHeight + gap);
drawTicketCard(doc, { x, y, w: ticketWidth, h: ticketHeight }, ticket, qrCodeMap[ticket.id], ticket.user?.name || user.name);
ticketIndex++;
if (ticketIndex % (ticketsPerRow * ticketsPerColumn) === 0 && ticketIndex < tickets.length) doc.addPage();
}
doc.end();
await new Promise((resolve, reject) => { writeStream.on('finish', resolve); writeStream.on('error', reject); });
const eventTitle = tickets[0].event.title;
const eventDate = require('../utils/sessions').ticketDateText(tickets[0].event, 'long') || (tickets[0].event.startDate
? new Date(tickets[0].event.startDate).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' })
: '');
const pdfFilename = `tickets-${eventTitle.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
const sentChannels = [];
const { sendMail, emailWrapper } = require('../utils/email');
const { canWhatsApp, waPdf } = require('../utils/notify');
const { buildWATicketCaption } = require('../utils/waMessages');
const orgUrl = (process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const ff = `-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif`;
const ticketHtml = emailWrapper(`
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 8px 0">🎟️ Your tickets are here!</p>
<p style="margin:0 0 8px 0;color:#374151;font-family:${ff}">Hi <strong>${user.name}</strong>,</p>
<p style="margin:0 0 24px 0;color:#374151;font-family:${ff}">Your tickets for <strong>${eventTitle}</strong>${eventDate ? ` on <strong>${eventDate}</strong>` : ''} are attached.</p>
<p style="font-size:13px;color:#64748b;font-family:${ff}">Questions? Visit <a href="${orgUrl}" style="color:#2563eb">${orgUrl}</a>.</p>
`, { preheader: `Your tickets for ${eventTitle} are attached!` });
// Send email unless preference is whatsapp-only
if (pref !== 'whatsapp' && user.email && !user.email.endsWith('@deleted.invalid') && !user.email.endsWith('@guest.local')) {
await sendMail({
to: user.email,
subject: `Your tickets for ${eventTitle}`,
html: ticketHtml,
text: `Hi ${user.name},\n\nYour tickets for ${eventTitle}${eventDate ? ' on ' + eventDate : ''} are attached.\n\nSee you there!`,
attachments: [{ filename: pdfFilename, path: tempFilePath, contentType: 'application/pdf' }],
});
sentChannels.push('email');
}
// Send WhatsApp if preference includes it, or as fallback when email isn't available
if (pref === 'whatsapp' || pref === 'both' || noValidEmail) {
const { normalizeZAPhone } = require('../utils/whatsapp');
const normalizedPhone = normalizeZAPhone(user.phoneNumber);
if (normalizedPhone) {
const caption = buildWATicketCaption({ name: user.name, eventTitle, eventDate });
await waPdf({ ...user, phoneNumber: normalizedPhone, notificationPreference: 'both' }, tempFilePath, pdfFilename, caption).catch(() => {});
sentChannels.push('whatsapp');
}
}
try { require('fs').unlinkSync(tempFilePath); } catch {}
await prisma.ticket.updateMany({ where: { id: { in: tickets.map(t => t.id) } }, data: { emailSent: true, updatedAt: new Date() } });
res.json({ message: `${tickets.length} ticket(s) sent via ${sentChannels.join(' & ') || 'no channel'}`, ticketIds: tickets.map(t => t.id) });
}
module.exports = {
generateTickets,
getTickets,
getUserTickets,
getTicketById,
getTicketByQrCode,
getScanPreview,
scanTicket,
getTicketsByEvent,
markTicketsAsEmailSent,
emailTickets,
sendTicketsTo,
getRecentScans,
getScanStats,
transferTicketHandler,
cancelTransferHandler,
getTransferOffer,
acceptTransferOffer,
declineTransferOffer,
getTicketTransfers
};