const ExcelJS = require('exceljs'); const PDFDocument = require('pdfkit'); const prisma = require('../config/db'); const { safeErrorMessage } = require('../utils/errorUtils'); const { logAdminAction } = require('../utils/adminAudit'); const { getClientIp } = require('../utils/requestUtils'); const sessionsUtil = require('../utils/sessions'); const certificates = require('../utils/certificates'); const SUPERVISORS = ['admin', 'supervisor']; const fail = (res, error) => res.status(error.statusCode || (res.statusCode === 200 ? 400 : res.statusCode)).json({ message: safeErrorMessage(error) }); const notFound = (msg) => Object.assign(new Error(msg), { statusCode: 404 }); const badRequest = (msg) => Object.assign(new Error(msg), { statusCode: 400 }); // Single-date events have no sessions; the register treats the event itself as one // column with this id (its check-ins have no sessionId). const WHOLE_EVENT = 'event'; /** * The attendance register for an event: columns (sessions), one row per main ticket with * check-ins per session, and certificate status. */ async function buildRegister(eventId) { const event = await prisma.event.findUnique({ where: { id: eventId }, include: { sessions: { orderBy: { startsAt: 'asc' } } }, }); if (!event) throw notFound('Event not found'); const multi = event.scheduleType === 'sessions'; const active = sessionsUtil.activeSessions(event.sessions); const columns = multi ? event.sessions.map(s => ({ id: s.id, label: s.cancelledAt ? (s.label || 'Session') : sessionsUtil.sessionLabel(s, event.sessions), startsAt: s.startsAt, endsAt: s.endsAt, cancelled: !!s.cancelledAt, })) : [{ id: WHOLE_EVENT, label: 'Checked in', startsAt: event.startDate, endsAt: event.endDate, cancelled: false }]; const tickets = await prisma.ticket.findMany({ where: { eventId, registrationOption: { eventOption: { isMainTicket: true }, registration: { status: { not: 'cancelled' } } } }, include: { attendee: { select: { name: true } }, holder: { select: { name: true } }, user: { select: { name: true, email: true, phoneNumber: true } }, usages: { select: { quantityRedeemed: true, sessionId: true, manual: true } }, registrationOption: { select: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } }, certificates: { select: { id: true, code: true, issuedAt: true, revokedAt: true, manual: true, emailedAt: true } }, }, orderBy: { createdAt: 'asc' }, }); const activeIds = new Set(active.map(s => s.id)); const config = sessionsUtil.normalizeCertificateConfig(event.certificateConfig); const rows = tickets.map(t => { const bySession = {}; for (const u of t.usages) { const key = multi ? u.sessionId : WHOLE_EVENT; if (!key) continue; bySession[key] = (bySession[key] || 0) + (u.quantityRedeemed || 1); } const attended = multi ? Object.keys(bySession).filter(k => activeIds.has(k)).length : (bySession[WHOLE_EVENT] ? 1 : 0); const total = multi ? active.length : 1; const cert = t.certificates[0] || null; return { ticketId: t.id, name: t.attendee?.name || t.holder?.name || t.user?.name || '', bookedBy: t.attendee || t.holder ? t.user?.name || '' : '', email: t.user?.email || '', phone: t.user?.phoneNumber || '', option: t.registrationOption?.eventOption?.name || '', variant: t.registrationOption?.variant?.name || null, quantity: t.quantity || 1, checkIns: bySession, attended, total, completed: sessionsUtil.completionMet(config.rule, attended, total), certificate: cert, }; }).sort((a, b) => a.name.localeCompare(b.name)); const totals = Object.fromEntries(columns.map(c => [c.id, rows.reduce((n, r) => n + (r.checkIns[c.id] ? 1 : 0), 0)])); return { event: { id: event.id, title: event.title, scheduleType: event.scheduleType, startDate: event.startDate, endDate: event.endDate, cashupStatus: event.cashupStatus, certificatesEnabled: event.certificatesEnabled, certificateConfig: config, }, columns, rows, totals, }; } // @desc Attendance register (sessions × attendees) // @route GET /api/attendance/:eventId // @access Private/Staff const getRegister = async (req, res) => { try { res.json(await buildRegister(req.params.eventId)); } catch (error) { fail(res, error); } }; // @desc Tick someone present or absent for a session (e.g. let in without a scan) // @route POST /api/attendance/:eventId/mark body: { ticketId, sessionId, present } // @access Private/Supervisor const markAttendance = async (req, res) => { try { const { eventId } = req.params; const { ticketId, present } = req.body || {}; const sessionKey = String(req.body?.sessionId || ''); const ticket = await prisma.ticket.findUnique({ where: { id: String(ticketId || '') }, include: { usages: true, event: { select: { scheduleType: true, cashupStatus: true } } } }); if (!ticket || ticket.eventId !== eventId) throw notFound('Ticket not found for this event'); if (ticket.event.cashupStatus === 'closed') throw badRequest('This event is closed — reopen it to change attendance.'); const multi = ticket.event.scheduleType === 'sessions'; let sessionId = null; if (multi) { const session = await prisma.eventSession.findUnique({ where: { id: sessionKey } }); if (!session || session.eventId !== eventId) throw notFound('Session not found for this event'); if (session.cancelledAt) throw badRequest('That session was cancelled'); sessionId = session.id; } else if (sessionKey !== WHOLE_EVENT) { throw badRequest('This event has no sessions'); } const forSession = ticket.usages.filter(u => (u.sessionId || null) === sessionId); if (present === true) { const redeemed = forSession.reduce((s, u) => s + (u.quantityRedeemed || 1), 0); const left = (ticket.quantity || 1) - redeemed; if (left > 0) { await prisma.ticketUsage.create({ data: { ticketId: ticket.id, scannedById: req.user.id, quantityRedeemed: left, sessionId, manual: true } }); if (!multi) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: true, updatedAt: new Date() } }); } } else { if (forSession.length > 0) { await prisma.ticketUsage.deleteMany({ where: { id: { in: forSession.map(u => u.id) } } }); if (!multi) await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: false, updatedAt: new Date() } }); } } logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: present === true ? 'attendance_marked' : 'attendance_unmarked', targetType: 'Ticket', targetId: ticket.id, metadata: { eventId, sessionId, removedScans: present === true ? 0 : forSession.length }, ip: getClientIp(req), }); res.json({ ok: true }); } catch (error) { fail(res, error); } }; function registerFileName(reg, ext) { return `Attendance - ${reg.event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80) + `.${ext}`; } function fmtColumnDate(d) { return new Date(d).toLocaleDateString('en-GB', { timeZone: sessionsUtil.TIMEZONE, day: 'numeric', month: 'short' }); } // @desc Download the attendance register as Excel or PDF // @route GET /api/attendance/:eventId/export?format=xlsx|pdf // @access Private/Staff const exportRegister = async (req, res) => { try { const reg = await buildRegister(req.params.eventId); const cols = reg.columns.filter(c => !c.cancelled); if (req.query.format === 'pdf') { const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 36 }); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${registerFileName(reg, 'pdf')}"`); doc.pipe(res); doc.font('Helvetica-Bold').fontSize(16).text(`Attendance — ${reg.event.title}`); doc.font('Helvetica').fontSize(9).fillColor('#64748b').text(`${reg.rows.length} attendee(s) · printed ${new Date().toLocaleString('en-GB', { timeZone: sessionsUtil.TIMEZONE })}`); doc.moveDown(0.6); const nameW = 170; const totalW = 50; const usable = doc.page.width - 72 - nameW - totalW; const colW = Math.max(28, Math.min(70, usable / Math.max(1, cols.length))); // Header height fits the longest session label. const headText = (c) => `${c.label}\n${fmtColumnDate(c.startsAt)}`; doc.font('Helvetica-Bold').fontSize(8); const headH = Math.max(12, ...cols.map(c => doc.heightOfString(headText(c), { width: colW - 2 }))); const header = () => { const y = doc.y; doc.font('Helvetica-Bold').fontSize(8).fillColor('#1e293b').text('Name', 36, y + headH - 10, { width: nameW }); cols.forEach((c, i) => doc.text(headText(c), 36 + nameW + i * colW, y, { width: colW - 2, align: 'center' })); doc.text('Total', 36 + nameW + cols.length * colW, y + headH - 10, { width: totalW, align: 'center' }); doc.y = y + headH + 6; doc.moveTo(36, doc.y - 3).lineTo(doc.page.width - 36, doc.y - 3).lineWidth(0.5).strokeColor('#cbd5e1').stroke(); doc.y += 2; }; // The built-in PDF fonts have no ✓, so the tick is drawn. const tick = (cx, cy) => { doc.save().lineWidth(1.4).strokeColor('#15803d') .moveTo(cx - 3.5, cy).lineTo(cx - 1, cy + 2.8).lineTo(cx + 4, cy - 3).stroke().restore(); }; header(); for (const r of reg.rows) { if (doc.y > doc.page.height - 50) { doc.addPage(); header(); } const y = doc.y; doc.font('Helvetica').fontSize(8.5).fillColor('#1e293b').text(r.name + (r.quantity > 1 ? ` (×${r.quantity})` : ''), 36, y, { width: nameW, ellipsis: true, lineBreak: false }); cols.forEach((c, i) => { const qty = r.checkIns[c.id] || 0; const x = 36 + nameW + i * colW; if (qty && r.quantity <= 1) tick(x + (colW - 2) / 2, y + 4); else doc.fillColor(qty ? '#15803d' : '#cbd5e1').text(qty ? String(qty) : '·', x, y, { width: colW - 2, align: 'center', lineBreak: false }); }); doc.fillColor('#1e293b').text(`${r.attended}/${r.total}`, 36 + nameW + cols.length * colW, y, { width: totalW, align: 'center', lineBreak: false }); doc.y = y + 14; } doc.moveDown(0.5); const y = doc.y; doc.font('Helvetica-Bold').fontSize(8.5).text('Present', 36, y, { width: nameW }); cols.forEach((c, i) => doc.text(String(reg.totals[c.id] || 0), 36 + nameW + i * colW, y, { width: colW - 2, align: 'center', lineBreak: false })); doc.end(); return; } const wb = new ExcelJS.Workbook(); const ws = wb.addWorksheet('Attendance'); ws.addRow(['Name', 'Booked by', 'Ticket', 'Email', 'Phone', 'Places', ...cols.map(c => `${c.label} (${fmtColumnDate(c.startsAt)})`), 'Attended', 'Completed', 'Certificate']); ws.getRow(1).font = { bold: true }; for (const r of reg.rows) { ws.addRow([ r.name, r.bookedBy, r.variant ? `${r.option} — ${r.variant}` : r.option, r.email, r.phone, r.quantity, ...cols.map(c => r.checkIns[c.id] ? (r.quantity > 1 ? r.checkIns[c.id] : '✓') : ''), `${r.attended}/${r.total}`, r.completed ? 'Yes' : 'No', r.certificate && !r.certificate.revokedAt ? r.certificate.code : '', ]); } ws.addRow([]); ws.addRow(['Present', '', '', '', '', '', ...cols.map(c => reg.totals[c.id] || 0)]).font = { bold: true }; ws.columns.forEach((col, i) => { col.width = i < 5 ? 22 : 14; }); res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); res.setHeader('Content-Disposition', `attachment; filename="${registerFileName(reg, 'xlsx')}"`); await wb.xlsx.write(res); res.end(); } catch (error) { fail(res, error); } }; // ─── Certificates (supervisor) ──────────────────────────────────────────────── async function assertCertificatesOn(eventId) { const event = await prisma.event.findUnique({ where: { id: eventId }, select: { id: true, title: true, endDate: true, certificatesEnabled: true, certificateConfig: true } }); if (!event) throw notFound('Event not found'); if (!event.certificatesEnabled) throw badRequest('Certificates are switched off for this event (Edit event → Attendance).'); return event; } // @desc Issue certificates now — to everyone eligible, or one ticket (even if not eligible) // @route POST /api/attendance/:eventId/certificates body: { ticketId? } // @access Private/Supervisor const issueCertificates = async (req, res) => { try { await assertCertificatesOn(req.params.eventId); const created = req.body?.ticketId ? [await certificates.issueManual(req.params.eventId, String(req.body.ticketId), req.user.id)] : await certificates.issueEligible(req.params.eventId, { issuedById: req.user.id }); for (const c of created) { logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'certificate_issued', targetType: 'Certificate', targetId: c.id, metadata: { eventId: req.params.eventId, ticketId: c.ticketId, manual: !!req.body?.ticketId }, ip: getClientIp(req) }); } res.json({ issued: created.length }); } catch (error) { fail(res, error); } }; // @desc Revoke a certificate (the automatic job won't issue it again) // @route POST /api/attendance/certificates/:id/revoke // @access Private/Supervisor const revokeCertificate = async (req, res) => { try { const cert = await prisma.certificate.findUnique({ where: { id: req.params.id } }); if (!cert) throw notFound('Certificate not found'); await prisma.certificate.update({ where: { id: cert.id }, data: { revokedAt: new Date() } }); logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'certificate_revoked', targetType: 'Certificate', targetId: cert.id, metadata: { eventId: cert.eventId, ticketId: cert.ticketId }, ip: getClientIp(req) }); res.json({ ok: true }); } catch (error) { fail(res, error); } }; // @desc Email/WhatsApp certificates that haven't been sent yet (or one, again) // @route POST /api/attendance/:eventId/certificates/send body: { certificateId? } // @access Private/Supervisor const sendCertificatesHandler = async (req, res) => { try { await assertCertificatesOn(req.params.eventId); const where = req.body?.certificateId ? { id: String(req.body.certificateId), eventId: req.params.eventId } : { eventId: req.params.eventId, emailedAt: null }; const ids = (await prisma.certificate.findMany({ where: { ...where, revokedAt: null }, select: { id: true } })).map(c => c.id); const sent = await certificates.sendCertificates(ids); res.json({ sent, skipped: ids.length - sent }); } catch (error) { fail(res, error); } }; // @desc All of an event's certificates in one PDF (one page each) // @route GET /api/attendance/:eventId/certificates.pdf // @access Private/Supervisor const downloadEventCertificates = async (req, res) => { try { const event = await assertCertificatesOn(req.params.eventId); const certs = await prisma.certificate.findMany({ where: { eventId: event.id, revokedAt: null }, orderBy: { name: 'asc' } }); if (certs.length === 0) throw badRequest('No certificates have been issued for this event yet.'); const pdf = await certificates.renderCertificatesPdf(certs, event); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${`Certificates - ${event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80)}.pdf"`); res.send(pdf); } catch (error) { fail(res, error); } }; // @desc Sample certificate with the editor's (unsaved) settings // @route POST /api/attendance/certificates/preview body: { title, config } // @access Private/Supervisor const previewCertificate = async (req, res) => { try { const event = { title: String(req.body?.title || 'Your event').slice(0, 200), endDate: new Date() }; const pdf = await certificates.renderCertificatesPdf([certificates.sampleCertificate()], event, req.body?.config || {}); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', 'inline; filename="certificate-preview.pdf"'); res.send(pdf); } catch (error) { fail(res, error); } }; // ─── Certificates (attendees, public) ───────────────────────────────────────── // @desc The logged-in user's certificates // @route GET /api/attendance/certificates/mine // @access Private const myCertificates = async (req, res) => { try { const certs = await prisma.certificate.findMany({ where: { revokedAt: null, OR: [{ userId: req.user.id }, { ticket: { userId: req.user.id } }] }, include: { event: { select: { id: true, title: true, endDate: true } } }, orderBy: { issuedAt: 'desc' }, }); res.json(certs.map(c => ({ id: c.id, code: c.code, name: c.name, issuedAt: c.issuedAt, sessionsAttended: c.sessionsAttended, totalSessions: c.totalSessions, event: c.event }))); } catch (error) { fail(res, error); } }; // @desc Download one certificate (its person, the booker, or a supervisor) // @route GET /api/attendance/certificates/:id/pdf // @access Private const downloadCertificate = async (req, res) => { try { const cert = await prisma.certificate.findUnique({ where: { id: req.params.id }, include: { ticket: { select: { userId: true } }, event: { select: { id: true, title: true, endDate: true, certificateConfig: true } } }, }); const allowed = cert && (SUPERVISORS.includes(req.user.role) || cert.userId === req.user.id || cert.ticket?.userId === req.user.id); if (!allowed || cert.revokedAt) throw notFound('Certificate not found'); const pdf = await certificates.renderCertificatesPdf([cert], cert.event); res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Disposition', `attachment; filename="${`Certificate - ${cert.event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80)}.pdf"`); res.send(pdf); } catch (error) { fail(res, error); } }; // @desc Check a certificate code (public verify page). Shows only name, event and date. // @route GET /api/attendance/certificates/verify/:code // @access Public const verifyCertificate = async (req, res) => { try { const code = String(req.params.code || '').trim().toUpperCase().slice(0, 20); const cert = await prisma.certificate.findUnique({ where: { code }, include: { event: { select: { title: true, endDate: true } } } }); if (!cert) return res.json({ valid: false }); res.json({ valid: !cert.revokedAt, revoked: !!cert.revokedAt, name: cert.name, event: cert.event.title, completedOn: cert.event.endDate, issuedAt: cert.issuedAt, }); } catch (error) { fail(res, error); } }; module.exports = { getRegister, markAttendance, exportRegister, issueCertificates, revokeCertificate, sendCertificatesHandler, downloadEventCertificates, previewCertificate, myCertificates, downloadCertificate, verifyCertificate, };