Files
hope-events/backend/src/controllers/attendanceController.js
T
joshuaandClaude Opus 5.5 39558577f7 Add multi-day events (frontend), attendance register and certificates
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>
2026-10-22 14:53:39 +02:00

398 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const 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,
};