diff --git a/apps/cms/.env.example b/apps/cms/.env.example index 6682608..218ff5f 100644 --- a/apps/cms/.env.example +++ b/apps/cms/.env.example @@ -26,6 +26,8 @@ IS_PROXIED=false # Log verbosity: fatal | error | warn | info | http | debug | trace LOG_LEVEL=info # Enable the Strapi cron runner (config/server.ts -> cron.enabled). +# This also turns on the hourly event-reminder e-mails (see src/lib/event-mail). +# Needs SMTP below to be real - with no provider configured the job logs errors. CRON_ENABLED=false # Include relations in webhook payloads. Off by default: heavier payloads. WEBHOOKS_POPULATE_RELATIONS=false @@ -149,6 +151,8 @@ SMTP_USERNAME=your-username SMTP_PASSWORD=your-password DEFAULT_FROM=no-reply@yourdomain.com DEFAULT_REPLY_TO=support@yourdomain.com +# The values above are also the from/reply-to for the registration +# confirmation and event-reminder e-mails (src/lib/event-mail.ts). # Production only - switching the upload provider to S3. Uncomment these after # adding @strapi/provider-upload-aws-s3 and editing the upload block in diff --git a/apps/cms/src/api/event-registration/services/event-registration.ts b/apps/cms/src/api/event-registration/services/event-registration.ts index 6117ab0..5278e1c 100644 --- a/apps/cms/src/api/event-registration/services/event-registration.ts +++ b/apps/cms/src/api/event-registration/services/event-registration.ts @@ -1,7 +1,64 @@ /** * event-registration service + * + * `create` is overridden to fire the confirmation e-mail *after* the row is + * written. The send is deliberately not awaited: a slow or failing SMTP call + * must never turn the registration POST into a 502. The mail helper swallows + * and logs its own errors, and the state derivation stays in the + * sanitize-submission middleware - nothing here recomputes confirmed/waitlisted. */ import { factories } from '@strapi/strapi'; -export default factories.createCoreService('api::event-registration.event-registration'); +import { sendRegistrationConfirmation } from '../../../lib/event-mail'; + +const REGISTRATION_UID = 'api::event-registration.event-registration'; +const LOG_PREFIX = '[event-registration]'; + +/** + * The `event` value a client may have sent is a documentId (what the REST API + * uses for relations), or a legacy numeric id. Mirrors the middleware's reader + * so the confirmation mail points at the event the registrant chose. + */ +const readEventReference = (value: unknown): { documentId?: string; id?: number } | undefined => { + if (typeof value === 'string' && value.length > 0) { + return { documentId: value }; + } + + if (typeof value === 'number' && Number.isInteger(value)) { + return { id: value }; + } + + return undefined; +}; + +const readCreatedResult = (result: unknown): { email?: unknown; registrationState?: unknown } => + (typeof result === 'object' && result !== null ? result : {}) as { + email?: unknown; + registrationState?: unknown; + }; + +export default factories.createCoreService(REGISTRATION_UID, ({ strapi }) => ({ + async create(params: { data?: { event?: unknown } } & Record) { + const result = await super.create(params); + + const { email, registrationState } = readCreatedResult(result); + + if (typeof email === 'string' && email.length > 0) { + const eventReference = readEventReference(params.data?.event); + + void sendRegistrationConfirmation({ + strapi, + to: email, + state: registrationState === 'waitlisted' ? 'waitlisted' : 'confirmed', + eventReference, + }); + } else { + strapi.log.warn( + `${LOG_PREFIX} no email on the created registration; confirmation e-mail skipped.`, + ); + } + + return result; + }, +})); \ No newline at end of file diff --git a/apps/cms/src/api/reminder-settings/content-types/reminder-settings/schema.json b/apps/cms/src/api/reminder-settings/content-types/reminder-settings/schema.json new file mode 100644 index 0000000..8b30ec3 --- /dev/null +++ b/apps/cms/src/api/reminder-settings/content-types/reminder-settings/schema.json @@ -0,0 +1,26 @@ +{ + "kind": "singleType", + "collectionName": "reminder_settings", + "info": { + "singularName": "reminder-settings", + "pluralName": "reminder-settings-list", + "displayName": "Reminder Settings", + "description": "Operational settings for the event reminder emails: whether they are enabled and how long before an event starts the reminder is sent. Read by the cron job only; not exposed over the public API." + }, + "options": { + "draftAndPublish": false + }, + "attributes": { + "enabled": { + "type": "boolean", + "required": true, + "default": true + }, + "leadHours": { + "type": "integer", + "required": true, + "default": 48, + "min": 0 + } + } +} \ No newline at end of file diff --git a/apps/cms/src/bootstrap/index.ts b/apps/cms/src/bootstrap/index.ts index 3a86008..205b231 100644 --- a/apps/cms/src/bootstrap/index.ts +++ b/apps/cms/src/bootstrap/index.ts @@ -4,10 +4,12 @@ import { revokePublicAuthActions } from './auth-permissions'; import { LOG_PREFIX } from './constants'; import { ensureLocales } from './locales'; import { ensurePublicPermissions } from './public-permissions'; +import { ensureReminderSettings } from './reminder-settings'; export { revokePublicAuthActions } from './auth-permissions'; export { ensureLocales } from './locales'; export { ensurePublicPermissions } from './public-permissions'; +export { ensureReminderSettings } from './reminder-settings'; /** * Boot-time reconciliation, run from `src/index.ts`. @@ -26,6 +28,7 @@ export const runBootstrap = async ({ strapi }: { strapi: Core.Strapi }): Promise await ensureLocales(strapi); await ensurePublicPermissions(strapi); await revokePublicAuthActions(strapi); + await ensureReminderSettings(strapi); strapi.log.debug(`${LOG_PREFIX} bootstrap complete.`); }; diff --git a/apps/cms/src/bootstrap/reminder-settings.ts b/apps/cms/src/bootstrap/reminder-settings.ts new file mode 100644 index 0000000..d949393 --- /dev/null +++ b/apps/cms/src/bootstrap/reminder-settings.ts @@ -0,0 +1,40 @@ +import type { Core } from '@strapi/strapi'; + +import { LOG_PREFIX } from './constants'; + +const REMINDER_SETTINGS_UID = 'api::reminder-settings.reminder-settings'; + +/** + * The values the reminder cron job falls back to when no `reminder-settings` + * single type exists yet (first boot on an empty database). The admin panel + * owns these values from then on; this is only the bootstrap default. + */ +export const DEFAULT_REMINDER_SETTINGS = { + enabled: true, + leadHours: 48, +} as const; + +/** + * Create the `reminder-settings` single type if the database does not have one + * yet, so the values the reminder cron job depends on exist and are editable in + * the admin panel after the very first boot. Never overwrites an existing row. + */ +export const ensureReminderSettings = async (strapi: Core.Strapi): Promise => { + const existing = await strapi + .documents(REMINDER_SETTINGS_UID) + .findFirst({}); + + if (existing) { + return; + } + + await strapi.documents(REMINDER_SETTINGS_UID).create({ + data: { ...DEFAULT_REMINDER_SETTINGS }, + }); + + strapi.log.info( + `${LOG_PREFIX} created reminder-settings single type with defaults ` + + `(enabled=${DEFAULT_REMINDER_SETTINGS.enabled}, ` + + `leadHours=${DEFAULT_REMINDER_SETTINGS.leadHours}).` + ); +}; \ No newline at end of file diff --git a/apps/cms/src/index.ts b/apps/cms/src/index.ts index e9163b7..ee4039e 100644 --- a/apps/cms/src/index.ts +++ b/apps/cms/src/index.ts @@ -1,6 +1,7 @@ import type { Core } from '@strapi/strapi'; import { runBootstrap } from './bootstrap'; +import { runEventReminders } from './lib/event-reminders'; export default { /** @@ -21,5 +22,19 @@ export default { */ async bootstrap({ strapi }: { strapi: Core.Strapi }) { await runBootstrap({ strapi }); + + // The cron provider always runs its own `start()`, so `strapi.cron.add()` + // here schedules immediately. Respect the explicit opt-out in + // `server.cron.enabled` (env `CRON_ENABLED`): without SMTP configured the + // hourly job would just fail and log, so it stays inert by default. + if (strapi.config.get('server.cron.enabled', false)) { + strapi.cron.add({ + 'send-event-reminders': { + task: runEventReminders, + options: { rule: '0 * * * *', tz: 'Africa/Tripoli' }, + }, + }); + strapi.log.info('[index] event reminder cron job registered.'); + } }, }; diff --git a/apps/cms/src/lib/event-mail.ts b/apps/cms/src/lib/event-mail.ts new file mode 100644 index 0000000..6fc6602 --- /dev/null +++ b/apps/cms/src/lib/event-mail.ts @@ -0,0 +1,410 @@ +/** + * event-mail — bilingual e-mail building and sending for event registrations. + * + * The CMS sends two kinds of e-mails, both bilingual with Arabic first: + * + * confirmation the moment a registration is created (confirmed or + * waitlisted) — fire-and-forget from the write path, never + * awaited, so a slow or failing SMTP call can never make the + * registration request fail. + * reminder sent by the cron job to confirmed registrations who have not + * been reminded yet, for events starting within the configured + * lead time. + * + * The Arabic block always comes first and is marked dir="rtl"; the English + * block follows inside the same message. Subjects are bilingual too. + * + * Copy mirrors the approved web copy in apps/web (i18n/messages and + * _events-opportunities/copy.ts); anything invented lives in the seed, not + * here. EU visibility uses the approved wording from the seed sources. + */ + +import type { Core } from '@strapi/strapi'; + +const EVENT_UID = 'api::event.event'; + +export type RegistrationState = 'confirmed' | 'waitlisted'; + +type Locale = 'en' | 'ar'; + +/** Non-localised fields are shared by both locale rows of an event. */ +type EventRow = { + documentId: string; + title?: string | null; + /** Strapi returns datetime attributes as ISO strings (or Dates); accept both. */ + startDateTime?: string | Date | null; + timezone?: string | null; + format?: 'in-person' | 'online' | 'hybrid' | null; + venue?: string | null; + onlineUrl?: string | null; +}; + +/** An event looked up in both locales; missing locales are allowed. */ +type BilingualEvent = { en?: EventRow; ar?: EventRow }; + +/** Identifier a public form may have sent for the event relation. */ +type EventReference = { documentId?: string; id?: number } | undefined; + +const EMAIL_TAG = { + en: 'en-GB-u-nu-latn', + ar: 'ar-LY-u-nu-latn', +} as const; + +const DEFAULT_TIMEZONE = 'Africa/Tripoli'; + +/** + * Date plus time of day, mirroring the web's `formatDateTime`: Arabic uses the + * latn numbering system, so digits never switch to Eastern Arabic numerals. + */ +function formatEventDateTime( + value: string | Date | null | undefined, + locale: Locale, + timeZone: string, +): string { + if (!value) return ''; + const date = typeof value === 'string' ? new Date(value) : value; + if (Number.isNaN(date.getTime())) return ''; + + return new Intl.DateTimeFormat(EMAIL_TAG[locale], { + dateStyle: 'long', + timeStyle: 'short', + timeZone, + }).format(date); +} + +/* --------------------------------------------------------------------------- + CONFIRMATION / WAITLIST COPY (approved web copy) + ------------------------------------------------------------------------ */ + +const CONFIRMATION_SUBJECT = { + ar: 'تم تأكيد تسجيلك', + en: 'Registration confirmed', +} as const; + +const WAITLIST_SUBJECT = { + ar: 'تمت إضافتك إلى قائمة الانتظار', + en: "You're on the waitlist", +} as const; + +const CONFIRMATION_BODY = { + ar: 'شكرًا لك — تم تأكيد مكانك في {event} بتاريخ {date}. سنرسل لك تذكيرًا وأي تفاصيل إضافية على البريد الإلكتروني الذي قدّمته.', + en: 'Thank you — your spot for {event} on {date} is confirmed. We’ll send a reminder and any joining details to the email you provided.', +} as const; + +const WAITLIST_BODY = { + ar: 'شكرًا لك — اكتمل العدد في {event} بتاريخ {date}، لذلك أضفناك إلى قائمة الانتظار. سنراسلك عبر البريد الإلكتروني في حال توفّر مكان.', + en: 'Thank you — {event} on {date} is fully booked, so we have added you to the waitlist. We will email you if a place becomes available.', +} as const; + +/* --------------------------------------------------------------------------- + REMINDER COPY + ------------------------------------------------------------------------ */ + +const REMINDER_SUBJECT_PREFIX = { + ar: 'تذكير', + en: 'Reminder', +} as const; + +const REMINDER_BODY = { + ar: 'هذا تذكير بأنك مسجّل في {event} بتاريخ {date}. تبدأ الفعالية في الوقت المُشار إليه أدناه.', + en: 'This is a reminder that you are registered for {event} on {date}. A summary is below.', +} as const; + +const LOCATION_LABEL = { + ar: 'الموقع', + en: 'Location', +} as const; + +const JOIN_LABEL = { + ar: 'الانضمام عبر الإنترنت', + en: 'Join online', +} as const; + +/* --------------------------------------------------------------------------- + EU VISIBILITY FOOTER (approved wording) + ------------------------------------------------------------------------ */ + +const FOOTER = { + ar: 'SHARP-ER — العمل المشترك لتعزيز سيادة القانون، مسار الخروج — يُنفَّذ من قِبل منظمة Acted بالشراكة مع United Efforts Consultancy (UEC) وStichting Bureau Ensemble (SBE)، وبتمويل من الاتحاد الأوروبي.', + en: 'SHARP-ER — Shared Action on Rule of Law Progress, Exit Route — is implemented by Acted, in consortium with United Efforts Consultancy (UEC) and Stichting Bureau Ensemble (SBE), and funded by the European Union.', +} as const; + +/* --------------------------------------------------------------------------- + MARKUP + ------------------------------------------------------------------------ */ + +const BASE_FONT = + "font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;"; + +function wrapParagraphs(text: string): string { + return text + .trim() + .split(/\n\n+/) + .map((paragraph) => `

