/** * event-reminders — the hourly cron task that sends reminder e-mails to * confirmed registrations whose event starts within the configured lead time. * * Semantics are at-least-once: `reminderSentAt` is written ONLY after the * e-mail sent successfully, so a failure on one run is retried on the next. * One failing registration never stops the batch — every registration is * wrapped in its own try/catch. * * Registered from src/index.ts only when `server.cron.enabled` is true. */ import type { Core } from '@strapi/strapi'; import { sendEventReminderMail } from './event-mail'; const REGISTRATION_UID = 'api::event-registration.event-registration'; const REMINDER_SETTINGS_UID = 'api::reminder-settings.reminder-settings'; const LOG_PREFIX = '[event-reminders]'; const DEFAULT_LEAD_HOURS = 48; const DEFAULT_REMINDER_ENABLED = true; const PAGE_SIZE = 50; type ReminderSettings = { enabled: boolean; leadHours: number; }; /** * Read the dashboard-controlled settings, falling back to the seed defaults * when the single type is missing or its values are empty. */ async function readReminderSettings( strapi: Core.Strapi, ): Promise { try { const settings = await strapi .documents(REMINDER_SETTINGS_UID) .findFirst({}); if (!settings) { return { enabled: DEFAULT_REMINDER_ENABLED, leadHours: DEFAULT_LEAD_HOURS }; } const leadHours = typeof settings.leadHours === 'number' && settings.leadHours >= 0 ? settings.leadHours : DEFAULT_LEAD_HOURS; return { enabled: settings.enabled !== false, leadHours, }; } catch (error) { strapi.log.warn( `${LOG_PREFIX} could not read reminder settings, using defaults: ${String(error)}`, ); return { enabled: DEFAULT_REMINDER_ENABLED, leadHours: DEFAULT_LEAD_HOURS }; } } export async function runEventReminders({ strapi, }: { strapi: Core.Strapi; }): Promise { const settings = await readReminderSettings(strapi); if (!settings.enabled) { strapi.log.debug(`${LOG_PREFIX} disabled in reminder settings, skipping.`); return; } const now = new Date(); const horizon = new Date(now.getTime() + settings.leadHours * 3_600_000); strapi.log.info( `${LOG_PREFIX} running for events starting between ${now.toISOString()} and ${horizon.toISOString()}.`, ); let page = 1; let sent = 0; let failed = 0; while (true) { const registrations = await strapi .documents(REGISTRATION_UID) .findMany({ filters: { registrationState: { $eq: 'confirmed' }, reminderSentAt: { $null: true }, event: { startDateTime: { $gte: now.toISOString(), $lte: horizon.toISOString(), }, }, }, populate: { event: true }, pagination: { page, pageSize: PAGE_SIZE }, }); for (const registration of registrations) { try { const event = registration.event as | { documentId?: string; id?: number } | null | undefined; const reference = event?.documentId ? { documentId: event.documentId } : event?.id !== undefined ? { id: event.id } : undefined; if (!event || !reference) { strapi.log.warn( `${LOG_PREFIX} registration ${registration.documentId} has no event, skipping.`, ); continue; } const ok = await sendEventReminderMail({ strapi, to: registration.email as string, eventReference: reference, }); if (!ok) { failed += 1; continue; } await strapi .documents(REGISTRATION_UID) .update({ documentId: registration.documentId as string, data: { reminderSentAt: new Date().toISOString() }, }); sent += 1; } catch (error) { failed += 1; strapi.log.error( `${LOG_PREFIX} failed for registration ${registration.documentId}: ${String(error)}`, ); } } if (registrations.length < PAGE_SIZE) break; page += 1; } strapi.log.info( `${LOG_PREFIX} finished: ${sent} reminder(s) sent, ${failed} failed.`, ); }