feat: event registration emails and reminders (reminders untested)

This commit is contained in:
2026-09-24 00:55:47 +02:00
parent 1879a206ad
commit e5bce55bf7
9 changed files with 757 additions and 1 deletions
@@ -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<string, unknown>) {
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;
},
}));
@@ -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
}
}
}
+3
View File
@@ -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.`);
};
@@ -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<void> => {
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}).`
);
};
+15
View File
@@ -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.');
}
},
};
+410
View File
@@ -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) => `<p>${paragraph.trim()}</p>`)
.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 <head> 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 = `
<div dir="rtl" lang="ar" style="${BASE_FONT} line-height:1.6; color:#222;">
${wrapParagraphs(bodyAr)}
</div>
<hr style="border:none; border-top:1px solid #ddd; margin:24px 0;" />
<div dir="ltr" lang="en" style="${BASE_FONT} line-height:1.6; color:#222;">
${wrapParagraphs(bodyEn)}
</div>
<p style="margin-top:32px; font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}">
${FOOTER.ar}
</p>
<p style="font-size:12px; line-height:1.5; color:#666; ${BASE_FONT}">
${FOOTER.en}
</p>`;
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<BilingualEvent> {
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<unknown> };
/**
* 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<void> {
const service = strapi.plugin('email').service<EmailServiceShape>('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<boolean> {
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<void> {
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<boolean> {
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 });
}
+159
View File
@@ -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<ReminderSettings> {
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<void> {
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.`,
);
}