diff --git a/backend/prisma/migrations/20261022120410_add_event_sessions/migration.sql b/backend/prisma/migrations/20261022120410_add_event_sessions/migration.sql new file mode 100644 index 0000000..f304d70 --- /dev/null +++ b/backend/prisma/migrations/20261022120410_add_event_sessions/migration.sql @@ -0,0 +1,132 @@ +-- CreateEnum +CREATE TYPE "ScheduleType" AS ENUM ('single', 'sessions'); + +-- CreateEnum +CREATE TYPE "AttendanceMode" AS ENUM ('whole', 'pick'); + +-- CreateEnum +CREATE TYPE "LatePricing" AS ENUM ('full', 'prorata', 'fixed'); + +-- CreateEnum +CREATE TYPE "CheckInWindow" AS ENUM ('whole_day', 'session_only'); + +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "AdminAuditAction" ADD VALUE 'event_sessions_changed'; +ALTER TYPE "AdminAuditAction" ADD VALUE 'attendance_marked'; +ALTER TYPE "AdminAuditAction" ADD VALUE 'attendance_unmarked'; +ALTER TYPE "AdminAuditAction" ADD VALUE 'certificate_issued'; +ALTER TYPE "AdminAuditAction" ADD VALUE 'certificate_revoked'; + +-- AlterTable +ALTER TABLE "Event" ADD COLUMN "attendanceMode" "AttendanceMode" NOT NULL DEFAULT 'whole', +ADD COLUMN "certificateConfig" JSONB, +ADD COLUMN "certificatesEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "checkInWindow" "CheckInWindow" NOT NULL DEFAULT 'whole_day', +ADD COLUMN "lateFixedPrice" DOUBLE PRECISION, +ADD COLUMN "lateJoinEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "lateJoinUntilSessionId" TEXT, +ADD COLUMN "latePricing" "LatePricing" NOT NULL DEFAULT 'full', +ADD COLUMN "scheduleType" "ScheduleType" NOT NULL DEFAULT 'single', +ADD COLUMN "sessionReminders" JSONB; + +-- AlterTable +ALTER TABLE "TicketUsage" ADD COLUMN "manual" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "sessionId" TEXT; + +-- CreateTable +CREATE TABLE "EventSession" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "label" TEXT, + "startsAt" TIMESTAMP(3) NOT NULL, + "endsAt" TIMESTAMP(3) NOT NULL, + "location" TEXT, + "cancelledAt" TIMESTAMP(3), + "order" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EventSession_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SessionReminderLog" ( + "id" TEXT NOT NULL, + "sessionId" TEXT NOT NULL, + "offset" INTEGER NOT NULL, + "sent" INTEGER NOT NULL DEFAULT 0, + "sentAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SessionReminderLog_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Certificate" ( + "id" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "ticketId" TEXT NOT NULL, + "userId" TEXT, + "name" TEXT NOT NULL, + "code" TEXT NOT NULL, + "sessionsAttended" INTEGER NOT NULL DEFAULT 0, + "totalSessions" INTEGER NOT NULL DEFAULT 0, + "manual" BOOLEAN NOT NULL DEFAULT false, + "issuedById" TEXT, + "issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "emailedAt" TIMESTAMP(3), + "revokedAt" TIMESTAMP(3), + + CONSTRAINT "Certificate_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "EventSession_eventId_startsAt_idx" ON "EventSession"("eventId", "startsAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "SessionReminderLog_sessionId_offset_key" ON "SessionReminderLog"("sessionId", "offset"); + +-- CreateIndex +CREATE UNIQUE INDEX "Certificate_code_key" ON "Certificate"("code"); + +-- CreateIndex +CREATE INDEX "Certificate_userId_idx" ON "Certificate"("userId"); + +-- CreateIndex +CREATE INDEX "Certificate_ticketId_idx" ON "Certificate"("ticketId"); + +-- CreateIndex +CREATE INDEX "Certificate_issuedById_idx" ON "Certificate"("issuedById"); + +-- CreateIndex +CREATE UNIQUE INDEX "Certificate_eventId_ticketId_key" ON "Certificate"("eventId", "ticketId"); + +-- CreateIndex +CREATE INDEX "TicketUsage_sessionId_idx" ON "TicketUsage"("sessionId"); + +-- AddForeignKey +ALTER TABLE "TicketUsage" ADD CONSTRAINT "TicketUsage_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventSession" ADD CONSTRAINT "EventSession_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SessionReminderLog" ADD CONSTRAINT "SessionReminderLog_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "EventSession"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "Ticket"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Certificate" ADD CONSTRAINT "Certificate_issuedById_fkey" FOREIGN KEY ("issuedById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/migration_lock.toml b/backend/prisma/migrations/migration_lock.toml index fbffa92..044d57c 100644 --- a/backend/prisma/migrations/migration_lock.toml +++ b/backend/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) -provider = "postgresql" \ No newline at end of file +# It should be added in your version-control system (e.g., Git) +provider = "postgresql" diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 03a7cbf..ba8ded6 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -119,6 +119,8 @@ model User { twoFactorChallenges TwoFactorChallenge[] trustedDevices TrustedDevice[] + certificates Certificate[] + certificatesIssued Certificate[] @relation("CertificateIssuedBy") } model Event { @@ -199,6 +201,28 @@ model Event { scanLocks ScanLock[] bundles EventBundle[] + // Multi-day events: `sessions` is one event running on several dates (e.g. every + // Wednesday for 5 weeks). startDate/endDate are kept in sync with the first session's + // start and the last session's end (see utils/sessions.js), so code that only reads + // them keeps working. + scheduleType ScheduleType @default(single) + attendanceMode AttendanceMode @default(whole) + // Late joining: registration stays open until session `lateJoinUntilSessionId` starts + // (otherwise it closes when the first session starts). Late pricing applies to main + // tickets only. + lateJoinEnabled Boolean @default(false) + lateJoinUntilSessionId String? + latePricing LatePricing @default(full) + lateFixedPrice Float? + checkInWindow CheckInWindow @default(whole_day) + // null = off; { offsets: [minutes before start], channels: ["email", "whatsapp"] } + sessionReminders Json? + certificatesEnabled Boolean @default(false) + // { template, title, body, signatureName, signatureRole, signatureImage, rule: { type, value } } + certificateConfig Json? + sessions EventSession[] + certificates Certificate[] + @@index([createdById]) @@index([closedById]) @@index([reopenedById]) @@ -398,6 +422,7 @@ model Ticket { usages TicketUsage[] transfers TicketTransfer[] transferRequests TicketTransferRequest[] + certificates Certificate[] @@index([holderUserId]) @@index([eventId]) @@ -418,6 +443,11 @@ model TicketUsage { offline Boolean @default(false) scanLockId String? syncedAt DateTime? + // Multi-day events: the session this check-in counts for (null on single-date events). + sessionId String? + session EventSession? @relation(fields: [sessionId], references: [id], onDelete: Restrict) + // Ticked present by a supervisor from the attendance register, not scanned. + manual Boolean @default(false) ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Restrict) scannedBy User @relation(fields: [scannedById], references: [id], onDelete: Restrict) scanLock ScanLock? @relation(fields: [scanLockId], references: [id], onDelete: SetNull) @@ -425,6 +455,7 @@ model TicketUsage { @@index([ticketId]) @@index([scannedAt]) @@index([scannedById, scannedAt]) + @@index([sessionId]) } model PasswordReset { @@ -518,6 +549,11 @@ enum AdminAuditAction { yoco_config_updated yoco_webhook_registered yoco_webhook_deleted + event_sessions_changed + attendance_marked + attendance_unmarked + certificate_issued + certificate_revoked } // Append-only audit trail for admin/supervisor-initiated actions with money or @@ -1391,3 +1427,85 @@ model OfflineSyncReport { @@index([scanLockId]) @@index([reviewedById]) } + +enum ScheduleType { + single + sessions +} + +enum AttendanceMode { + whole + pick +} + +enum LatePricing { + full + prorata + fixed +} + +enum CheckInWindow { + whole_day + session_only +} + +// One date/time of a multi-day event. +model EventSession { + id String @id @default(uuid()) + eventId String + label String? + startsAt DateTime + endsAt DateTime + location String? + // Cancelled sessions are kept (they may have check-ins) but hidden from the schedule + // and left out of attendance totals. + cancelledAt DateTime? + order Int @default(0) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + usages TicketUsage[] + reminders SessionReminderLog[] + + @@index([eventId, startsAt]) +} + +// Which session reminders have gone out, so the job sends each one once. +model SessionReminderLog { + id String @id @default(uuid()) + sessionId String + // Minutes before the session start this reminder is for. + offset Int + sent Int @default(0) + sentAt DateTime @default(now()) + session EventSession @relation(fields: [sessionId], references: [id], onDelete: Cascade) + + @@unique([sessionId, offset]) +} + +// A completion certificate for one ticket (one person). +model Certificate { + id String @id @default(uuid()) + eventId String + ticketId String + userId String? + name String + // Short code printed on the certificate and checked on the public verify page. + code String @unique + sessionsAttended Int @default(0) + totalSessions Int @default(0) + manual Boolean @default(false) + issuedById String? + issuedAt DateTime @default(now()) + emailedAt DateTime? + revokedAt DateTime? + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + ticket Ticket @relation(fields: [ticketId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + issuedBy User? @relation("CertificateIssuedBy", fields: [issuedById], references: [id], onDelete: SetNull) + + @@unique([eventId, ticketId]) + @@index([userId]) + @@index([ticketId]) + @@index([issuedById]) +} diff --git a/backend/src/controllers/attendanceController.js b/backend/src/controllers/attendanceController.js new file mode 100644 index 0000000..111a0bf --- /dev/null +++ b/backend/src/controllers/attendanceController.js @@ -0,0 +1,385 @@ +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))); + const header = () => { + const y = doc.y; + doc.font('Helvetica-Bold').fontSize(8).fillColor('#1e293b').text('Name', 36, y, { width: nameW }); + cols.forEach((c, i) => doc.text(`${c.label}\n${fmtColumnDate(c.startsAt)}`, 36 + nameW + i * colW, y, { width: colW - 2, align: 'center' })); + doc.text('Total', 36 + nameW + cols.length * colW, y, { width: totalW, align: 'center' }); + doc.y = y + 24; + doc.moveTo(36, doc.y - 3).lineTo(doc.page.width - 36, doc.y - 3).lineWidth(0.5).strokeColor('#cbd5e1').stroke(); + }; + 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; + doc.fillColor(qty ? '#15803d' : '#cbd5e1').text(qty ? (r.quantity > 1 ? String(qty) : '✓') : '·', 36 + nameW + i * colW, 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, +}; diff --git a/backend/src/controllers/eventController.js b/backend/src/controllers/eventController.js index b7bc3d1..9ed270e 100644 --- a/backend/src/controllers/eventController.js +++ b/backend/src/controllers/eventController.js @@ -7,6 +7,74 @@ const crypto = require('crypto'); const { assertEventOpen } = require('../utils/cashupUtils'); const { logAdminAction } = require('../utils/adminAudit'); const { getClientIp } = require('../utils/requestUtils'); +const sessionsUtil = require('../utils/sessions'); + +const SESSIONS_INCLUDE = { orderBy: { startsAt: 'asc' } }; + +// Adds the multi-day schedule to an event for API responses: sessions (cancelled ones +// flagged), when registration closes, the next session, and late-joiner pricing. +function withSchedule(ev) { + const sessions = (ev.sessions || []).map(s => ({ ...s, cancelled: !!s.cancelledAt })); + const active = sessionsUtil.activeSessions(sessions); + const now = new Date(); + return { + ...ev, + sessions, + registrationClosesAt: sessionsUtil.registrationClosesAt(ev, sessions), + nextSession: ev.scheduleType === 'sessions' ? active.find(s => new Date(s.endsAt) > now) || null : null, + lateJoin: ev.scheduleType === 'sessions' ? sessionsUtil.lateInfo(ev, sessions, now) : null, + }; +} + +// The schedule/attendance fields of an event from a create/update body. `current` is the +// saved event on update (unsent fields keep their value). +function scheduleData(body, current = {}) { + const has = (k) => body[k] !== undefined; + const data = {}; + if (has('scheduleType')) data.scheduleType = body.scheduleType === 'sessions' ? 'sessions' : 'single'; + if (has('checkInWindow')) data.checkInWindow = body.checkInWindow === 'session_only' ? 'session_only' : 'whole_day'; + if (has('lateJoinEnabled')) data.lateJoinEnabled = body.lateJoinEnabled === true; + if (has('latePricing')) data.latePricing = ['prorata', 'fixed'].includes(body.latePricing) ? body.latePricing : 'full'; + if (has('lateFixedPrice')) { + const n = parseFloat(body.lateFixedPrice); + data.lateFixedPrice = body.lateFixedPrice === null || body.lateFixedPrice === '' || isNaN(n) ? null : Math.max(0, n); + } + if (has('sessionReminders')) data.sessionReminders = sessionsUtil.normalizeReminders(body.sessionReminders) ?? null; + if (has('certificatesEnabled')) data.certificatesEnabled = body.certificatesEnabled === true; + if (has('certificateConfig')) data.certificateConfig = sessionsUtil.normalizeCertificateConfig(body.certificateConfig); + const latePricing = data.latePricing ?? current.latePricing; + const lateFixedPrice = data.lateFixedPrice !== undefined ? data.lateFixedPrice : current.lateFixedPrice; + if (latePricing === 'fixed' && (lateFixedPrice === null || lateFixedPrice === undefined) && (data.lateJoinEnabled ?? current.lateJoinEnabled)) { + throw sessionsUtil.sessionError('Enter the fixed late price, or choose another late price option'); + } + return data; +} + +/** + * Saves the session list and the "late joining until" session after an event is created + * or updated, inside the caller's transaction (`tx`) so a refused change saves nothing. + * `lateJoinUntilSessionIndex` points into the sent session list (sorted by start). + * @returns {Promise} the change counts, or null when nothing was sent + */ +async function saveSchedule(tx, eventId, body) { + sessionsUtil.forgetSchedule(eventId); + if (body.scheduleType === 'single') { + const used = await tx.ticketUsage.count({ where: { session: { eventId } } }); + if (used > 0) throw sessionsUtil.sessionError("This event already has session check-ins, so it can't go back to a single date."); + await tx.eventSession.deleteMany({ where: { eventId } }); + await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: null } }); + return null; + } + if (!Array.isArray(body.sessions)) return null; + const { sessions, changes } = await sessionsUtil.syncEventSessions(tx, eventId, body.sessions); + if (body.lateJoinUntilSessionIndex !== undefined) { + const idx = parseInt(body.lateJoinUntilSessionIndex, 10); + const ordered = [...sessions].sort((a, b) => a.order - b.order); + const until = Number.isInteger(idx) && ordered[idx] && !ordered[idx].cancelledAt ? ordered[idx].id : null; + await tx.event.update({ where: { id: eventId }, data: { lateJoinUntilSessionId: until } }); + } + return changes; +} // Helper to convert stored picture path/URL to an absolute, externally reachable URL based on the incoming request function toAbsoluteUrl(req, url) { @@ -81,7 +149,14 @@ const createEvent = async (req, res) => { contactPhone: contactPhone || null, contactEmail: contactEmail || null, location: location || null, + ...scheduleData(req.body), }; + if (data.scheduleType === 'sessions') { + // Check the sessions before anything is saved; the dates come from them. + const bounds = sessionsUtil.scheduleBounds(sessionsUtil.normalizeSessionsInput(req.body.sessions).filter(s => !s.cancelled)); + data.startDate = bounds.startDate; + data.endDate = bounds.endDate; + } const logEventCreate = (createdEvent) => logAdminAction({ actorId: req.user?.id, @@ -96,6 +171,7 @@ const createEvent = async (req, res) => { try { const event = await prisma.event.create({ data }); logEventCreate(event); + if (data.scheduleType === 'sessions') await prisma.$transaction(tx => saveSchedule(tx, event.id, req.body)); // Automatically create a main ticket (event option) with the event price — contact-only // events have no bookable options, so there's nothing to auto-create for them. @@ -151,7 +227,7 @@ const createEvent = async (req, res) => { return res.status(400).json({ message: 'Failed to save event form/fields', detail: msg, hint: 'Ensure Prisma migrations are applied and Prisma Client is regenerated, then restart the server.' }); } - return res.status(201).json(event); + return res.status(201).json(withSchedule(await prisma.event.findUnique({ where: { id: event.id }, include: { sessions: SESSIONS_INCLUDE } }))); } catch (err) { // Gracefully handle environments where migration isn't applied yet const msg = String(err?.message || ''); @@ -186,7 +262,7 @@ const createEvent = async (req, res) => { throw err; } } catch (error) { - res.status(400).json({ message: error.message }); + res.status(error.statusCode || 400).json({ message: error.message }); } }; @@ -246,7 +322,7 @@ const getEvents = async (req, res) => { goLiveAt: { lte: new Date() }, endDate: { gte: new Date() } }, - include: { eventOptions: eventOptionsInclude } + include: { eventOptions: eventOptionsInclude, sessions: SESSIONS_INCLUDE } }); } catch (e) { // Fallback if goLiveAt/isHidden not available yet (pre-migration) @@ -273,9 +349,9 @@ const getEvents = async (req, res) => { const soldCounts = await Promise.all(limitedOpts.map(opt => takenCount({ eventOptionId: opt.id }))); isSoldOut = limitedOpts.every((opt, i) => soldCounts[i] >= opt.stockLimit); } - return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut, earlyBirdEndsAt }; + return withSchedule({ ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut, earlyBirdEndsAt }); } catch { - return { ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false, earlyBirdEndsAt }; + return withSchedule({ ...ev, picture: toAbsoluteUrl(req, ev.picture), isSoldOut: false, earlyBirdEndsAt }); } })); @@ -293,11 +369,12 @@ const getAllEvents = async (req, res) => { const canIncludeTiers = !!(prisma && prisma.earlyBirdTier && typeof prisma.earlyBirdTier.findMany === 'function'); const events = await prisma.event.findMany({ include: { - eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true + eventOptions: canIncludeTiers ? { include: { earlyBirdTiers: true } } : true, + sessions: SESSIONS_INCLUDE, } }); - const mapped = events.map(ev => ({ + const mapped = events.map(ev => withSchedule({ ...ev, picture: toAbsoluteUrl(req, ev.picture), })); @@ -334,12 +411,13 @@ const getEventsAll = async (req, res) => { where, include: { eventOptions: optionInclude ? { include: optionInclude } : true, - form: { include: { fields: true } } + form: { include: { fields: true } }, + sessions: SESSIONS_INCLUDE, }, orderBy: { startDate: 'asc' } }); - const mapped = events.map(ev => ({ + const mapped = events.map(ev => withSchedule({ ...ev, picture: toAbsoluteUrl(req, ev.picture), })); @@ -362,7 +440,7 @@ const getEventById = async (req, res) => { const optionInclude = canIncludeTiers ? { earlyBirdTiers: true, ...(canIncludeVariants ? { variants: { orderBy: { order: 'asc' } } } : {}) } : undefined; - const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true }; + const includeObj = { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: SESSIONS_INCLUDE }; // Staff/supervisor/admin need the creator + notify-recipient list to populate the // event edit screen's Notifications step; public callers don't need this. @@ -490,7 +568,8 @@ const getEventById = async (req, res) => { // Non-fatal: fall back to options without stock info } - const mapped = { + if (!event.sessions) event.sessions = await prisma.eventSession.findMany({ where: { eventId }, ...SESSIONS_INCLUDE }).catch(() => []); + const mapped = withSchedule({ ...event, eventOptions: eventOptionsWithStock, picture: toAbsoluteUrl(req, event.picture), @@ -500,7 +579,7 @@ const getEventById = async (req, res) => { })), form, bundles: await require('./bundleController').publicBundlesForEvent(eventId).catch(() => []), - }; + }); res.json(mapped); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: error.message }); @@ -515,7 +594,7 @@ const getEventIcs = async (req, res) => { const eventId = req.params.id; const event = await prisma.event.findUnique({ where: { id: eventId }, - select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true }, + select: { id: true, title: true, description: true, startDate: true, endDate: true, location: true, isActive: true, goLiveAt: true, scheduleType: true, sessions: SESSIONS_INCLUDE }, }); if (!event) { @@ -625,10 +704,36 @@ const updateEvent = async (req, res) => { location: location !== undefined ? (location || null) : event.location, updatedAt: new Date(), redirectUrl: redirectUrl !== undefined ? redirectUrl : event.redirectUrl, + ...scheduleData(req.body, event), }; + // A multi-session event's dates come from its sessions (set by saveSchedule). + if ((data.scheduleType ?? event.scheduleType) === 'sessions') { + data.startDate = event.startDate; + data.endDate = event.endDate; + if (Array.isArray(req.body.sessions)) sessionsUtil.normalizeSessionsInput(req.body.sessions); + else if (event.scheduleType !== 'sessions') throw sessionsUtil.sessionError('Add the sessions for a multi-session event'); + } try { - const updatedEvent = await prisma.event.update({ where: { id: req.params.id }, data }); + // Event fields and sessions save together: a refused schedule change saves nothing. + const scheduleBody = data.scheduleType === 'single' && event.scheduleType === 'sessions' ? { scheduleType: 'single' } : req.body; + let updatedEvent; + let sessionChanges; + await prisma.$transaction(async (tx) => { + updatedEvent = await tx.event.update({ where: { id: req.params.id }, data }); + sessionChanges = updatedEvent.scheduleType === 'sessions' || scheduleBody.scheduleType === 'single' + ? await saveSchedule(tx, updatedEvent.id, scheduleBody) + : null; + }); + sessionsUtil.forgetSchedule(updatedEvent.id); + if (sessionChanges && Object.values(sessionChanges).some(n => n > 0)) { + logAdminAction({ + actorId: req.user.id, actorRole: req.user.role, action: 'event_sessions_changed', + targetType: 'Event', targetId: updatedEvent.id, metadata: sessionChanges, ip: getClientIp(req), + }); + } + updatedEvent = withSchedule(await prisma.event.findUnique({ where: { id: updatedEvent.id }, include: { sessions: SESSIONS_INCLUDE } })); + updatedEvent.sessionChanges = sessionChanges; // Upsert form if provided diff --git a/backend/src/controllers/registrationController.js b/backend/src/controllers/registrationController.js index 851912a..6d8258c 100644 --- a/backend/src/controllers/registrationController.js +++ b/backend/src/controllers/registrationController.js @@ -52,6 +52,7 @@ const ACTIVE_COUPONS_INCLUDE = { select: { id: true, discountAmount: true, createdAt: true, coupon: { select: { id: true, code: true, type: true, value: true, description: true } } }, }; const { BUNDLE_APPS_INCLUDE } = require('../utils/bundles'); +const { registrationClosesAt } = require('../utils/sessions'); /** * Coupon lines for options that haven't been saved yet, so a code can be checked @@ -121,7 +122,7 @@ const createRegistration = async (req, res) => { : true; const event = await prisma.event.findUnique({ where: { id: eventId }, - include: { eventOptions: optionInclude ? { include: optionInclude } : true } + include: { eventOptions: optionInclude ? { include: optionInclude } : true, sessions: true } }); if (!event) { @@ -207,11 +208,10 @@ const createRegistration = async (req, res) => { } } - // Enforce registration cutoff for public registrations + // Enforce registration cutoff for public registrations (multi-day events close when + // the first session starts, or later with late joining — see utils/sessions.js). const now = new Date(); - const end = new Date(event.endDate); - const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null; - if ((deadline && now >= deadline) || now >= end) { + if (now >= registrationClosesAt(event, event.sessions)) { res.status(400); throw new Error('Registration is closed for this event'); } diff --git a/backend/src/controllers/ticketController.js b/backend/src/controllers/ticketController.js index 813477e..96147de 100644 --- a/backend/src/controllers/ticketController.js +++ b/backend/src/controllers/ticketController.js @@ -268,6 +268,7 @@ const getScanPreview = async (req, res) => { ov.name AS "variantName", e.id AS "evId", e.title AS "eventTitle", + e."scheduleType" AS "scheduleType", u.id AS "userId", u.name AS "userName", a.name AS "attendeeName", @@ -275,7 +276,8 @@ const getScanPreview = async (req, res) => { json_agg( json_build_object( 'quantityRedeemed', tu."quantityRedeemed", - 'scannedAt', tu."scannedAt" + 'scannedAt', tu."scannedAt", + 'sessionId', tu."sessionId" ) ) FILTER (WHERE tu.id IS NOT NULL), '[]'::json @@ -303,12 +305,33 @@ const getScanPreview = async (req, res) => { const r = rows[0]; const { consentStatusForTickets } = require('../utils/consent'); const consentStatus = (await consentStatusForTickets([r.id])).get(r.id) || 'not_required'; + const usages = r.usages || []; + // Multi-day events: check-in is per session, so what's left is counted for the + // session this scan is for (the one picked, or today's). + let remaining = Number(r.quantity) - usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0); + let session = null; + let sessionError = null; + if (r.scheduleType === 'sessions') { + const sessionsUtil = require('../utils/sessions'); + const schedule = await sessionsUtil.eventSchedule(r.eventId); + const found = sessionsUtil.scanSession(schedule, schedule.sessions, req.query.sessionId || null); + sessionError = found.error; + if (found.session) { + session = { id: found.session.id, label: sessionsUtil.sessionLabel(found.session, schedule.sessions) }; + remaining = Number(r.quantity) - sessionsUtil.redeemedForSession(usages, found.session.id); + } else { + remaining = 0; + } + } res.json({ id: r.id, quantity: Number(r.quantity), isUsed: r.isUsed, eventId: r.eventId, consentStatus, + remaining, + session, + sessionError, registrationOption: { id: r.registrationOptionId, eventOption: { id: r.eventOptionId, name: r.eventOptionName }, @@ -317,7 +340,7 @@ const getScanPreview = async (req, res) => { event: { id: r.evId, title: r.eventTitle }, user: { id: r.userId, name: r.userName }, attendee: r.attendeeName ? { name: r.attendeeName } : null, - usages: r.usages || [] + usages }); } catch (error) { res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) }); @@ -337,6 +360,7 @@ const scanTicket = async (req, res) => { t."isUsed", t."eventId", e.title AS "eventTitle", + e."scheduleType" AS "scheduleType", eo.id AS "eventOptionId", eo.name AS "eventOptionName", eo."isMainTicket" AS "isMainTicket", @@ -345,7 +369,8 @@ const scanTicket = async (req, res) => { json_build_object( 'id', tu.id, 'quantityRedeemed', tu."quantityRedeemed", - 'scannedAt', tu."scannedAt" + 'scannedAt', tu."scannedAt", + 'sessionId', tu."sessionId" ) ) FILTER (WHERE tu.id IS NOT NULL), '[]'::json @@ -405,11 +430,35 @@ const scanTicket = async (req, res) => { return res.status(423).json({ message: `${scope} is offline-locked by ${lock.user?.name || 'another device'}${lock.deviceName ? ` on ${lock.deviceName}` : ''}. Scan it on that device, or ask a supervisor to release the lock.`, code: 'SCAN_LOCKED' }); } + // Multi-day events: the check-in is for one session (the one picked, or today's), and + // what's left is counted per session. + let session = null; + let sessionsUtil = null; + let schedule = null; + if (r.scheduleType === 'sessions') { + sessionsUtil = require('../utils/sessions'); + schedule = await sessionsUtil.eventSchedule(ticket.eventId); + const found = sessionsUtil.scanSession(schedule, schedule.sessions, String(req.body?.sessionId || req.query?.sessionId || '').trim() || null); + if (!found.session) return res.status(400).json({ message: found.error, code: 'NO_SESSION', ticket }); + session = found.session; + } + // Compute how many have already been redeemed - const totalRedeemed = (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0); + const totalRedeemed = session + ? sessionsUtil.redeemedForSession(ticket.usages, session.id) + : (ticket.usages || []).reduce((s, u) => s + (u.quantityRedeemed || 1), 0); const remaining = (ticket.quantity || 1) - totalRedeemed; if (remaining <= 0) { + if (session) { + const last = (ticket.usages || []).filter(u => u.sessionId === session.id).sort((a, b) => new Date(b.scannedAt) - new Date(a.scannedAt))[0]; + const at = last ? new Date(last.scannedAt).toLocaleTimeString('en-ZA', { timeZone: sessionsUtil.TIMEZONE, hour: '2-digit', minute: '2-digit' }) : ''; + return res.status(403).json({ + message: `Already checked in for ${sessionsUtil.sessionLabel(session, schedule.sessions)}${at ? ` at ${at}` : ''}`, + ticket, + remaining: 0 + }); + } return res.status(403).json({ message: 'Ticket has already been fully used', ticket, @@ -452,11 +501,13 @@ const scanTicket = async (req, res) => { ticketId: ticket.id, scannedById: req.user.id, quantityRedeemed: qtyToRedeem, + sessionId: session ? session.id : null, } }), + // A multi-session ticket is never "used up" — it's checked in again next session. prisma.ticket.update({ where: { id: ticket.id }, - data: { isUsed: fullyUsed, updatedAt: new Date() } + data: { isUsed: session ? false : fullyUsed, updatedAt: new Date() } }) ]); @@ -468,8 +519,10 @@ const scanTicket = async (req, res) => { .catch(e => console.error('Failed to send check-in notification:', e)); } + const sessionPart = session ? ` for ${sessionsUtil.sessionLabel(session, schedule.sessions)}` : ''; res.json({ - message: `Ticket scanned successfully (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`, + message: `Ticket scanned successfully${sessionPart} (${qtyToRedeem} of ${ticket.quantity || 1} redeemed${newRemaining > 0 ? `, ${newRemaining} remaining` : ''})`, + session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null, ticketUsage, ticket, qtyRedeemed: qtyToRedeem, diff --git a/backend/src/index.js b/backend/src/index.js index 6fa20ec..11e36f3 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -185,6 +185,7 @@ app.use('/api/scan-locks', require('./routes/scanLockRoutes')); app.use('/api/allocations', require('./routes/allocationRoutes')); app.use('/api/sponsorships', require('./routes/sponsorshipRoutes')); app.use('/api/feedback', require('./routes/feedbackRoutes')); +app.use('/api/attendance', require('./routes/attendanceRoutes')); app.use('/api/analytics', require('./routes/analyticsRoutes')); app.use('/api/backups', backupRoutes); app.use('/api/yoco', yocoRoutes); @@ -1536,6 +1537,36 @@ app.listen(PORT, () => { console.warn('[feedback] Not scheduled:', e?.message || e); } + // Multi-day events — reminders before each session (when the event has them on), and + // completion certificates once an event with certificates on has ended. + try { + const enabled = String(process.env.SESSION_JOBS_ENABLED || 'true').toLowerCase() !== 'false'; + if (enabled) { + const { runSessionReminderJob } = require('./utils/sessionReminders'); + const { runCertificateJob } = require('./utils/certificates'); + const everyMs = parseInt(process.env.SESSION_JOBS_INTERVAL_MS || String(5 * 60 * 1000), 10); + setInterval(async () => { + try { + const r = await runSessionReminderJob(); + if (r.sent) console.log(`[sessions] ${r.sent} reminder(s) sent for ${r.sessions} session(s).`); + } catch (e) { + console.warn('[sessions] Reminder job failed:', e?.message || e); + } + try { + const c = await runCertificateJob(); + if (c.issued) console.log(`[certificates] ${c.issued} issued, ${c.sent} sent.`); + } catch (e) { + console.warn('[certificates] Job failed:', e?.message || e); + } + }, everyMs); + console.log(`[sessions] Reminder and certificate job every ${Math.round(everyMs / 60000)} min.`); + } else { + console.log('[sessions] Jobs disabled by env SESSION_JOBS_ENABLED=false'); + } + } catch (e) { + console.warn('[sessions] Not scheduled:', e?.message || e); + } + // Daily parental-consent job — reminders for unanswered requests, and purging // medical/emergency details a set number of days after each event. try { diff --git a/backend/src/routes/attendanceRoutes.js b/backend/src/routes/attendanceRoutes.js new file mode 100644 index 0000000..d9474c2 --- /dev/null +++ b/backend/src/routes/attendanceRoutes.js @@ -0,0 +1,25 @@ +const express = require('express'); +const router = express.Router(); +const { + getRegister, markAttendance, exportRegister, + issueCertificates, revokeCertificate, sendCertificatesHandler, downloadEventCertificates, previewCertificate, + myCertificates, downloadCertificate, verifyCertificate, +} = require('../controllers/attendanceController'); +const { protect, staff, supervisor } = require('../middleware/authMiddleware'); + +// Certificates — fixed paths first so they aren't taken as an :eventId +router.get('/certificates/verify/:code', verifyCertificate); +router.get('/certificates/mine', protect, myCertificates); +router.post('/certificates/preview', protect, supervisor, previewCertificate); +router.get('/certificates/:id/pdf', protect, downloadCertificate); +router.post('/certificates/:id/revoke', protect, supervisor, revokeCertificate); + +// Attendance register +router.get('/:eventId', protect, staff, getRegister); +router.get('/:eventId/export', protect, staff, exportRegister); +router.post('/:eventId/mark', protect, supervisor, markAttendance); +router.post('/:eventId/certificates', protect, supervisor, issueCertificates); +router.post('/:eventId/certificates/send', protect, supervisor, sendCertificatesHandler); +router.get('/:eventId/certificates.pdf', protect, supervisor, downloadEventCertificates); + +module.exports = router; diff --git a/backend/src/utils/certificates.js b/backend/src/utils/certificates.js new file mode 100644 index 0000000..9d7b1eb --- /dev/null +++ b/backend/src/utils/certificates.js @@ -0,0 +1,358 @@ +/** + * 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, 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 } }, + }, + }); + if (event.scheduleType === 'sessions') { + const active = activeSessions(event.sessions); + const activeIds = new Set(active.map(s => s.id)); + const bySession = await attendanceBySession(eventId); + return { + event, + rows: tickets.map(t => { + const m = bySession.get(t.id) || new Map(); + const attended = [...m.entries()].filter(([sid, qty]) => activeIds.has(sid) && qty > 0).length; + return { ticket: t, attended, total: active.length }; + }), + }; + } + 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(` +

Hi ${user.name?.split(' ')[0] || 'there'},

+

Congratulations on completing ${cert.event.title}! Your certificate for ${cert.name} is attached.

+ ${ctaButton('View my events', `${org.url}/dashboard/user`)} +

Anyone can check it at ${org.url}/certificates/verify/${cert.code}

`, { 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, +}; diff --git a/backend/src/utils/email.js b/backend/src/utils/email.js index 89775ba..30dc553 100644 --- a/backend/src/utils/email.js +++ b/backend/src/utils/email.js @@ -423,6 +423,7 @@ function buildAccountClosedEmail({ name, dataDeleted }) { module.exports = { sendMail, isSmtpConfigured, + getOrg, emailWrapper, ctaButton, fallbackLink, diff --git a/backend/src/utils/icsUtils.js b/backend/src/utils/icsUtils.js index cb9f7ea..b287bbd 100644 --- a/backend/src/utils/icsUtils.js +++ b/backend/src/utils/icsUtils.js @@ -1,4 +1,4 @@ -const { createEvent } = require('ics'); +const { createEvent, createEvents } = require('ics'); // Event.startDate/endDate are stored as true UTC instants (the admin event form's // datetime-local input is parsed in the browser's local time before being sent as an @@ -9,26 +9,46 @@ function toUtcArray(date) { return [d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate(), d.getUTCHours(), d.getUTCMinutes()]; } -/** - * Build an RFC 5545 .ics file (as a string) for a single event. - * - * @param {object} event - Prisma Event row: title, description?, startDate, endDate, location? - * @param {string} eventUrl - absolute URL to the event's public page - * @returns {string} - */ -function buildEventIcs(event, eventUrl) { - const { error, value } = createEvent({ - title: event.title, - start: toUtcArray(event.startDate), - end: toUtcArray(event.endDate), +function icsEntry(event, eventUrl, { start, end, title, location }) { + return { + title, + start: toUtcArray(start), + end: toUtcArray(end), startInputType: 'utc', endInputType: 'utc', startOutputType: 'utc', endOutputType: 'utc', - location: event.location || undefined, + location: location || event.location || undefined, description: event.description || undefined, url: eventUrl, - }); + }; +} + +/** + * Build an RFC 5545 .ics file (as a string) for an event. A multi-session event gets one + * calendar entry per session that isn't cancelled (not a repeat rule, so moved or skipped + * sessions are exact). + * + * @param {object} event - Prisma Event row: title, description?, startDate, endDate, location?, + * scheduleType?, sessions?[] + * @param {string} eventUrl - absolute URL to the event's public page + * @returns {string} + */ +function buildEventIcs(event, eventUrl) { + const sessions = event.scheduleType === 'sessions' + ? (event.sessions || []).filter(s => !s.cancelledAt).sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt)) + : []; + if (sessions.length > 0) { + const { error, value } = createEvents(sessions.map((s, i) => icsEntry(event, eventUrl, { + start: s.startsAt, + end: s.endsAt, + title: `${event.title} — ${s.label || `Session ${i + 1}`}`, + location: s.location, + }))); + if (error) throw error; + return value; + } + const { error, value } = createEvent(icsEntry(event, eventUrl, { start: event.startDate, end: event.endDate, title: event.title })); if (error) throw error; return value; } diff --git a/backend/src/utils/pdfDocs.js b/backend/src/utils/pdfDocs.js index 075d520..f26eead 100644 --- a/backend/src/utils/pdfDocs.js +++ b/backend/src/utils/pdfDocs.js @@ -387,4 +387,4 @@ async function generateInvoicePdf(registration, { paymentUrl = null, totalDue = return { filePath, filename }; } -module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, bundleDiscountLine, discountLines, getBranding, docNumber }; +module.exports = { generateReceiptPdf, generateInvoicePdf, buildLineItems, couponDiscountLine, bundleDiscountLine, discountLines, getBranding, docNumber, tempDir }; diff --git a/backend/src/utils/pricing.js b/backend/src/utils/pricing.js index 48c3cd4..4f4ad60 100644 --- a/backend/src/utils/pricing.js +++ b/backend/src/utils/pricing.js @@ -30,6 +30,7 @@ */ const prisma = require('../config/db'); +const { withLatePrice } = require('./sessions'); const STAFF_ROLES = ['staff', 'supervisor', 'admin']; @@ -91,8 +92,9 @@ function getEffectiveUnitPrice(eventOption, referenceTime, atTime) { * @param {object|null} user - buyer ({ role }); Staff and above may get the staff price * @returns {Promise<{ price: number, tierId: string|null }>} */ +// Late joiners on a multi-day event pay a lower main-ticket price (utils/sessions.js). async function resolveOptionPrice(option, requestedQty = 1, user = null) { - return withStaffPrice(await resolveOptionTierPrice(option, requestedQty), staffPriceFor(option, null, user)); + return withLatePrice(option, withStaffPrice(await resolveOptionTierPrice(option, requestedQty), staffPriceFor(option, null, user))); } async function resolveOptionTierPrice(option, requestedQty) { @@ -147,7 +149,7 @@ async function resolveOptionTierPrice(option, requestedQty) { * @returns {Promise<{ price: number, tierId: string|null }>} */ async function resolveVariantTierPrice(option, variantId, requestedQty = 1, user = null) { - return withStaffPrice(await resolveVariantOnlyTierPrice(option, variantId, requestedQty), staffPriceFor(option, variantId, user)); + return withLatePrice(option, withStaffPrice(await resolveVariantOnlyTierPrice(option, variantId, requestedQty), staffPriceFor(option, variantId, user))); } async function resolveVariantOnlyTierPrice(option, variantId, requestedQty) { diff --git a/backend/src/utils/scanLocks.js b/backend/src/utils/scanLocks.js index d1de2a4..d623be4 100644 --- a/backend/src/utils/scanLocks.js +++ b/backend/src/utils/scanLocks.js @@ -14,6 +14,8 @@ * up, outside the locked scope, consent not given — goes into an OfflineSyncReport. * - A supervisor can force-release a lock (lost phone). If that device syncs later its * scans are still recorded, and the report is flagged. + * - Multi-day events: the snapshot counts what's left for the session running when it was + * downloaded, and each synced scan counts for the session at its scan time. */ const prisma = require('../config/db'); @@ -53,6 +55,7 @@ function lockCovers(lock, { sectionId = null, optionId }) { function judgeSyncedScan(ticket, qty) { if (!ticket) return { redeem: 0, conflict: 'not_found' }; if (ticket.transferred) return { redeem: 0, conflict: 'transferred' }; + if (ticket.noSession) return { redeem: 0, conflict: 'no_session' }; if (ticket.cancelled) return { redeem: 0, conflict: 'cancelled' }; if (!ticket.inScope) return { redeem: 0, conflict: 'wrong_scope' }; if (ticket.remaining <= 0) return { redeem: 0, conflict: 'already_used' }; @@ -69,6 +72,7 @@ const CONFLICT_TEXT = { already_used: 'Ticket had already been fully used', partly_used: 'Fewer places were left than were checked in offline', consent: 'Checked in without parental consent', + no_session: 'Scanned on a day (or at a time) with no session open for check-in', }; // ─── Database helpers ───────────────────────────────────────────────────────── @@ -123,7 +127,7 @@ async function snapshotFor(lock) { registrationOption: { registration: { status: { not: 'cancelled' } }, ...(optionIds ? { eventOptionId: { in: optionIds } } : {}) }, }, include: { - usages: { select: { quantityRedeemed: true } }, + usages: { select: { quantityRedeemed: true, sessionId: true } }, attendee: { select: { name: true } }, user: { select: { name: true } }, registrationOption: { include: { eventOption: { select: { id: true, name: true, isMainTicket: true } }, variant: { select: { name: true } } } }, @@ -133,10 +137,17 @@ async function snapshotFor(lock) { const consent = await consentStatusForTickets(tickets.map(t => t.id)); const snapshotAt = new Date(); await prisma.scanLock.update({ where: { id: lock.id }, data: { snapshotAt } }); + const sessionsUtil = require('./sessions'); + const schedule = await sessionsUtil.eventSchedule(lock.eventId); + const session = schedule?.scheduleType === 'sessions' ? sessionsUtil.currentSession(schedule.sessions, snapshotAt, schedule.checkInWindow) : null; return { snapshotAt, + session: session ? { id: session.id, label: sessionsUtil.sessionLabel(session, schedule.sessions) } : null, + noSessionToday: schedule?.scheduleType === 'sessions' && !session, tickets: tickets.map(t => { - const redeemed = t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0); + const redeemed = schedule?.scheduleType === 'sessions' + ? (session ? sessionsUtil.redeemedForSession(t.usages, session.id) : t.quantity || 1) + : t.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0); return { id: t.id, qrCode: t.qrCode, @@ -161,6 +172,9 @@ async function syncScans(lock, scans) { const optionIds = lock.section ? new Set(lock.section.allowedOptions.map(o => o.eventOptionId)) : null; const { consentStatusForTickets } = require('./consent'); const checkIns = []; + const sessionsUtil = require('./sessions'); + const schedule = await sessionsUtil.eventSchedule(lock.eventId); + const hasSessions = schedule?.scheduleType === 'sessions'; for (const scan of (scans || []).slice(0, 2000)) { const clientScanId = String(scan?.clientScanId || '').slice(0, 100); @@ -178,9 +192,14 @@ async function syncScans(lock, scans) { const { findTransferByOldCode } = require('./ticketTransfer'); if (await findTransferByOldCode(scan.qrCode)) state = { transferred: true }; } else if (ticket.eventId === lock.eventId) { - const redeemed = ticket.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0); + const session = hasSessions ? sessionsUtil.currentSession(schedule.sessions, scannedAt, schedule.checkInWindow) : null; + const redeemed = session + ? sessionsUtil.redeemedForSession(ticket.usages, session.id) + : ticket.usages.reduce((s, u) => s + (u.quantityRedeemed || 1), 0); const consent = (await consentStatusForTickets([ticket.id])).get(ticket.id); state = { + sessionId: session?.id || null, + noSession: hasSessions && !session, remaining: (ticket.quantity || 1) - redeemed, cancelled: ticket.registrationOption.registration.status === 'cancelled', inScope: !optionIds || optionIds.has(ticket.registrationOption.eventOptionId), @@ -190,9 +209,9 @@ async function syncScans(lock, scans) { const verdict = judgeSyncedScan(state, qty); if (verdict.redeem > 0) { await prisma.ticketUsage.create({ - data: { ticketId: ticket.id, scannedById: lock.userId, scannedAt, quantityRedeemed: verdict.redeem, clientScanId, offline: true, scanLockId: lock.id, syncedAt: new Date() }, + data: { ticketId: ticket.id, scannedById: lock.userId, scannedAt, quantityRedeemed: verdict.redeem, clientScanId, offline: true, scanLockId: lock.id, syncedAt: new Date(), sessionId: state.sessionId }, }); - await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: state.remaining - verdict.redeem <= 0, updatedAt: new Date() } }); + await prisma.ticket.update({ where: { id: ticket.id }, data: { isUsed: !hasSessions && state.remaining - verdict.redeem <= 0, updatedAt: new Date() } }); results.accepted += 1; if (ticket.registrationOption.eventOption.isMainTicket) { checkIns.push({ ticketId: ticket.id, qty: verdict.redeem, total: (ticket.quantity || 1) - state.remaining + verdict.redeem, left: state.remaining - verdict.redeem }); diff --git a/backend/src/utils/sessionReminders.js b/backend/src/utils/sessionReminders.js new file mode 100644 index 0000000..8abfbd2 --- /dev/null +++ b/backend/src/utils/sessionReminders.js @@ -0,0 +1,123 @@ +/** + * Session reminders — optional per multi-day event (Event.sessionReminders), e.g. "3 h + * before" and "1 day before" each session, by email and/or WhatsApp. + * + * Rules: + * - Goes to everyone with a ticket for the event (the booker, and named attendees with + * an account), skipping cancelled registrations. One message per person per session. + * - Each (session, offset) is sent once (SessionReminderLog). When several offsets are + * already due — e.g. reminders were switched on 2 h before a session — only the + * closest one is sent and the others are logged as skipped, so nobody gets two at once. + * - Cancelled sessions get no reminders. Email/WhatsApp follow each person's + * notification preference. + */ + +const prisma = require('../config/db'); +const { activeSessions, sessionLabel, TIMEZONE } = require('./sessions'); + +const MAX_OFFSET_MIN = 1440; + +function fmtWhen(d) { + return new Date(d).toLocaleString('en-GB', { timeZone: TIMEZONE, weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit' }); +} + +/** People to remind for an event: Map userId → user. */ +async function recipientsFor(eventId) { + const tickets = await prisma.ticket.findMany({ + where: { eventId, registrationOption: { registration: { status: { not: 'cancelled' } } } }, + select: { + user: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } }, + holder: { select: { id: true, name: true, email: true, phoneNumber: true, notificationPreference: true, isActive: true } }, + }, + }); + const out = new Map(); + for (const t of tickets) { + for (const u of [t.user, t.holder]) if (u && !out.has(u.id)) out.set(u.id, u); + } + return out; +} + +async function sendReminder(user, event, session, sessions) { + const { sendMail, emailWrapper, ctaButton, getOrg } = require('./email'); + const { shouldEmail, waText, waTextAny } = require('./notify'); + const org = getOrg(); + const label = sessionLabel(session, sessions); + const when = fmtWhen(session.startsAt); + const where = session.location || event.location || ''; + const first = (user.name || '').split(' ')[0] || 'there'; + const url = `${org.url}/events/${event.id}`; + const jobs = []; + const hasEmail = user.email && !user.email.endsWith('@guest.local') && !user.email.endsWith('@deleted.invalid'); + if (hasEmail && shouldEmail(user)) { + const html = emailWrapper(` +

Hi ${first},

+

A reminder that ${event.title} — ${label} is on ${when}${where ? ` at ${where}` : ''}.

+

Bring your ticket QR code (it's the same one for every session).

+ ${ctaButton('View event', url)}`, { preheader: `${label} — ${when}` }); + jobs.push(sendMail({ to: user.email, subject: `Reminder: ${event.title} — ${label}`, html, text: `Reminder: ${event.title} — ${label} is on ${when}${where ? ` at ${where}` : ''}. ${url}` })); + } + const wa = `⏰ *Reminder*\n\nHi ${first}, *${event.title} — ${label}* is on ${when}${where ? `\n📍 ${where}` : ''}.\n\nBring your ticket QR code — it's the same one for every session.\n${url}`; + jobs.push(hasEmail ? waText(user, wa) : waTextAny(user, wa)); + await Promise.all(jobs); +} + +/** + * Sends the reminders that are due. Returns { sessions, sent } counts. + */ +async function runSessionReminderJob(now = new Date()) { + // (Filtering a Json column on null is awkward in Prisma, so events without reminders + // are skipped below instead.) + const events = await prisma.event.findMany({ + where: { scheduleType: 'sessions', isActive: true, endDate: { gte: now } }, + include: { + sessions: { + where: { cancelledAt: null, startsAt: { gt: now, lte: new Date(now.getTime() + MAX_OFFSET_MIN * 60 * 1000) } }, + include: { reminders: true }, + }, + }, + }); + let sessionsDone = 0; + let sent = 0; + for (const event of events) { + if (!event.sessionReminders || event.sessions.length === 0) continue; + const config = event.sessionReminders; + const offsets = (config.offsets || []).map(Number).filter(n => n > 0).sort((a, b) => a - b); + if (offsets.length === 0) continue; + const allSessions = await prisma.eventSession.findMany({ where: { eventId: event.id } }); + let recipients = null; + for (const session of event.sessions) { + const minutesLeft = (new Date(session.startsAt) - now) / 60000; + const loggedOffsets = new Set(session.reminders.map(r => r.offset)); + const due = offsets.filter(o => minutesLeft <= o && !loggedOffsets.has(o)); + if (due.length === 0) continue; + // Claim every due offset first (unique per session+offset), so a second server + // process running the same job can't send them again. + const claimed = []; + for (const o of due) { + try { + await prisma.sessionReminderLog.create({ data: { sessionId: session.id, offset: o } }); + claimed.push(o); + } catch (e) { + if (e?.code !== 'P2002') throw e; + } + } + if (!claimed.includes(due[0])) continue; // the closest one was claimed elsewhere + recipients = recipients || await recipientsFor(event.id); + let count = 0; + for (const user of recipients.values()) { + try { + await sendReminder(user, event, session, activeSessions(allSessions)); + count += 1; + } catch (e) { + console.warn('[session reminders] Send failed:', e?.message || e); + } + } + await prisma.sessionReminderLog.update({ where: { sessionId_offset: { sessionId: session.id, offset: due[0] } }, data: { sent: count } }); + sessionsDone += 1; + sent += count; + } + } + return { sessions: sessionsDone, sent }; +} + +module.exports = { runSessionReminderJob }; diff --git a/backend/src/utils/sessions.js b/backend/src/utils/sessions.js new file mode 100644 index 0000000..64fd2aa --- /dev/null +++ b/backend/src/utils/sessions.js @@ -0,0 +1,320 @@ +/** + * Multi-day events — one event running on several dates ("sessions"), e.g. every + * Wednesday for 5 weeks. + * + * Rules: + * - Event.startDate/endDate always mirror the first active session's start and the last + * active session's end, so everything that only reads those keeps working. + * - A cancelled session is kept (it may have check-ins) but is hidden from the schedule + * and left out of totals. A session with check-ins can't be deleted, only cancelled. + * - Check-in is once per session. "Whole day" accepts a scan any time on the session's + * date (in the church's timezone); "session only" from 2 h before the start until the end. + * - Registration closes when the first session starts, or — with late joining — when + * session N starts. A registration deadline, if set, still wins when it's earlier. + * - Late pricing applies to main tickets only, for people registering after the first + * session has started: pro-rata on the sessions left (nearest R10), or a fixed price. + */ + +const prisma = require('../config/db'); + +const TIMEZONE = process.env.APP_TIMEZONE || 'Africa/Johannesburg'; +const EARLY_CHECK_IN_MS = 2 * 60 * 60 * 1000; +const MAX_SESSIONS = 200; +const REMINDER_OFFSETS = [60, 180, 1440]; // minutes before start: 1 h, 3 h, 1 day +const CERTIFICATE_TEMPLATES = ['classic', 'modern', 'church', 'kids']; + +function sessionError(message, statusCode = 400) { + const err = new Error(message); + err.statusCode = statusCode; + return err; +} + +// ─── Pure helpers ───────────────────────────────────────────────────────────── + +/** 'YYYY-MM-DD' of a moment in the church's timezone. */ +function dayKey(date, timeZone = TIMEZONE) { + return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(date)); +} + +/** Sessions that aren't cancelled, earliest first. */ +function activeSessions(sessions) { + return (sessions || []) + .filter(s => !s.cancelledAt) + .sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt)); +} + +/** { startDate, endDate } covering the active sessions, or null when there are none. */ +function scheduleBounds(sessions) { + const active = activeSessions(sessions); + if (active.length === 0) return null; + const endDate = active.reduce((max, s) => (new Date(s.endsAt) > max ? new Date(s.endsAt) : max), new Date(active[0].endsAt)); + return { startDate: new Date(active[0].startsAt), endDate }; +} + +/** + * Checks and tidies the sessions sent by the event editor. + * Each: { id?, label?, startsAt, endsAt, location?, cancelled? }. + */ +function normalizeSessionsInput(list) { + if (!Array.isArray(list)) throw sessionError('Sessions must be a list'); + if (list.length > MAX_SESSIONS) throw sessionError(`An event can have at most ${MAX_SESSIONS} sessions`); + const out = list.map((s, i) => { + const startsAt = new Date(s?.startsAt); + const endsAt = new Date(s?.endsAt); + if (isNaN(startsAt) || isNaN(endsAt)) throw sessionError(`Session ${i + 1} needs a start and end date`); + if (endsAt <= startsAt) throw sessionError(`Session ${i + 1} must end after it starts`); + return { + id: s.id ? String(s.id) : null, + label: s.label ? String(s.label).trim().slice(0, 120) || null : null, + startsAt, + endsAt, + location: s.location ? String(s.location).trim().slice(0, 300) || null : null, + cancelled: s.cancelled === true, + }; + }); + out.sort((a, b) => a.startsAt - b.startsAt); + if (!out.some(s => !s.cancelled)) throw sessionError('A multi-session event needs at least one session that isn\'t cancelled'); + return out; +} + +/** Whether a check-in for `session` is allowed at `now`. */ +function sessionAcceptsCheckIn(session, now = new Date(), window = 'whole_day') { + if (!session || session.cancelledAt) return false; + const t = new Date(now); + if (window === 'session_only') { + return t >= new Date(new Date(session.startsAt).getTime() - EARLY_CHECK_IN_MS) && t <= new Date(session.endsAt); + } + const today = dayKey(t); + return today >= dayKey(session.startsAt) && today <= dayKey(session.endsAt); +} + +/** + * The session a scan at `now` counts for, or null. On a day with more than one session: + * the one happening now (from 2 h before), else the next one today, else the last one. + */ +function currentSession(sessions, now = new Date(), window = 'whole_day') { + const t = new Date(now); + const open = activeSessions(sessions).filter(s => sessionAcceptsCheckIn(s, t, window)); + if (open.length <= 1) return open[0] || null; + const running = open.find(s => t >= new Date(new Date(s.startsAt).getTime() - EARLY_CHECK_IN_MS) && t <= new Date(s.endsAt)); + if (running) return running; + return open.find(s => new Date(s.startsAt) > t) || open[open.length - 1]; +} + +/** When public registration closes for an event (Date). */ +function registrationClosesAt(event, sessions) { + const deadline = event.registrationDeadline ? new Date(event.registrationDeadline) : null; + let closes = new Date(event.endDate); + if (event.scheduleType === 'sessions') { + const active = activeSessions(sessions); + if (active.length > 0) { + const until = event.lateJoinEnabled ? active.find(s => s.id === event.lateJoinUntilSessionId) : null; + closes = new Date((until || active[0]).startsAt); + } + } + return deadline && deadline < closes ? deadline : closes; +} + +/** + * Late-joiner pricing state at `now`: active once the first session has started (for + * events with late joining and a late price). sessionsLeft counts sessions not yet started. + */ +function lateInfo(event, sessions, now = new Date()) { + const active = activeSessions(sessions); + const base = { active: false, mode: event.latePricing || 'full', sessionsLeft: active.length, totalSessions: active.length, fixedPrice: event.lateFixedPrice ?? null }; + if (event.scheduleType !== 'sessions' || !event.lateJoinEnabled || active.length === 0) return base; + const t = new Date(now); + const sessionsLeft = active.filter(s => new Date(s.startsAt) > t).length; + const started = t >= new Date(active[0].startsAt); + const priced = base.mode === 'prorata' || (base.mode === 'fixed' && base.fixedPrice !== null); + return { ...base, sessionsLeft, active: started && priced }; +} + +/** A main ticket's price for a late joiner. Never more than the normal price. */ +function latePrice(price, info) { + if (!info || !info.active || !(price > 0)) return price; + if (info.mode === 'fixed') return Math.min(price, Math.max(0, Number(info.fixedPrice))); + if (info.mode === 'prorata') { + if (!info.totalSessions) return price; + const rounded = Math.round((price * info.sessionsLeft / info.totalSessions) / 10) * 10; + return Math.min(price, Math.max(10, rounded)); + } + return price; +} + +/** Whether `attended` of `total` sessions meets a certificate completion rule. */ +function completionMet(rule, attended, total) { + if (total <= 0 || attended <= 0) return false; + const type = rule?.type || 'all'; + const value = Number(rule?.value) || 0; + if (type === 'count') return attended >= Math.min(Math.max(1, value), total); + if (type === 'percent') return attended / total * 100 >= Math.min(Math.max(1, value), 100) - 1e-9; + return attended >= total; +} + +/** Cleans the reminder settings from the editor; null = off. */ +function normalizeReminders(input) { + if (!input || input.enabled === false) return null; + const offsets = [...new Set((input.offsets || []).map(Number).filter(n => REMINDER_OFFSETS.includes(n)))].sort((a, b) => b - a); + const channels = [...new Set((input.channels || []).filter(c => ['email', 'whatsapp'].includes(c)))]; + if (offsets.length === 0 || channels.length === 0) return null; + return { offsets, channels }; +} + +/** Cleans the certificate settings from the editor. */ +function normalizeCertificateConfig(input) { + const c = input && typeof input === 'object' ? input : {}; + const ruleType = ['all', 'count', 'percent'].includes(c.rule?.type) ? c.rule.type : 'all'; + const str = (v, max) => (v ? String(v).trim().slice(0, max) : ''); + return { + template: CERTIFICATE_TEMPLATES.includes(c.template) ? c.template : 'classic', + title: str(c.title, 120) || 'Certificate of Completion', + body: str(c.body, 1000) || 'This certifies that {name} has completed {event}, attending {sessionsAttended} of {totalSessions} sessions.', + signatureName: str(c.signatureName, 120), + signatureRole: str(c.signatureRole, 120), + signatureImage: str(c.signatureImage, 500), + rule: { type: ruleType, value: ruleType === 'all' ? null : Math.max(1, parseInt(c.rule?.value, 10) || 1) }, + sendOnIssue: c.sendOnIssue === true, + }; +} + +// ─── Database helpers ───────────────────────────────────────────────────────── + +/** + * Saves the editor's session list for an event: updates rows it kept, creates new ones, + * deletes rows it dropped (refused when they have check-ins — cancel those instead), and + * moves Event.startDate/endDate to match. Run inside a transaction (`tx`). + * @returns {Promise<{ sessions, changes: { added, moved, cancelled, restored, removed } }>} + */ +async function syncEventSessions(tx, eventId, input) { + const wanted = normalizeSessionsInput(input); + const existing = await tx.eventSession.findMany({ where: { eventId }, include: { _count: { select: { usages: true } } } }); + const byId = new Map(existing.map(s => [s.id, s])); + const changes = { added: 0, moved: 0, cancelled: 0, restored: 0, removed: 0 }; + + const keep = new Set(wanted.filter(w => w.id && byId.has(w.id)).map(w => w.id)); + for (const s of existing) { + if (keep.has(s.id)) continue; + if (s._count.usages > 0) { + throw sessionError(`"${s.label || dayKey(s.startsAt)}" already has check-ins, so it can't be removed. Cancel it instead.`); + } + await tx.eventSession.delete({ where: { id: s.id } }); + changes.removed += 1; + } + + for (let i = 0; i < wanted.length; i++) { + const w = wanted[i]; + const data = { label: w.label, startsAt: w.startsAt, endsAt: w.endsAt, location: w.location, order: i }; + const old = w.id ? byId.get(w.id) : null; + if (old) { + const cancelledAt = w.cancelled ? (old.cancelledAt || new Date()) : null; + if (+old.startsAt !== +w.startsAt || +old.endsAt !== +w.endsAt) changes.moved += 1; + if (!old.cancelledAt && cancelledAt) changes.cancelled += 1; + if (old.cancelledAt && !cancelledAt) changes.restored += 1; + await tx.eventSession.update({ where: { id: old.id }, data: { ...data, cancelledAt } }); + } else { + await tx.eventSession.create({ data: { ...data, eventId, cancelledAt: w.cancelled ? new Date() : null } }); + changes.added += 1; + } + } + + const sessions = await tx.eventSession.findMany({ where: { eventId }, orderBy: { startsAt: 'asc' } }); + const bounds = scheduleBounds(sessions); + await tx.event.update({ where: { id: eventId }, data: { startDate: bounds.startDate, endDate: bounds.endDate } }); + return { sessions, changes }; +} + +const SCHEDULE_SELECT = { + id: true, endDate: true, registrationDeadline: true, scheduleType: true, checkInWindow: true, + lateJoinEnabled: true, lateJoinUntilSessionId: true, latePricing: true, lateFixedPrice: true, + sessions: { orderBy: { startsAt: 'asc' } }, +}; + +// Late pricing is looked up while pricing every option line, so keep each event's +// schedule for a few seconds rather than querying it per line. +const scheduleCache = new Map(); +const SCHEDULE_TTL_MS = 5000; + +async function eventSchedule(eventId) { + const hit = scheduleCache.get(eventId); + if (hit && hit.expires > Date.now()) return hit.value; + const value = await prisma.event.findUnique({ where: { id: eventId }, select: SCHEDULE_SELECT }); + scheduleCache.set(eventId, { value, expires: Date.now() + SCHEDULE_TTL_MS }); + if (scheduleCache.size > 500) scheduleCache.delete(scheduleCache.keys().next().value); + return value; +} + +function forgetSchedule(eventId) { + scheduleCache.delete(eventId); +} + +/** Label for a session in messages: its own label, else "Session N" by position. */ +function sessionLabel(session, sessions) { + if (session.label) return session.label; + const idx = activeSessions(sessions).findIndex(s => s.id === session.id); + return `Session ${idx >= 0 ? idx + 1 : ''}`.trim(); +} + +/** + * Which session a scan on a multi-session event counts for. `requestedId` is the session + * the scanner picked (optional — defaults to today's). Returns { session, error } with + * session null and an error message when check-in isn't possible now. + */ +function scanSession(event, sessions, requestedId = null, now = new Date()) { + const window = event.checkInWindow || 'whole_day'; + if (requestedId) { + const session = (sessions || []).find(s => s.id === requestedId); + if (!session) return { session: null, error: 'That session isn\'t part of this event' }; + if (session.cancelledAt) return { session: null, error: `${sessionLabel(session, sessions)} was cancelled` }; + if (!sessionAcceptsCheckIn(session, now, window)) { + return { session: null, error: `Check-in for ${sessionLabel(session, sessions)} is only open ${window === 'session_only' ? 'from 2 hours before it starts until it ends' : 'on the day of the session'}` }; + } + return { session, error: null }; + } + const session = currentSession(sessions, now, window); + if (!session) { + const next = activeSessions(sessions).find(s => new Date(s.startsAt) > new Date(now)); + return { session: null, error: next ? `No session today — the next one is ${sessionLabel(next, sessions)} on ${dayKey(next.startsAt)}` : 'All sessions of this event are over' }; + } + return { session, error: null }; +} + +/** Places already checked in for one session, from a ticket's usages. */ +function redeemedForSession(usages, sessionId) { + return (usages || []).filter(u => u.sessionId === sessionId).reduce((sum, u) => sum + (u.quantityRedeemed || 1), 0); +} + +/** Applies late pricing to a resolved option price ({ price, tierId }). */ +async function withLatePrice(option, resolved) { + if (!option?.isMainTicket || !option.eventId || !(resolved.price > 0)) return resolved; + const event = await eventSchedule(option.eventId); + if (!event || event.scheduleType !== 'sessions') return resolved; + const price = latePrice(resolved.price, lateInfo(event, event.sessions)); + return price === resolved.price ? resolved : { ...resolved, price }; +} + +/** + * Check-ins per ticket per session for an event: Map ticketId → Map sessionId → qty. + * Cancelled sessions are included; callers drop them when counting. + */ +async function attendanceBySession(eventId) { + const usages = await prisma.ticketUsage.findMany({ + where: { ticket: { eventId }, sessionId: { not: null } }, + select: { ticketId: true, sessionId: true, quantityRedeemed: true }, + }); + const out = new Map(); + for (const u of usages) { + if (!out.has(u.ticketId)) out.set(u.ticketId, new Map()); + const m = out.get(u.ticketId); + m.set(u.sessionId, (m.get(u.sessionId) || 0) + (u.quantityRedeemed || 1)); + } + return out; +} + +module.exports = { + TIMEZONE, MAX_SESSIONS, REMINDER_OFFSETS, CERTIFICATE_TEMPLATES, sessionError, + dayKey, activeSessions, scheduleBounds, normalizeSessionsInput, sessionAcceptsCheckIn, currentSession, + sessionLabel, scanSession, redeemedForSession, + registrationClosesAt, lateInfo, latePrice, completionMet, normalizeReminders, normalizeCertificateConfig, + syncEventSessions, eventSchedule, forgetSchedule, withLatePrice, attendanceBySession, +}; diff --git a/backend/tests/sessions.test.js b/backend/tests/sessions.test.js new file mode 100644 index 0000000..4f5823f --- /dev/null +++ b/backend/tests/sessions.test.js @@ -0,0 +1,270 @@ +const { + dayKey, + activeSessions, + scheduleBounds, + normalizeSessionsInput, + sessionAcceptsCheckIn, + currentSession, + registrationClosesAt, + lateInfo, + latePrice, + completionMet, + normalizeReminders, + normalizeCertificateConfig, + scanSession, + redeemedForSession, + sessionLabel, +} = require('../src/utils/sessions'); + +// Africa/Johannesburg is UTC+2 all year, so 16:30Z is 18:30 local. +const wed = (day, h = 16, m = 30) => new Date(Date.UTC(2026, 10, day, h, m)); // November 2026 +const session = (id, day, extra = {}) => ({ id, startsAt: wed(day), endsAt: wed(day, 18, 30), cancelledAt: null, ...extra }); +// Five Wednesdays: 4, 11, 18, 25 Nov and 2 Dec (as day 32 → Date.UTC rolls over). +const COURSE = [session('s1', 4), session('s2', 11), session('s3', 18), session('s4', 25), session('s5', 32)]; + +describe('dayKey', () => { + test('uses the church timezone, not UTC', () => { + expect(dayKey(new Date('2026-11-04T23:30:00Z'))).toBe('2026-11-05'); // 01:30 local next day + expect(dayKey(new Date('2026-11-04T21:59:00Z'))).toBe('2026-11-04'); + }); +}); + +describe('activeSessions / scheduleBounds', () => { + test('drops cancelled sessions and sorts by start', () => { + const list = [session('b', 11), session('a', 4), session('c', 18, { cancelledAt: new Date() })]; + expect(activeSessions(list).map(s => s.id)).toEqual(['a', 'b']); + }); + + test('bounds run from the first start to the last end', () => { + const b = scheduleBounds(COURSE); + expect(b.startDate).toEqual(wed(4)); + expect(b.endDate).toEqual(wed(32, 18, 30)); + }); + + test('a cancelled last session moves the end date back', () => { + const list = [...COURSE.slice(0, 4), { ...COURSE[4], cancelledAt: new Date() }]; + expect(scheduleBounds(list).endDate).toEqual(wed(25, 18, 30)); + }); + + test('no active sessions → null', () => { + expect(scheduleBounds([session('a', 4, { cancelledAt: new Date() })])).toBeNull(); + }); +}); + +describe('normalizeSessionsInput', () => { + test('sorts sessions and trims labels', () => { + const out = normalizeSessionsInput([ + { startsAt: wed(11), endsAt: wed(11, 18), label: ' Week 2 ' }, + { startsAt: wed(4), endsAt: wed(4, 18) }, + ]); + expect(out.map(s => s.label)).toEqual([null, 'Week 2']); + }); + + test('rejects a session that ends before it starts', () => { + expect(() => normalizeSessionsInput([{ startsAt: wed(4, 18), endsAt: wed(4, 16) }])).toThrow(/must end after/); + }); + + test('rejects missing dates', () => { + expect(() => normalizeSessionsInput([{ startsAt: 'nope', endsAt: wed(4) }])).toThrow(/start and end/); + }); + + test('needs at least one session that is not cancelled', () => { + expect(() => normalizeSessionsInput([{ startsAt: wed(4), endsAt: wed(4, 18), cancelled: true }])).toThrow(/at least one/); + expect(() => normalizeSessionsInput([])).toThrow(/at least one/); + }); +}); + +describe('check-in window', () => { + const s = session('s1', 4); // 18:30–20:30 local on 4 Nov + + test('whole day accepts any time on the session date (local)', () => { + expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T05:00:00Z'), 'whole_day')).toBe(true); // 07:00 local + expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T21:30:00Z'), 'whole_day')).toBe(true); // 23:30 local + expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T22:30:00Z'), 'whole_day')).toBe(false); // 00:30 next day + }); + + test('session only accepts from 2 h before start until the end', () => { + expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T14:29:00Z'), 'session_only')).toBe(false); + expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T14:30:00Z'), 'session_only')).toBe(true); + expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T18:30:00Z'), 'session_only')).toBe(true); + expect(sessionAcceptsCheckIn(s, new Date('2026-11-04T18:31:00Z'), 'session_only')).toBe(false); + }); + + test('cancelled sessions never accept check-ins', () => { + expect(sessionAcceptsCheckIn({ ...s, cancelledAt: new Date() }, wed(4), 'whole_day')).toBe(false); + }); +}); + +describe('currentSession', () => { + test('finds today\'s session', () => { + expect(currentSession(COURSE, new Date('2026-11-11T08:00:00Z')).id).toBe('s2'); + }); + + test('null on a day without a session', () => { + expect(currentSession(COURSE, new Date('2026-11-12T08:00:00Z'))).toBeNull(); + }); + + test('with two sessions in a day, picks the running one, else the next, else the last', () => { + const day = [ + { id: 'am', startsAt: new Date('2026-11-07T07:00:00Z'), endsAt: new Date('2026-11-07T10:00:00Z'), cancelledAt: null }, + { id: 'pm', startsAt: new Date('2026-11-07T14:00:00Z'), endsAt: new Date('2026-11-07T17:00:00Z'), cancelledAt: null }, + ]; + expect(currentSession(day, new Date('2026-11-07T08:00:00Z')).id).toBe('am'); + expect(currentSession(day, new Date('2026-11-07T11:00:00Z')).id).toBe('pm'); // between: next one + expect(currentSession(day, new Date('2026-11-07T13:00:00Z')).id).toBe('pm'); // within 2 h of pm + expect(currentSession(day, new Date('2026-11-07T19:00:00Z')).id).toBe('pm'); // after both: last + expect(currentSession(day, new Date('2026-11-07T04:00:00Z')).id).toBe('am'); // before both: next + }); +}); + +describe('registrationClosesAt', () => { + const base = { scheduleType: 'sessions', endDate: wed(32, 18, 30), registrationDeadline: null, lateJoinEnabled: false, lateJoinUntilSessionId: null }; + + test('single-date events close at the end date', () => { + expect(registrationClosesAt({ ...base, scheduleType: 'single' }, [])).toEqual(wed(32, 18, 30)); + }); + + test('without late joining, closes when the first session starts', () => { + expect(registrationClosesAt(base, COURSE)).toEqual(wed(4)); + }); + + test('with late joining, closes when the chosen session starts', () => { + expect(registrationClosesAt({ ...base, lateJoinEnabled: true, lateJoinUntilSessionId: 's3' }, COURSE)).toEqual(wed(18)); + }); + + test('an earlier registration deadline wins', () => { + const deadline = new Date('2026-11-01T00:00:00Z'); + expect(registrationClosesAt({ ...base, lateJoinEnabled: true, lateJoinUntilSessionId: 's3', registrationDeadline: deadline }, COURSE)).toEqual(deadline); + }); + + test('a cancelled "until" session falls back to the first session', () => { + const list = COURSE.map(s => (s.id === 's3' ? { ...s, cancelledAt: new Date() } : s)); + expect(registrationClosesAt({ ...base, lateJoinEnabled: true, lateJoinUntilSessionId: 's3' }, list)).toEqual(wed(4)); + }); +}); + +describe('late pricing', () => { + const event = { scheduleType: 'sessions', lateJoinEnabled: true, latePricing: 'prorata', lateFixedPrice: null }; + + test('not active before the first session starts', () => { + expect(lateInfo(event, COURSE, wed(3)).active).toBe(false); + }); + + test('pro-rata on sessions not yet started, rounded to the nearest R10', () => { + const info = lateInfo(event, COURSE, wed(5)); // session 1 done → 4 of 5 left + expect(info).toMatchObject({ active: true, sessionsLeft: 4, totalSessions: 5 }); + expect(latePrice(300, info)).toBe(240); + expect(latePrice(229, info)).toBe(180); // 183.2 → 180 + expect(latePrice(232, info)).toBe(190); // 185.6 → 190 + }); + + test('pro-rata never goes below R10 or above the normal price', () => { + const info = lateInfo(event, COURSE, wed(26)); // 1 of 5 left + expect(latePrice(15, info)).toBe(10); // 3 → 0 → at least R10 + expect(latePrice(8, info)).toBe(8); // under R10 already: unchanged + expect(latePrice(0, info)).toBe(0); + }); + + test('a session in progress counts as started', () => { + const info = lateInfo(event, COURSE, new Date(wed(11).getTime() + 60_000)); + expect(info.sessionsLeft).toBe(3); + }); + + test('fixed late price only ever lowers the price', () => { + const info = lateInfo({ ...event, latePricing: 'fixed', lateFixedPrice: 150 }, COURSE, wed(12)); + expect(latePrice(300, info)).toBe(150); + expect(latePrice(100, info)).toBe(100); + }); + + test('full price, late joining off, or a fixed price without an amount → no change', () => { + expect(lateInfo({ ...event, latePricing: 'full' }, COURSE, wed(12)).active).toBe(false); + expect(lateInfo({ ...event, lateJoinEnabled: false }, COURSE, wed(12)).active).toBe(false); + expect(lateInfo({ ...event, latePricing: 'fixed', lateFixedPrice: null }, COURSE, wed(12)).active).toBe(false); + expect(latePrice(300, { active: false })).toBe(300); + }); +}); + +describe('completionMet', () => { + test('all sessions (default)', () => { + expect(completionMet(undefined, 5, 5)).toBe(true); + expect(completionMet({ type: 'all' }, 4, 5)).toBe(false); + }); + + test('at least N sessions, capped at the total', () => { + expect(completionMet({ type: 'count', value: 4 }, 4, 5)).toBe(true); + expect(completionMet({ type: 'count', value: 4 }, 3, 5)).toBe(false); + expect(completionMet({ type: 'count', value: 10 }, 3, 3)).toBe(true); + }); + + test('at least N percent', () => { + expect(completionMet({ type: 'percent', value: 80 }, 4, 5)).toBe(true); + expect(completionMet({ type: 'percent', value: 80 }, 3, 5)).toBe(false); + expect(completionMet({ type: 'percent', value: 66 }, 2, 3)).toBe(true); + }); + + test('nobody completes without attending', () => { + expect(completionMet({ type: 'count', value: 1 }, 0, 5)).toBe(false); + expect(completionMet(undefined, 0, 0)).toBe(false); + }); +}); + +describe('settings cleaning', () => { + test('reminders: off unless an offset and a channel are chosen', () => { + expect(normalizeReminders(null)).toBeNull(); + expect(normalizeReminders({ offsets: [180], channels: [] })).toBeNull(); + expect(normalizeReminders({ offsets: [180, 999, 1440], channels: ['email', 'sms'] })).toEqual({ offsets: [1440, 180], channels: ['email'] }); + }); + + test('certificates: unknown template falls back to classic, rule is tidied', () => { + const c = normalizeCertificateConfig({ template: 'fancy', rule: { type: 'count', value: '3' } }); + expect(c.template).toBe('classic'); + expect(c.rule).toEqual({ type: 'count', value: 3 }); + expect(c.title).toBe('Certificate of Completion'); + expect(normalizeCertificateConfig({ rule: { type: 'all', value: 4 } }).rule).toEqual({ type: 'all', value: null }); + }); +}); + +describe('scanSession', () => { + const event = { checkInWindow: 'whole_day' }; + + test("defaults to today's session", () => { + expect(scanSession(event, COURSE, null, new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3'); + }); + + test('no session today: says when the next one is', () => { + const r = scanSession(event, COURSE, null, new Date('2026-11-12T09:00:00Z')); + expect(r.session).toBeNull(); + expect(r.error).toMatch(/next one is Session 3 on 2026-11-18/); + }); + + test('after the last session: all over', () => { + expect(scanSession(event, COURSE, null, new Date('2026-12-10T09:00:00Z')).error).toMatch(/over/); + }); + + test('a picked session must be open now', () => { + expect(scanSession(event, COURSE, 's3', new Date('2026-11-18T09:00:00Z')).session.id).toBe('s3'); + expect(scanSession(event, COURSE, 's4', new Date('2026-11-18T09:00:00Z')).error).toMatch(/only open on the day/); + expect(scanSession({ checkInWindow: 'session_only' }, COURSE, 's3', new Date('2026-11-18T09:00:00Z')).error).toMatch(/2 hours before/); + }); + + test('a cancelled or unknown session is refused', () => { + const list = COURSE.map(s => (s.id === 's3' ? { ...s, cancelledAt: new Date() } : s)); + expect(scanSession(event, list, 's3', new Date('2026-11-18T09:00:00Z')).error).toMatch(/cancelled/); + expect(scanSession(event, COURSE, 'nope').error).toMatch(/isn't part/); + }); +}); + +describe('redeemedForSession / sessionLabel', () => { + test("counts only that session's check-ins", () => { + const usages = [{ sessionId: 's1', quantityRedeemed: 2 }, { sessionId: 's2', quantityRedeemed: 1 }, { sessionId: 's1' }]; + expect(redeemedForSession(usages, 's1')).toBe(3); + expect(redeemedForSession(usages, 's3')).toBe(0); + }); + + test('uses the label, else the position among active sessions', () => { + expect(sessionLabel({ id: 'x', label: 'Opening night' }, COURSE)).toBe('Opening night'); + expect(sessionLabel(COURSE[2], COURSE)).toBe('Session 3'); + const list = [{ ...COURSE[0], cancelledAt: new Date() }, ...COURSE.slice(1)]; + expect(sessionLabel(list[2], list)).toBe('Session 2'); + }); +});