feat: event registration emails and reminders (reminders untested)
This commit is contained in:
@@ -26,6 +26,8 @@ IS_PROXIED=false
|
|||||||
# Log verbosity: fatal | error | warn | info | http | debug | trace
|
# Log verbosity: fatal | error | warn | info | http | debug | trace
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
# Enable the Strapi cron runner (config/server.ts -> cron.enabled).
|
# 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
|
CRON_ENABLED=false
|
||||||
# Include relations in webhook payloads. Off by default: heavier payloads.
|
# Include relations in webhook payloads. Off by default: heavier payloads.
|
||||||
WEBHOOKS_POPULATE_RELATIONS=false
|
WEBHOOKS_POPULATE_RELATIONS=false
|
||||||
@@ -149,6 +151,8 @@ SMTP_USERNAME=your-username
|
|||||||
SMTP_PASSWORD=your-password
|
SMTP_PASSWORD=your-password
|
||||||
DEFAULT_FROM=no-reply@yourdomain.com
|
DEFAULT_FROM=no-reply@yourdomain.com
|
||||||
DEFAULT_REPLY_TO=support@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
|
# Production only - switching the upload provider to S3. Uncomment these after
|
||||||
# adding @strapi/provider-upload-aws-s3 and editing the upload block in
|
# adding @strapi/provider-upload-aws-s3 and editing the upload block in
|
||||||
|
|||||||
@@ -1,7 +1,64 @@
|
|||||||
/**
|
/**
|
||||||
* event-registration service
|
* 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';
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import { revokePublicAuthActions } from './auth-permissions';
|
|||||||
import { LOG_PREFIX } from './constants';
|
import { LOG_PREFIX } from './constants';
|
||||||
import { ensureLocales } from './locales';
|
import { ensureLocales } from './locales';
|
||||||
import { ensurePublicPermissions } from './public-permissions';
|
import { ensurePublicPermissions } from './public-permissions';
|
||||||
|
import { ensureReminderSettings } from './reminder-settings';
|
||||||
|
|
||||||
export { revokePublicAuthActions } from './auth-permissions';
|
export { revokePublicAuthActions } from './auth-permissions';
|
||||||
export { ensureLocales } from './locales';
|
export { ensureLocales } from './locales';
|
||||||
export { ensurePublicPermissions } from './public-permissions';
|
export { ensurePublicPermissions } from './public-permissions';
|
||||||
|
export { ensureReminderSettings } from './reminder-settings';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Boot-time reconciliation, run from `src/index.ts`.
|
* 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 ensureLocales(strapi);
|
||||||
await ensurePublicPermissions(strapi);
|
await ensurePublicPermissions(strapi);
|
||||||
await revokePublicAuthActions(strapi);
|
await revokePublicAuthActions(strapi);
|
||||||
|
await ensureReminderSettings(strapi);
|
||||||
|
|
||||||
strapi.log.debug(`${LOG_PREFIX} bootstrap complete.`);
|
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}).`
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Core } from '@strapi/strapi';
|
import type { Core } from '@strapi/strapi';
|
||||||
|
|
||||||
import { runBootstrap } from './bootstrap';
|
import { runBootstrap } from './bootstrap';
|
||||||
|
import { runEventReminders } from './lib/event-reminders';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
/**
|
/**
|
||||||
@@ -21,5 +22,19 @@ export default {
|
|||||||
*/
|
*/
|
||||||
async bootstrap({ strapi }: { strapi: Core.Strapi }) {
|
async bootstrap({ strapi }: { strapi: Core.Strapi }) {
|
||||||
await runBootstrap({ 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.');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
+42
@@ -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<true>;
|
||||||
|
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 {
|
export interface ApiResourceResource extends Struct.CollectionTypeSchema {
|
||||||
collectionName: 'resources';
|
collectionName: 'resources';
|
||||||
info: {
|
info: {
|
||||||
@@ -2718,6 +2759,7 @@ declare module '@strapi/strapi' {
|
|||||||
'api::opportunity.opportunity': ApiOpportunityOpportunity;
|
'api::opportunity.opportunity': ApiOpportunityOpportunity;
|
||||||
'api::partner.partner': ApiPartnerPartner;
|
'api::partner.partner': ApiPartnerPartner;
|
||||||
'api::region.region': ApiRegionRegion;
|
'api::region.region': ApiRegionRegion;
|
||||||
|
'api::reminder-settings.reminder-settings': ApiReminderSettingsReminderSettings;
|
||||||
'api::resource.resource': ApiResourceResource;
|
'api::resource.resource': ApiResourceResource;
|
||||||
'api::site-settings.site-settings': ApiSiteSettingsSiteSettings;
|
'api::site-settings.site-settings': ApiSiteSettingsSiteSettings;
|
||||||
'api::topic.topic': ApiTopicTopic;
|
'api::topic.topic': ApiTopicTopic;
|
||||||
|
|||||||
Reference in New Issue
Block a user