feat: event registration emails and reminders (reminders untested)
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
@@ -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.`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user