Files
hope-events/backend/src/controllers/ticketController.js
T
joshuaandClaude Sonnet 5 c07e9c928a Add door check-in flow for Main Tickets
Lets staff redeem a registration's Main Tickets by quantity at the door
(via the Payment/registration flow) instead of scanning each QR code,
and automatically emails/WhatsApps a check-in confirmation to the
attendee. Also routes the At The Door "Open" button and post-payment
flow dynamically: paid registrations jump straight to Check-In instead
of a forced ticket print, since tickets are already sent automatically.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 14:56:31 +02:00

804 lines
29 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 {
const tickets = await prisma.ticket.findMany({
where: {
userId: req.user.id,
registrationOption: { registration: { status: { not: 'cancelled' } } }
},
include: {
registrationOption: {
include: {
eventOption: true,
variant: true,
registration: true
}
},
event: 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",
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 "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
`;
if (!rows || rows.length === 0) {
res.status(404);
throw new Error('Ticket not found');
}
const r = rows[0];
res.json({
id: r.id,
quantity: Number(r.quantity),
isUsed: r.isUsed,
eventId: r.eventId,
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 },
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) {
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 });
}
// 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
});
}
// 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 } },
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) });
}
};
// 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.' });
}
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: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
orderBy: { createdAt: 'asc' }
});
} else {
tickets = await prisma.ticket.findMany({
where: { id: { in: ticketIds }, userId },
include: { event: true, registrationOption: { include: { eventOption: true, variant: true } }, usages: true },
orderBy: { createdAt: 'asc' }
});
}
// Dedup by registrationOptionId
{
const seen = new Map(); const deduped = [];
for (const t of tickets) {
const key = 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 });
doc.text(`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: ${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
};