Files
hope-events/backend/src/utils/sessionReminders.js
T
joshuaandClaude Opus 5.5 f99b84858e Session gaps: per-session waitlist, door session picker, all-sessions choice
- 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>
2026-10-23 11:38:21 +02:00

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 };