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

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

1053 lines
43 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: true,
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",
u.id AS "userId",
u.name AS "userName",
a.name AS "attendeeName",
COALESCE(
json_agg(
json_build_object(
'quantityRedeemed', tu."quantityRedeemed",
'scannedAt', tu."scannedAt"
)
) 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';
res.json({
id: r.id,
quantity: Number(r.quantity),
isUsed: r.isUsed,
eventId: r.eventId,
consentStatus,
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: r.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",
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"
)
) 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' });
}
// Compute how many have already been redeemed
const totalRedeemed = (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0);
const remaining = (ticket.quantity || 1) - totalRedeemed;
if (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,
}
}),
prisma.ticket.update({
where: { id: ticket.id },
data: { isUsed: 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));
}
res.json({
message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`,
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 } },
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(' · ');
}
// 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: true,
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);
const pageWidth = doc.page.width - 40; const pageHeight = doc.page.height - 40;
const ticketsPerRow = 2; const ticketsPerColumn = 4;
const ticketWidth = pageWidth / ticketsPerRow; const ticketHeight = pageHeight / ticketsPerColumn;
const qrCodes = await Promise.all(tickets.map(ticket => new Promise((resolve, reject) => {
QRCode.toDataURL(ticket.qrCode, (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 row = Math.floor(ticketIndex % ticketsPerColumn);
const col = Math.floor((ticketIndex / ticketsPerColumn) % ticketsPerRow);
const x = col * ticketWidth + 20; const y = row * ticketHeight + 20;
doc.rect(x, y, ticketWidth, ticketHeight).stroke();
doc.font('Helvetica-Bold').fontSize(12).text(ticket.event.title, x + 10, y + 10, { width: ticketWidth - 20 });
doc.font('Helvetica').fontSize(10);
const variantSuffix = ticket.registrationOption.variant ? ` — ${ticket.registrationOption.variant.name}` : '';
doc.text(`Ticket Type: ${ticket.registrationOption.eventOption.name}${variantSuffix}`, x + 10, y + 30, { width: ticketWidth - 20 });
const groupLabel = allocationLabel(ticket.attendee);
doc.text(ticket.attendee ? `Name: ${ticket.attendee.name}${groupLabel ? ` · ${groupLabel}` : ''}` : `Qty: ${ticket.quantity || 1}`, x + 10, y + 45, { width: ticketWidth - 20 });
const eventDate = new Date(ticket.event.startDate);
doc.text(`Date: ${new Intl.DateTimeFormat('en-GB', { day: '2-digit', month: 'long', year: 'numeric' }).format(eventDate)}`, x + 10, y + 60, { width: ticketWidth - 20 });
doc.text(`Purchased by: ${ticket.user?.name || user.name}`, x + 10, y + 75, { width: ticketWidth - 20 });
const qrCodeSize = Math.min(ticketWidth, ticketHeight) * 0.45;
const qrX = x + (ticketWidth - qrCodeSize) / 2; const qrY = y + 90;
doc.image(qrCodeMap[ticket.id], qrX, qrY, { width: qrCodeSize, height: qrCodeSize });
doc.fontSize(8).text(`Ticket ID: ${ticket.id}`, x + 10, qrY + qrCodeSize + 5, { width: ticketWidth - 20, align: 'center' });
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 = 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
};