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>
1157 lines
48 KiB
JavaScript
1157 lines
48 KiB
JavaScript
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
|
||
}; |