- Waitlist per session: entries for pick-sessions tickets name a session; offers use that session's free places, never run past its start, and claim links tick it. Held offers count towards session capacity. - At the door: the Select items pop-up has the session picker, with today's session preselected for new attendees. - On pick-sessions events every ticket shows the session list straight away. "All sessions" tickets record the ticked sessions (RegistrationOption.sessionIds) at one price; scanning, attendance, certificates, reminders and capacity follow them. - Fix: per-session stock limits weren't enforced (takenCount filtered WaitlistEntry by a sessionId column that didn't exist, and the error was swallowed), and the public event page lost its stock info. - Fix: editing a door registration with picked sessions couldn't be saved. - Fix: the offline-scanning service worker served /_next/static cache-first forever, so dev browsers that had opened the scanner ran stale code. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
123 lines
5.9 KiB
JavaScript
123 lines
5.9 KiB
JavaScript
/**
|
|
* 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 session (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 about a session: whole-course tickets (all sessions, or this one ticked) plus picked places on it. Map userId → user. */
|
|
async function recipientsFor(eventId, sessionId) {
|
|
const tickets = await prisma.ticket.findMany({
|
|
where: { eventId, registrationOption: { registration: { status: { not: 'cancelled' } }, OR: [{ sessionId: null, sessionIds: { isEmpty: true } }, { sessionId: null, sessionIds: { has: sessionId } }, { sessionId }] } },
|
|
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 } });
|
|
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
|
|
const recipients = await recipientsFor(event.id, session.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 };
|