- Waitlist per session: entries for pick-sessions tickets name a session; offers use that session's free places, never run past its start, and claim links tick it. Held offers count towards session capacity. - At the door: the Select items pop-up has the session picker, with today's session preselected for new attendees. - On pick-sessions events every ticket shows the session list straight away. "All sessions" tickets record the ticked sessions (RegistrationOption.sessionIds) at one price; scanning, attendance, certificates, reminders and capacity follow them. - Fix: per-session stock limits weren't enforced (takenCount filtered WaitlistEntry by a sessionId column that didn't exist, and the error was swallowed), and the public event page lost its stock info. - Fix: editing a door registration with picked sessions couldn't be saved. - Fix: the offline-scanning service worker served /_next/static cache-first forever, so dev browsers that had opened the scanner ran stale code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
410 lines
20 KiB
JavaScript
410 lines
20 KiB
JavaScript
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: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true, sessionIds: true, 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);
|
||
// A picked-session booking (one ticket per session) shows as one row.
|
||
const rows = sessionsUtil.groupTickets(tickets).map(g => {
|
||
const t = g.tickets[0];
|
||
const bySession = {};
|
||
for (const tk of g.tickets) {
|
||
for (const u of tk.usages) {
|
||
const key = multi ? u.sessionId : WHOLE_EVENT;
|
||
if (!key) continue;
|
||
bySession[key] = (bySession[key] || 0) + (u.quantityRedeemed || 1);
|
||
}
|
||
}
|
||
const booked = g.sessionIds ? g.sessionIds.filter(id => activeIds.has(id)) : null;
|
||
const counted = booked ? new Set(booked) : activeIds;
|
||
const attended = multi ? Object.keys(bySession).filter(k => counted.has(k)).length : (bySession[WHOLE_EVENT] ? 1 : 0);
|
||
const total = multi ? counted.size : 1;
|
||
const cert = t.certificates[0] || null;
|
||
return {
|
||
ticketId: t.id,
|
||
// Picked or ticked sessions: which sessions this person booked, and the ticket for each
|
||
// (an all-sessions ticket with sessions ticked is one ticket for all of them).
|
||
bookedSessions: g.sessionIds,
|
||
sessionTickets: g.sessionIds ? Object.fromEntries(g.tickets.flatMap(tk => (tk.registrationOption.sessionId ? [[tk.registrationOption.sessionId, tk.id]] : g.sessionIds.map(id => [id, tk.id])))) : null,
|
||
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 } }, registrationOption: { select: { sessionId: true, sessionIds: 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');
|
||
if (ticket.registrationOption && !require('../utils/sessions').lineCovers(ticket.registrationOption, session.id)) throw badRequest('That ticket isn\'t booked for this session');
|
||
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;
|
||
const notBooked = r.bookedSessions && !r.bookedSessions.includes(c.id);
|
||
if (qty && r.quantity <= 1) tick(x + (colW - 2) / 2, y + 4);
|
||
else doc.fillColor(qty ? '#15803d' : '#cbd5e1').text(qty ? String(qty) : notBooked ? '-' : '·', 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.bookedSessions && !r.bookedSessions.includes(c.id) ? 'not booked' : '')),
|
||
`${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,
|
||
};
|