${paragraph.trim()}

`) + .join(''); +} + +/** + * One bilingual message: the Arabic block first (rtl), then English, then the + * EU-visibility footer. Inline styles keep the layout working in mail clients + * that strip style sheets. + */ +export function buildBilingualMail(opts: { + subjectAr: string; + subjectEn: string; + bodyAr: string; + bodyEn: string; + locationLine?: { ar: string; en: string }; +}): { subject: string; text: string; html: string } { + const { subjectAr, subjectEn, bodyAr, bodyEn, locationLine } = opts; + + const html = ` +
+ ${wrapParagraphs(bodyAr)} +
+
+
+ ${wrapParagraphs(bodyEn)} +
+

+ ${FOOTER.ar} +

+

+ ${FOOTER.en} +

`; + + const text = [ + bodyAr, + locationLine?.ar ?? '', + '', + bodyEn, + locationLine?.en ?? '', + '', + FOOTER.ar, + FOOTER.en, + ] + .filter((line) => line.length > 0) + .join('\n'); + + return { + subject: `${subjectAr} | ${subjectEn}`, + text, + html, + }; +} + +/* --------------------------------------------------------------------------- + EVENT LOOKUP + ------------------------------------------------------------------------ */ + +/** + * Fetch an event in both locales. Only published events are ever fetched: the + * documents API defaults to `status: published` when none is given. Either a + * documentId (what the REST API uses for relations) or a legacy numeric id is + * accepted, mirroring the sanitize-submission middleware. + */ +export async function fetchBilingualEvent( + strapi: Core.Strapi, + reference: EventReference, +): Promise { + const filters = reference?.documentId + ? { documentId: reference.documentId } + : reference?.id !== undefined + ? { id: reference.id } + : null; + + if (!filters) return {}; + + const [en, ar] = await Promise.all([ + strapi.documents(EVENT_UID).findFirst({ filters, locale: 'en' }), + strapi.documents(EVENT_UID).findFirst({ filters, locale: 'ar' }), + ]); + + return { en: en ?? undefined, ar: ar ?? undefined }; +} + +/* --------------------------------------------------------------------------- + SENDING + ------------------------------------------------------------------------ */ + +type MailOptions = { + to: string; + subject: string; + text: string; + html?: string; +}; + +type EmailServiceShape = { send: (options: MailOptions) => Promise }; + +/** + * Send one e-mail through the nodemailer provider. Throws on failure — callers + * decide whether that should surface (reminders: caught per registration) or + * be swallowed (confirmation: never awaited). + */ +export async function sendMail( + strapi: Core.Strapi, + options: MailOptions, +): Promise { + const service = strapi.plugin('email').service('email'); + await service.send(options); +} + +/** A guarded send that logs instead of throwing. Returns true when it worked. */ +export async function trySendMail( + strapi: Core.Strapi, + options: MailOptions, +): Promise { + try { + await sendMail(strapi, options); + return true; + } catch (error) { + strapi.log.error( + `[event-mail] failed to send to ${options.to}: ${String(error)}`, + ); + return false; + } +} + +/* --------------------------------------------------------------------------- + CONFIRMATION + ------------------------------------------------------------------------ */ + +/** + * Extra line with the venue or the online link, when the event has one. Each + * locale's line uses that locale's row, falling back to the other row when a + * localized field is empty on one side. + */ +function locationLineFor(event: BilingualEvent): { ar: string; en: string } | undefined { + const format = event.en?.format; + const onlineUrl = event.en?.onlineUrl || event.ar?.onlineUrl; + const venue = { + en: event.en?.venue || event.ar?.venue || '', + ar: event.ar?.venue || event.en?.venue || '', + }; + + const isOnline = format === 'online' || (format === 'hybrid' && !venue.en); + if (isOnline && onlineUrl) { + return { + ar: `${JOIN_LABEL.ar}: ${onlineUrl}`, + en: `${JOIN_LABEL.en}: ${onlineUrl}`, + }; + } + if (venue.en || venue.ar) { + return { + ar: `${LOCATION_LABEL.ar}: ${venue.ar}`, + en: `${LOCATION_LABEL.en}: ${venue.en}`, + }; + } + return undefined; +} + +/** + * Build and send the confirmation (or waitlist) e-mail for a registration. + * Best-effort: logs failures, never throws into the request path. + */ +export async function sendRegistrationConfirmation({ + strapi, + to, + state, + eventReference, +}: { + strapi: Core.Strapi; + to: string; + state: RegistrationState; + eventReference: EventReference; +}): Promise { + try { + const event = await fetchBilingualEvent(strapi, eventReference); + + if (!event.en && !event.ar) { + strapi.log.warn( + '[event-mail] confirmation skipped: no published event for the registration.', + ); + return; + } + + const timeZone = event.en?.timezone || event.ar?.timezone || DEFAULT_TIMEZONE; + + const titleEn = event.en?.title || event.ar?.title || ''; + const titleAr = event.ar?.title || event.en?.title || ''; + + const dateEn = formatEventDateTime( + event.en?.startDateTime, + 'en', + timeZone, + ); + const dateAr = formatEventDateTime( + event.ar?.startDateTime ?? event.en?.startDateTime, + 'ar', + timeZone, + ); + + const isWaitlist = state === 'waitlisted'; + const subjectAr = isWaitlist ? WAITLIST_SUBJECT.ar : CONFIRMATION_SUBJECT.ar; + const subjectEn = isWaitlist ? WAITLIST_SUBJECT.en : CONFIRMATION_SUBJECT.en; + const bodyAr = isWaitlist ? WAITLIST_BODY.ar : CONFIRMATION_BODY.ar; + const bodyEn = isWaitlist ? WAITLIST_BODY.en : CONFIRMATION_BODY.en; + + const mail = buildBilingualMail({ + subjectAr, + subjectEn, + bodyAr: bodyAr + .replace(/\{event\}/g, titleAr) + .replace(/\{date\}/g, dateAr), + bodyEn: bodyEn.replace(/\{event\}/g, titleEn).replace(/\{date\}/g, dateEn), + locationLine: locationLineFor(event), + }); + + await trySendMail(strapi, { to, ...mail }); + } catch (error) { + strapi.log.error(`[event-mail] confirmation failed: ${String(error)}`); + } +} + +/* --------------------------------------------------------------------------- + REMINDER + ------------------------------------------------------------------------ */ + +/** Build and send the reminder e-mail for a single confirmed registration. */ +export async function sendEventReminderMail({ + strapi, + to, + eventReference, +}: { + strapi: Core.Strapi; + to: string; + eventReference: EventReference; +}): Promise { + const event = await fetchBilingualEvent(strapi, eventReference); + + if (!event.en && !event.ar) { + strapi.log.warn( + '[event-mail] reminder skipped: no published event for the registration.', + ); + return false; + } + + const timeZone = event.en?.timezone || event.ar?.timezone || DEFAULT_TIMEZONE; + + const titleEn = event.en?.title || event.ar?.title || ''; + const titleAr = event.ar?.title || event.en?.title || ''; + + const dateEn = formatEventDateTime(event.en?.startDateTime, 'en', timeZone); + const dateAr = formatEventDateTime( + event.ar?.startDateTime ?? event.en?.startDateTime, + 'ar', + timeZone, + ); + + const mail = buildBilingualMail({ + subjectAr: `${REMINDER_SUBJECT_PREFIX.ar}: ${titleAr}`, + subjectEn: `${REMINDER_SUBJECT_PREFIX.en}: ${titleEn}`, + bodyAr: REMINDER_BODY.ar + .replace(/\{event\}/g, titleAr) + .replace(/\{date\}/g, dateAr), + bodyEn: REMINDER_BODY.en.replace(/\{event\}/g, titleEn).replace(/\{date\}/g, dateEn), + locationLine: locationLineFor(event), + }); + + return trySendMail(strapi, { to, ...mail }); +} \ No newline at end of file diff --git a/apps/cms/src/lib/event-reminders.ts b/apps/cms/src/lib/event-reminders.ts new file mode 100644 index 0000000..1faa98d --- /dev/null +++ b/apps/cms/src/lib/event-reminders.ts @@ -0,0 +1,159 @@ +/** + * 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.`, + ); +} \ No newline at end of file diff --git a/apps/cms/types/generated/contentTypes.d.ts b/apps/cms/types/generated/contentTypes.d.ts index ffb33d3..1219892 100644 --- a/apps/cms/types/generated/contentTypes.d.ts +++ b/apps/cms/types/generated/contentTypes.d.ts @@ -1761,6 +1761,47 @@ export interface ApiRegionRegion extends Struct.CollectionTypeSchema { }; } +export interface ApiReminderSettingsReminderSettings + extends Struct.SingleTypeSchema { + collectionName: 'reminder_settings'; + info: { + description: 'Operational settings for the event reminder emails: whether they are enabled and how long before an event starts the reminder is sent. Read by the cron job only; not exposed over the public API.'; + displayName: 'Reminder Settings'; + pluralName: 'reminder-settings-list'; + singularName: 'reminder-settings'; + }; + options: { + draftAndPublish: false; + }; + attributes: { + createdAt: Schema.Attribute.DateTime; + createdBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + enabled: Schema.Attribute.Boolean & + Schema.Attribute.Required & + Schema.Attribute.DefaultTo; + leadHours: Schema.Attribute.Integer & + Schema.Attribute.Required & + Schema.Attribute.SetMinMax< + { + min: 0; + }, + number + > & + Schema.Attribute.DefaultTo<48>; + locale: Schema.Attribute.String & Schema.Attribute.Private; + localizations: Schema.Attribute.Relation< + 'oneToMany', + 'api::reminder-settings.reminder-settings' + > & + Schema.Attribute.Private; + publishedAt: Schema.Attribute.DateTime; + updatedAt: Schema.Attribute.DateTime; + updatedBy: Schema.Attribute.Relation<'oneToOne', 'admin::user'> & + Schema.Attribute.Private; + }; +} + export interface ApiResourceResource extends Struct.CollectionTypeSchema { collectionName: 'resources'; info: { @@ -2718,6 +2759,7 @@ declare module '@strapi/strapi' { 'api::opportunity.opportunity': ApiOpportunityOpportunity; 'api::partner.partner': ApiPartnerPartner; 'api::region.region': ApiRegionRegion; + 'api::reminder-settings.reminder-settings': ApiReminderSettingsReminderSettings; 'api::resource.resource': ApiResourceResource; 'api::site-settings.site-settings': ApiSiteSettingsSiteSettings; 'api::topic.topic': ApiTopicTopic;