- 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>
366 lines
19 KiB
JavaScript
366 lines
19 KiB
JavaScript
/**
|
|
* Completion certificates — optional per event (Event.certificatesEnabled).
|
|
*
|
|
* Rules:
|
|
* - One certificate per main ticket (one person). Add-on tickets never get one.
|
|
* - Eligible when the ticket's attendance meets the event's completion rule
|
|
* (multi-day: sessions attended out of sessions held; single date: checked in).
|
|
* - Issued automatically once the event has ended, or by a supervisor at any time
|
|
* ("Issue now" for everyone eligible, or one person manually even if not eligible).
|
|
* - A revoked certificate stays revoked: the automatic job never re-issues it.
|
|
* - Each certificate has a short code that the public verify page checks.
|
|
*/
|
|
|
|
const crypto = require('crypto');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const PDFDocument = require('pdfkit');
|
|
const QRCode = require('qrcode');
|
|
const prisma = require('../config/db');
|
|
const { activeSessions, completionMet, normalizeCertificateConfig, attendanceBySession, groupTickets, TIMEZONE } = require('./sessions');
|
|
|
|
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
const AUTO_ISSUE_DAYS = 14;
|
|
|
|
/** A random code like "K7M2Q-9XRTB" (no 0/O/1/I to avoid misreading). */
|
|
function newCode() {
|
|
const bytes = crypto.randomBytes(10);
|
|
const chars = Array.from(bytes, b => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
|
|
return `${chars.slice(0, 5)}-${chars.slice(5)}`;
|
|
}
|
|
|
|
/** Fills {name}, {event}, {sessionsAttended}, {totalSessions}, {endDate} in certificate text. */
|
|
function fillPlaceholders(text, values) {
|
|
return String(text || '').replace(/\{(name|event|sessionsAttended|totalSessions|endDate)\}/g, (_, k) => String(values[k] ?? ''));
|
|
}
|
|
|
|
function fmtLongDate(d) {
|
|
return new Date(d).toLocaleDateString('en-GB', { timeZone: TIMEZONE, day: 'numeric', month: 'long', year: 'numeric' });
|
|
}
|
|
|
|
function ticketName(t) {
|
|
return t.attendee?.name || t.holder?.name || t.user?.name || 'Attendee';
|
|
}
|
|
|
|
/**
|
|
* Attendance per main ticket of an event: [{ ticket, attended, total }]. Multi-day events
|
|
* count sessions attended (cancelled sessions left out); single-date events count 1 when
|
|
* checked in.
|
|
*/
|
|
async function attendanceForCertificates(eventId) {
|
|
const event = await prisma.event.findUnique({ where: { id: eventId }, include: { sessions: true } });
|
|
if (!event) return { event: null, rows: [] };
|
|
const tickets = await prisma.ticket.findMany({
|
|
where: { eventId, registrationOption: { eventOption: { isMainTicket: true }, registration: { status: { not: 'cancelled' } } } },
|
|
include: {
|
|
attendee: { select: { name: true } },
|
|
holder: { select: { id: true, name: true } },
|
|
user: { select: { id: true, name: true } },
|
|
usages: { select: { quantityRedeemed: true, sessionId: true } },
|
|
registrationOption: { select: { registrationId: true, eventOptionId: true, variantId: true, sessionId: true, sessionIds: true } },
|
|
},
|
|
orderBy: { createdAt: 'asc' },
|
|
});
|
|
if (event.scheduleType === 'sessions') {
|
|
const active = activeSessions(event.sessions);
|
|
const activeIds = new Set(active.map(s => s.id));
|
|
const bySession = await attendanceBySession(eventId);
|
|
// A picked-session booking (one ticket per session) is one person: its first ticket
|
|
// carries the certificate, and it's measured against the sessions it booked.
|
|
return {
|
|
event,
|
|
rows: groupTickets(tickets).map(g => {
|
|
const booked = g.sessionIds ? new Set(g.sessionIds.filter(id => activeIds.has(id))) : activeIds;
|
|
const attendedIds = new Set();
|
|
for (const t of g.tickets) {
|
|
for (const [sid, qty] of (bySession.get(t.id) || new Map())) if (booked.has(sid) && qty > 0) attendedIds.add(sid);
|
|
}
|
|
return { ticket: g.tickets[0], attended: attendedIds.size, total: booked.size };
|
|
}),
|
|
};
|
|
}
|
|
return { event, rows: tickets.map(t => ({ ticket: t, attended: t.usages.length > 0 ? 1 : 0, total: 1 })) };
|
|
}
|
|
|
|
/**
|
|
* Issues certificates for everyone who meets the completion rule and doesn't have one yet
|
|
* (revoked ones are left alone). Returns the new certificates.
|
|
*/
|
|
async function issueEligible(eventId, { issuedById = null } = {}) {
|
|
const { event, rows } = await attendanceForCertificates(eventId);
|
|
if (!event || !event.certificatesEnabled) return [];
|
|
const config = normalizeCertificateConfig(event.certificateConfig);
|
|
const existing = new Set((await prisma.certificate.findMany({ where: { eventId }, select: { ticketId: true } })).map(c => c.ticketId));
|
|
const created = [];
|
|
for (const r of rows) {
|
|
if (existing.has(r.ticket.id) || !completionMet(config.rule, r.attended, r.total)) continue;
|
|
try {
|
|
created.push(await prisma.certificate.create({
|
|
data: {
|
|
eventId, ticketId: r.ticket.id, userId: r.ticket.holderUserId || r.ticket.userId,
|
|
name: ticketName(r.ticket), code: newCode(), sessionsAttended: r.attended, totalSessions: r.total,
|
|
issuedById,
|
|
},
|
|
}));
|
|
} catch (e) {
|
|
if (e?.code !== 'P2002') throw e; // issued at the same moment by the job — fine
|
|
}
|
|
}
|
|
return created;
|
|
}
|
|
|
|
/** Issues (or re-issues a revoked) certificate for one ticket, eligible or not. */
|
|
async function issueManual(eventId, ticketId, issuedById) {
|
|
const { event, rows } = await attendanceForCertificates(eventId);
|
|
const r = rows.find(x => x.ticket.id === ticketId);
|
|
if (!event || !r) {
|
|
const err = new Error('Ticket not found for this event');
|
|
err.statusCode = 404;
|
|
throw err;
|
|
}
|
|
const data = { name: ticketName(r.ticket), sessionsAttended: r.attended, totalSessions: r.total, manual: true, issuedById, revokedAt: null, issuedAt: new Date() };
|
|
return prisma.certificate.upsert({
|
|
where: { eventId_ticketId: { eventId, ticketId } },
|
|
create: { eventId, ticketId, userId: r.ticket.holderUserId || r.ticket.userId, code: newCode(), ...data },
|
|
update: data,
|
|
});
|
|
}
|
|
|
|
// ─── PDF ──────────────────────────────────────────────────────────────────────
|
|
|
|
const PAGE = { size: 'A4', layout: 'landscape', margin: 0 };
|
|
|
|
function hexToRgb(hex) {
|
|
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
|
|
if (!m) return [30, 58, 95];
|
|
const n = parseInt(m[1], 16);
|
|
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
|
|
}
|
|
|
|
function lighten(hex, amount) {
|
|
const [r, g, b] = hexToRgb(hex);
|
|
const mix = (c) => Math.round(c + (255 - c) * amount);
|
|
return `#${[mix(r), mix(g), mix(b)].map(c => c.toString(16).padStart(2, '0')).join('')}`;
|
|
}
|
|
|
|
function uploadPath(url) {
|
|
if (!url || /^https?:\/\//i.test(url)) return null;
|
|
const p = path.join(__dirname, '..', '..', 'public', String(url).replace(/^\//, ''));
|
|
return fs.existsSync(p) ? p : null;
|
|
}
|
|
|
|
function safeImage(doc, file, x, y, opts) {
|
|
if (!file) return false;
|
|
try { doc.image(file, x, y, opts); return true; } catch { return false; }
|
|
}
|
|
|
|
// Shared bottom block: signature (left), date (middle), verification QR + code (right).
|
|
function drawFooter(doc, { cert, config, branding, qr, color, font, bold, y }) {
|
|
const W = doc.page.width;
|
|
const colW = 200;
|
|
const sigX = 90;
|
|
const sigImg = uploadPath(config.signatureImage);
|
|
if (sigImg) safeImage(doc, sigImg, sigX + 20, y - 46, { fit: [160, 42] });
|
|
doc.moveTo(sigX, y).lineTo(sigX + colW, y).lineWidth(0.8).strokeColor('#94a3b8').stroke();
|
|
doc.font(bold).fontSize(11).fillColor('#1e293b').text(config.signatureName || branding.name, sigX, y + 6, { width: colW, align: 'center' });
|
|
if (config.signatureRole) doc.font(font).fontSize(9).fillColor('#64748b').text(config.signatureRole, sigX, y + 21, { width: colW, align: 'center' });
|
|
|
|
const dateX = W / 2 - colW / 2;
|
|
doc.moveTo(dateX, y).lineTo(dateX + colW, y).lineWidth(0.8).strokeColor('#94a3b8').stroke();
|
|
doc.font(bold).fontSize(11).fillColor('#1e293b').text(fmtLongDate(cert.issuedAt), dateX, y + 6, { width: colW, align: 'center' });
|
|
doc.font(font).fontSize(9).fillColor('#64748b').text('Date', dateX, y + 21, { width: colW, align: 'center' });
|
|
|
|
const qrSize = 64;
|
|
const qrX = W - 90 - qrSize;
|
|
safeImage(doc, qr, qrX, y - 50, { width: qrSize });
|
|
doc.font(font).fontSize(7.5).fillColor('#64748b').text(`Verify: ${cert.code}`, qrX - 40, y + 18, { width: qrSize + 80, align: 'center' });
|
|
doc.fillColor(color);
|
|
}
|
|
|
|
function drawTemplate(doc, template, ctx) {
|
|
const { cert, config, branding, event, title, body } = ctx;
|
|
const W = doc.page.width;
|
|
const H = doc.page.height;
|
|
const color = branding.brandColor;
|
|
const logo = branding.logoPath;
|
|
|
|
if (template === 'modern') {
|
|
doc.rect(0, 0, W, H).fill('#ffffff');
|
|
doc.rect(0, 0, 28, H).fill(color);
|
|
doc.rect(28, 0, 6, H).fill(lighten(color, 0.6));
|
|
const x = 90;
|
|
safeImage(doc, logo, x, 70, { fit: [120, 50] });
|
|
doc.font('Helvetica').fontSize(11).fillColor(color).text(branding.name.toUpperCase(), x, 150, { characterSpacing: 2 });
|
|
doc.font('Helvetica-Bold').fontSize(36).fillColor('#0f172a').text(title, x, 175, { width: W - x - 80 });
|
|
doc.font('Helvetica').fontSize(13).fillColor('#64748b').text('Presented to', x, 260);
|
|
doc.font('Helvetica-Bold').fontSize(32).fillColor(color).text(cert.name, x, 280, { width: W - x - 80 });
|
|
doc.font('Helvetica').fontSize(14).fillColor('#334155').text(body, x, 340, { width: W - x - 140, lineGap: 5 });
|
|
drawFooter(doc, { ...ctx, color, font: 'Helvetica', bold: 'Helvetica-Bold', y: H - 80 });
|
|
return;
|
|
}
|
|
|
|
if (template === 'church') {
|
|
doc.rect(0, 0, W, H).fill(lighten(color, 0.94));
|
|
doc.rect(30, 30, W - 60, H - 60).lineWidth(1).strokeColor(lighten(color, 0.4)).stroke();
|
|
const hasLogo = safeImage(doc, logo, W / 2 - 50, 55, { fit: [100, 70], align: 'center' });
|
|
const top = hasLogo ? 150 : 115;
|
|
doc.font('Times-Italic').fontSize(14).fillColor(color).text(branding.name, 0, top, { width: W, align: 'center' });
|
|
doc.font('Times-Bold').fontSize(34).fillColor('#1e293b').text(title, 60, top + 26, { width: W - 120, align: 'center' });
|
|
doc.moveTo(W / 2 - 60, top + 74).lineTo(W / 2 + 60, top + 74).lineWidth(1.5).strokeColor(color).stroke();
|
|
doc.font('Times-Italic').fontSize(14).fillColor('#475569').text('This is to certify that', 0, top + 88, { width: W, align: 'center' });
|
|
doc.font('Times-Bold').fontSize(30).fillColor(color).text(cert.name, 60, top + 110, { width: W - 120, align: 'center' });
|
|
doc.font('Times-Roman').fontSize(14).fillColor('#334155').text(body, 120, top + 162, { width: W - 240, align: 'center', lineGap: 5 });
|
|
drawFooter(doc, { ...ctx, color, font: 'Times-Roman', bold: 'Times-Bold', y: H - 85 });
|
|
return;
|
|
}
|
|
|
|
if (template === 'kids') {
|
|
const bright = ['#f59e0b', '#ec4899', '#22c55e', '#3b82f6', '#a855f7'];
|
|
doc.rect(0, 0, W, H).fill('#fffdf5');
|
|
doc.roundedRect(22, 22, W - 44, H - 44, 28).lineWidth(10).strokeColor(color).stroke();
|
|
// Confetti dots around the edge.
|
|
let seed = parseInt(cert.code.replace(/[^A-Z0-9]/g, '').slice(0, 6), 36) || 7;
|
|
const rnd = () => { seed = (seed * 9301 + 49297) % 233280; return seed / 233280; };
|
|
// Confetti in the top band and down the sides — never over the text or the footer.
|
|
for (let i = 0; i < 46; i++) {
|
|
const top = rnd() < 0.45;
|
|
const x = top ? 50 + rnd() * (W - 100) : (rnd() < 0.5 ? 48 + rnd() * 45 : W - 93 + rnd() * 45);
|
|
const y = top ? 48 + rnd() * 50 : 110 + rnd() * (H - 260);
|
|
doc.circle(x, y, 3 + rnd() * 5).fill(bright[i % bright.length]);
|
|
}
|
|
safeImage(doc, logo, W / 2 - 40, 100, { fit: [80, 50], align: 'center' });
|
|
doc.font('Helvetica-Bold').fontSize(38).fillColor(color).text(title, 100, 165, { width: W - 200, align: 'center' });
|
|
doc.font('Helvetica-Bold').fontSize(15).fillColor('#f59e0b').text('Well done,', 0, 230, { width: W, align: 'center' });
|
|
doc.font('Helvetica-Bold').fontSize(36).fillColor('#1e293b').text(cert.name, 100, 250, { width: W - 200, align: 'center' });
|
|
doc.font('Helvetica').fontSize(15).fillColor('#334155').text(body, 130, 310, { width: W - 260, align: 'center', lineGap: 5 });
|
|
drawFooter(doc, { ...ctx, color, font: 'Helvetica', bold: 'Helvetica-Bold', y: H - 90 });
|
|
return;
|
|
}
|
|
|
|
// classic
|
|
doc.rect(0, 0, W, H).fill('#fffefa');
|
|
doc.rect(24, 24, W - 48, H - 48).lineWidth(3).strokeColor(color).stroke();
|
|
doc.rect(34, 34, W - 68, H - 68).lineWidth(0.8).strokeColor(color).stroke();
|
|
for (const [cx, cy] of [[34, 34], [W - 34, 34], [34, H - 34], [W - 34, H - 34]]) {
|
|
doc.circle(cx, cy, 6).fill(color);
|
|
}
|
|
safeImage(doc, logo, W / 2 - 45, 70, { fit: [90, 55], align: 'center' });
|
|
doc.font('Times-Bold').fontSize(38).fillColor('#1e293b').text(title.toUpperCase(), 60, 150, { width: W - 120, align: 'center', characterSpacing: 1.5 });
|
|
doc.font('Times-Italic').fontSize(14).fillColor('#64748b').text('This certificate is proudly presented to', 0, 212, { width: W, align: 'center' });
|
|
doc.font('Times-BoldItalic').fontSize(34).fillColor(color).text(cert.name, 60, 236, { width: W - 120, align: 'center' });
|
|
doc.moveTo(W / 2 - 170, 284).lineTo(W / 2 + 170, 284).lineWidth(0.8).strokeColor('#cbd5e1').stroke();
|
|
doc.font('Times-Roman').fontSize(14).fillColor('#334155').text(body, 120, 300, { width: W - 240, align: 'center', lineGap: 5 });
|
|
doc.font('Times-Italic').fontSize(11).fillColor('#64748b').text(branding.name, 0, H - 132, { width: W, align: 'center' });
|
|
drawFooter(doc, { ...ctx, color, font: 'Times-Roman', bold: 'Times-Bold', y: H - 85 });
|
|
void event;
|
|
}
|
|
|
|
/**
|
|
* Renders certificates (one page each) to a PDF Buffer.
|
|
* @param {object[]} certs - Certificate rows ({ name, code, issuedAt, sessionsAttended, totalSessions })
|
|
* @param {object} event - { title, endDate, certificateConfig }
|
|
* @param {object} [configOverride] - certificate settings to use instead (editor preview)
|
|
*/
|
|
async function renderCertificatesPdf(certs, event, configOverride = null) {
|
|
const { getBranding } = require('./pdfDocs');
|
|
const branding = await getBranding();
|
|
const config = normalizeCertificateConfig(configOverride || event.certificateConfig);
|
|
const doc = new PDFDocument({ ...PAGE, autoFirstPage: false, info: { Title: `${config.title} — ${event.title}` } });
|
|
const chunks = [];
|
|
doc.on('data', c => chunks.push(c));
|
|
const done = new Promise((resolve, reject) => { doc.on('end', resolve); doc.on('error', reject); });
|
|
|
|
for (const cert of certs) {
|
|
doc.addPage(PAGE);
|
|
const values = { name: cert.name, event: event.title, sessionsAttended: cert.sessionsAttended, totalSessions: cert.totalSessions, endDate: fmtLongDate(event.endDate) };
|
|
const qrData = await QRCode.toDataURL(`${branding.url}/certificates/verify/${encodeURIComponent(cert.code)}`, { margin: 0, width: 200 });
|
|
const qr = Buffer.from(qrData.split(',')[1], 'base64');
|
|
drawTemplate(doc, config.template, {
|
|
cert, config, branding, event, qr,
|
|
title: fillPlaceholders(config.title, values),
|
|
body: fillPlaceholders(config.body, values),
|
|
});
|
|
}
|
|
doc.end();
|
|
await done;
|
|
return Buffer.concat(chunks);
|
|
}
|
|
|
|
/** A sample certificate for the event editor's preview button. */
|
|
function sampleCertificate() {
|
|
return { name: 'Thandi Mokoena', code: 'SAMPL-E2026', issuedAt: new Date(), sessionsAttended: 5, totalSessions: 5 };
|
|
}
|
|
|
|
// ─── Sending ──────────────────────────────────────────────────────────────────
|
|
|
|
/** Emails (and WhatsApps, per preference) each certificate to its person. Returns how many went out. */
|
|
async function sendCertificates(certIds) {
|
|
const { sendMail, emailWrapper, ctaButton, getOrg } = require('./email');
|
|
const { shouldEmail, waPdf, waPdfAny } = require('./notify');
|
|
const certs = await prisma.certificate.findMany({
|
|
where: { id: { in: certIds }, revokedAt: null },
|
|
include: {
|
|
event: { select: { id: true, title: true, endDate: true, certificateConfig: true } },
|
|
user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true } },
|
|
},
|
|
});
|
|
const org = getOrg();
|
|
let sent = 0;
|
|
for (const cert of certs) {
|
|
const user = cert.user;
|
|
if (!user) continue;
|
|
try {
|
|
const pdf = await renderCertificatesPdf([cert], cert.event);
|
|
const filename = `Certificate - ${cert.event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80) + '.pdf';
|
|
const hasEmail = user.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid');
|
|
const jobs = [];
|
|
if (hasEmail && shouldEmail(user)) {
|
|
const html = emailWrapper(`
|
|
<p>Hi ${user.name?.split(' ')[0] || 'there'},</p>
|
|
<p>Congratulations on completing <strong>${cert.event.title}</strong>! Your certificate for <strong>${cert.name}</strong> is attached.</p>
|
|
${ctaButton('View my events', `${org.url}/dashboard/user`)}
|
|
<p style="font-size:13px;color:#64748b">Anyone can check it at ${org.url}/certificates/verify/${cert.code}</p>`, { preheader: `Your certificate for ${cert.event.title}` });
|
|
jobs.push(sendMail({ to: user.email, subject: `Your certificate — ${cert.event.title}`, html, text: `Congratulations on completing ${cert.event.title}! Your certificate is attached.`, attachments: [{ filename, content: pdf, contentType: 'application/pdf' }] }));
|
|
}
|
|
const { tempDir } = require('./pdfDocs');
|
|
const tmp = path.join(tempDir(), `certificate-${cert.id}.pdf`);
|
|
fs.writeFileSync(tmp, pdf);
|
|
const caption = `🎓 Your certificate for ${cert.event.title}`;
|
|
jobs.push(hasEmail ? waPdf(user, tmp, filename, caption) : waPdfAny(user, tmp, filename, caption));
|
|
await Promise.all(jobs);
|
|
await prisma.certificate.update({ where: { id: cert.id }, data: { emailedAt: new Date() } });
|
|
sent += 1;
|
|
} catch (e) {
|
|
console.warn('[certificates] Send failed:', e?.message || e);
|
|
}
|
|
}
|
|
return sent;
|
|
}
|
|
|
|
/**
|
|
* Background job: for events with certificates on that ended within the last 14 days,
|
|
* issue certificates to everyone eligible, and send them when the event says so.
|
|
*/
|
|
async function runCertificateJob(now = new Date()) {
|
|
const since = new Date(now.getTime() - AUTO_ISSUE_DAYS * 24 * 60 * 60 * 1000);
|
|
const events = await prisma.event.findMany({
|
|
where: { certificatesEnabled: true, endDate: { lte: now, gte: since } },
|
|
select: { id: true, certificateConfig: true },
|
|
});
|
|
let issued = 0;
|
|
let sent = 0;
|
|
for (const ev of events) {
|
|
const created = await issueEligible(ev.id);
|
|
issued += created.length;
|
|
if (created.length && normalizeCertificateConfig(ev.certificateConfig).sendOnIssue) {
|
|
sent += await sendCertificates(created.map(c => c.id));
|
|
}
|
|
}
|
|
return { issued, sent };
|
|
}
|
|
|
|
module.exports = {
|
|
newCode, fillPlaceholders, attendanceForCertificates, issueEligible, issueManual,
|
|
renderCertificatesPdf, sampleCertificate, sendCertificates, runCertificateJob,
|
|
};
|