Add multi-day event sessions (backend)
Events can run over several sessions: per-session check-in with a whole-day or session-time window, late joining with pro-rata or fixed late pricing, an attendance register (Excel/PDF), session reminders, and completion certificates (four templates, public verify page). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
@@ -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"
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<object|null>} 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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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(`
|
||||
<p>Hi ${user.name?.split(' ')[0] || 'there'},</p>
|
||||
<p>Congratulations on completing <strong>${cert.event.title}</strong>! Your certificate for <strong>${cert.name}</strong> is attached.</p>
|
||||
${ctaButton('View my events', `${org.url}/dashboard/user`)}
|
||||
<p style="font-size:13px;color:#64748b">Anyone can check it at ${org.url}/certificates/verify/${cert.code}</p>`, { preheader: `Your certificate for ${cert.event.title}` });
|
||||
jobs.push(sendMail({ to: user.email, subject: `Your certificate — ${cert.event.title}`, html, text: `Congratulations on completing ${cert.event.title}! Your certificate is attached.`, attachments: [{ filename, content: pdf, contentType: 'application/pdf' }] }));
|
||||
}
|
||||
const { tempDir } = require('./pdfDocs');
|
||||
const tmp = path.join(tempDir(), `certificate-${cert.id}.pdf`);
|
||||
fs.writeFileSync(tmp, pdf);
|
||||
const caption = `🎓 Your certificate for ${cert.event.title}`;
|
||||
jobs.push(hasEmail ? waPdf(user, tmp, filename, caption) : waPdfAny(user, tmp, filename, caption));
|
||||
await Promise.all(jobs);
|
||||
await prisma.certificate.update({ where: { id: cert.id }, data: { emailedAt: new Date() } });
|
||||
sent += 1;
|
||||
} catch (e) {
|
||||
console.warn('[certificates] Send failed:', e?.message || e);
|
||||
}
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Background job: for events with certificates on that ended within the last 14 days,
|
||||
* issue certificates to everyone eligible, and send them when the event says so.
|
||||
*/
|
||||
async function runCertificateJob(now = new Date()) {
|
||||
const since = new Date(now.getTime() - AUTO_ISSUE_DAYS * 24 * 60 * 60 * 1000);
|
||||
const events = await prisma.event.findMany({
|
||||
where: { certificatesEnabled: true, endDate: { lte: now, gte: since } },
|
||||
select: { id: true, certificateConfig: true },
|
||||
});
|
||||
let issued = 0;
|
||||
let sent = 0;
|
||||
for (const ev of events) {
|
||||
const created = await issueEligible(ev.id);
|
||||
issued += created.length;
|
||||
if (created.length && normalizeCertificateConfig(ev.certificateConfig).sendOnIssue) {
|
||||
sent += await sendCertificates(created.map(c => c.id));
|
||||
}
|
||||
}
|
||||
return { issued, sent };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
newCode, fillPlaceholders, attendanceForCertificates, issueEligible, issueManual,
|
||||
renderCertificatesPdf, sampleCertificate, sendCertificates, runCertificateJob,
|
||||
};
|
||||
@@ -423,6 +423,7 @@ function buildAccountClosedEmail({ name, dataDeleted }) {
|
||||
module.exports = {
|
||||
sendMail,
|
||||
isSmtpConfigured,
|
||||
getOrg,
|
||||
emailWrapper,
|
||||
ctaButton,
|
||||
fallbackLink,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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(`
|
||||
<p>Hi ${first},</p>
|
||||
<p>A reminder that <strong>${event.title} — ${label}</strong> is on <strong>${when}</strong>${where ? ` at <strong>${where}</strong>` : ''}.</p>
|
||||
<p>Bring your ticket QR code (it's the same one for every session).</p>
|
||||
${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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user