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(`

🎟️ Your tickets are here!

Hi ${user.name},

Your tickets for ${eventTitle}${eventDate ? ` on ${eventDate}` : ''} are attached.

Questions? Visit ${orgUrl}.

`, { 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 };