fix: FIFO queue for waitlisting; send email for application form; EU logo in contact page in light mode
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* application-mail — bilingual acknowledgement e-mail for applications.
|
||||
*
|
||||
* The web copy promises every application is "acknowledged automatically by
|
||||
* email" (apps/web/app/routes/_events-opportunities/copy.ts:
|
||||
* `apply.acknowledgement` / `apply.receivedBody`), and the content type models
|
||||
* that with `application.acknowledgementSentAt`. This module is the half that
|
||||
* delivers: fired fire-and-forget from the application service's `create`
|
||||
* override, mirroring the event-registration confirmation, and stamping
|
||||
* `acknowledgementSentAt` ONLY after the mail was accepted, so a failure is
|
||||
* retried by nothing today but is visible in the admin panel as a null.
|
||||
*
|
||||
* The (Get Involved) section form can submit an application with no
|
||||
* opportunity relation. The mail then drops the call name and stays a plain
|
||||
* acknowledgement, so the promise holds for both entry points.
|
||||
*
|
||||
* Copy is derived from the approved web copy; the footer is the same
|
||||
* EU-visibility block the event mails use. Nothing here is invented.
|
||||
*/
|
||||
|
||||
import type { Core } from '@strapi/strapi';
|
||||
|
||||
import { buildBilingualMail, trySendMail } from './event-mail';
|
||||
|
||||
const OPPORTUNITY_UID = 'api::opportunity.opportunity';
|
||||
const APPLICATION_UID = 'api::application.application';
|
||||
|
||||
const LOG_PREFIX = '[application-mail]';
|
||||
|
||||
/** Identifier a public form may have sent for the opportunity relation. */
|
||||
type OpportunityReference = { documentId?: string; id?: number } | undefined;
|
||||
|
||||
/** An opportunity looked up in both locales; missing locales are allowed. */
|
||||
type BilingualOpportunity = { en?: { title?: string | null }; ar?: { title?: string | null } };
|
||||
|
||||
const ACKNOWLEDGEMENT_SUBJECT = {
|
||||
ar: 'تم استلام الطلب',
|
||||
en: 'Application received',
|
||||
} as const;
|
||||
|
||||
/*
|
||||
* The approved wording this mirrors, from
|
||||
* apps/web/app/routes/_events-opportunities/copy.ts:
|
||||
* applicationReceivedBody en: "Thank you for your application. Our team
|
||||
* will review it and follow up by email."
|
||||
* ar: "شكرًا لتقديم طلبك. سيراجعه فريقنا ويتواصل
|
||||
* معك عبر البريد الإلكتروني."
|
||||
* receivedBody ar: "شكرًا لتقديمك على {route}..." (the "على"
|
||||
* preposition is kept for the call name).
|
||||
*/
|
||||
const ACKNOWLEDGEMENT_BODY_WITH_CALL = {
|
||||
ar: 'شكرًا لك — استلمنا طلبك على {call}. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
|
||||
en: 'Thank you — we have received your application for {call}. Our team will review it and follow up by email.',
|
||||
} as const;
|
||||
|
||||
const ACKNOWLEDGEMENT_BODY = {
|
||||
ar: 'شكرًا لك — استلمنا طلبك. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
|
||||
en: 'Thank you — we have received your application. Our team will review it and follow up by email.',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Fetch an opportunity in both locales. Only published entries are ever
|
||||
* fetched; either a documentId or a legacy numeric id is accepted, mirroring
|
||||
* the event-registration middleware. Returns an empty record when there is no
|
||||
* usable reference (the Get Involved form sends none).
|
||||
*/
|
||||
export async function fetchBilingualOpportunity(
|
||||
strapi: Core.Strapi,
|
||||
reference: OpportunityReference,
|
||||
): Promise<BilingualOpportunity> {
|
||||
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(OPPORTUNITY_UID).findFirst({ filters, locale: 'en' }),
|
||||
strapi.documents(OPPORTUNITY_UID).findFirst({ filters, locale: 'ar' }),
|
||||
]);
|
||||
|
||||
return { en: en ?? undefined, ar: ar ?? undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build and send the acknowledgement for an application, and stamp
|
||||
* `acknowledgementSentAt` when the provider accepted it. Best-effort and
|
||||
* never awaited by the request path: a slow or failing SMTP call must not make
|
||||
* the application POST fail.
|
||||
*/
|
||||
export async function sendApplicationAcknowledgement({
|
||||
strapi,
|
||||
to,
|
||||
applicationDocumentId,
|
||||
opportunityReference,
|
||||
}: {
|
||||
strapi: Core.Strapi;
|
||||
to: string;
|
||||
applicationDocumentId: string;
|
||||
opportunityReference: OpportunityReference;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const opportunity = await fetchBilingualOpportunity(strapi, opportunityReference);
|
||||
|
||||
const callEn = opportunity.en?.title || undefined;
|
||||
const callAr = opportunity.ar?.title || opportunity.en?.title || undefined;
|
||||
|
||||
const mail = buildBilingualMail({
|
||||
subjectAr: ACKNOWLEDGEMENT_SUBJECT.ar,
|
||||
subjectEn: ACKNOWLEDGEMENT_SUBJECT.en,
|
||||
bodyAr: (callAr ? ACKNOWLEDGEMENT_BODY_WITH_CALL.ar : ACKNOWLEDGEMENT_BODY.ar)
|
||||
.replace(/\{call\}/g, callAr ?? ''),
|
||||
bodyEn: (callEn ? ACKNOWLEDGEMENT_BODY_WITH_CALL.en : ACKNOWLEDGEMENT_BODY.en)
|
||||
.replace(/\{call\}/g, callEn ?? ''),
|
||||
});
|
||||
|
||||
const ok = await trySendMail(strapi, { to, ...mail });
|
||||
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
await strapi
|
||||
.documents(APPLICATION_UID)
|
||||
.update({
|
||||
documentId: applicationDocumentId,
|
||||
data: { acknowledgementSentAt: new Date().toISOString() },
|
||||
});
|
||||
} catch (error) {
|
||||
strapi.log.error(`${LOG_PREFIX} acknowledgement failed: ${String(error)}`);
|
||||
}
|
||||
}
|
||||
@@ -136,11 +136,20 @@ const FOOTER = {
|
||||
const BASE_FONT =
|
||||
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;";
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function wrapParagraphs(text: string): string {
|
||||
return text
|
||||
.trim()
|
||||
.split(/\n\n+/)
|
||||
.map((paragraph) => `<p>${paragraph.trim()}</p>`)
|
||||
.map((paragraph) => `<p>${escapeHtml(paragraph.trim())}</p>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
@@ -301,6 +310,11 @@ function locationLineFor(event: BilingualEvent): { ar: string; en: string } | un
|
||||
/**
|
||||
* Build and send the confirmation (or waitlist) e-mail for a registration.
|
||||
* Best-effort: logs failures, never throws into the request path.
|
||||
*
|
||||
* Returns true when the mail was accepted by the provider. The waitlist
|
||||
* promotion cron uses this to only flip a registration to `confirmed` AFTER
|
||||
* the "your place is now confirmed" mail went out (at-least-once, like the
|
||||
* reminder job).
|
||||
*/
|
||||
export async function sendRegistrationConfirmation({
|
||||
strapi,
|
||||
@@ -312,7 +326,7 @@ export async function sendRegistrationConfirmation({
|
||||
to: string;
|
||||
state: RegistrationState;
|
||||
eventReference: EventReference;
|
||||
}): Promise<void> {
|
||||
}): Promise<boolean> {
|
||||
try {
|
||||
const event = await fetchBilingualEvent(strapi, eventReference);
|
||||
|
||||
@@ -320,7 +334,7 @@ export async function sendRegistrationConfirmation({
|
||||
strapi.log.warn(
|
||||
'[event-mail] confirmation skipped: no published event for the registration.',
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
const timeZone = event.en?.timezone || event.ar?.timezone || DEFAULT_TIMEZONE;
|
||||
@@ -355,9 +369,10 @@ export async function sendRegistrationConfirmation({
|
||||
locationLine: locationLineFor(event),
|
||||
});
|
||||
|
||||
await trySendMail(strapi, { to, ...mail });
|
||||
return trySendMail(strapi, { to, ...mail });
|
||||
} catch (error) {
|
||||
strapi.log.error(`[event-mail] confirmation failed: ${String(error)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* waitlist-promotion — the cron task that honours the "we will email you if a
|
||||
* place becomes available" promise.
|
||||
*
|
||||
* Registrations are stamped `confirmed` OR `waitlisted` at signup
|
||||
* (event-registration/middlewares/sanitize-submission.ts). Nothing ever moved
|
||||
* a `waitlisted` row to `confirmed` when a seat freed up, so the waitlist was
|
||||
* a queue nobody drained. This job does it, first-come-first-served:
|
||||
*
|
||||
* 1. find every event that still has `waitlisted` registrations;
|
||||
* 2. for each, lock the event row (the same serialisation the registration
|
||||
* middleware uses, so a concurrent POST cannot race the count) and
|
||||
* compute how many seats are left below capacity;
|
||||
* 3. promote the OLDEST waitlisted registrations (`submittedAt` asc) into
|
||||
* those seats, sending the standard confirmation mail first and flipping
|
||||
* the row to `confirmed` ONLY after the mail was accepted.
|
||||
*
|
||||
* Semantics are at-least-once, like the reminder job: a row whose mail failed
|
||||
* stays `waitlisted` and is retried on the next run. A waitlisted row with no
|
||||
* email cannot be told anything, so it is left as-is for staff to deal with.
|
||||
*
|
||||
* Registered from src/index.ts only when `server.cron.enabled` is true.
|
||||
*/
|
||||
|
||||
import type { Core } from '@strapi/strapi';
|
||||
|
||||
import { sendRegistrationConfirmation } from './event-mail';
|
||||
|
||||
const EVENT_UID = 'api::event.event';
|
||||
const REGISTRATION_UID = 'api::event-registration.event-registration';
|
||||
|
||||
const LOG_PREFIX = '[waitlist-promotion]';
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
/** The subset of the event that decides how many seats can be promoted into. */
|
||||
type EventCapacity = {
|
||||
documentId: string;
|
||||
capacity: number | null;
|
||||
};
|
||||
|
||||
/** How many places on this event are already taken, mirroring the middleware. */
|
||||
const countConfirmed = async (strapi: Core.Strapi, documentId: string): Promise<number> =>
|
||||
strapi.db.query(REGISTRATION_UID).count({
|
||||
where: {
|
||||
event: { documentId },
|
||||
registrationState: 'confirmed',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Promote the oldest waiting registrants of ONE event into its free seats.
|
||||
*
|
||||
* The lock mirrors `sanitize-submission`: registering and promoting both
|
||||
* serialize on the event row, so a concurrent request cannot steal a seat the
|
||||
* promotion just counted. Count and updates run inside the same transaction.
|
||||
*/
|
||||
async function promoteEvent(
|
||||
strapi: Core.Strapi,
|
||||
eventDocumentId: string,
|
||||
): Promise<{ promoted: number; failed: number }> {
|
||||
return strapi.db.transaction(async ({ trx }) => {
|
||||
await trx('events')
|
||||
.select('id')
|
||||
.where({ document_id: eventDocumentId })
|
||||
.orderBy('id')
|
||||
.forUpdate()
|
||||
.first();
|
||||
|
||||
const event = (await strapi.documents(EVENT_UID).findFirst({
|
||||
filters: { documentId: eventDocumentId },
|
||||
status: 'published',
|
||||
})) as EventCapacity | null;
|
||||
|
||||
if (!event) {
|
||||
return { promoted: 0, failed: 0 };
|
||||
}
|
||||
|
||||
const capacity =
|
||||
typeof event.capacity === 'number' && event.capacity > 0 ? event.capacity : null;
|
||||
|
||||
// No finite capacity means there is never a seat to free up.
|
||||
if (capacity === null) {
|
||||
return { promoted: 0, failed: 0 };
|
||||
}
|
||||
|
||||
const taken = await countConfirmed(strapi, eventDocumentId);
|
||||
let slots = capacity - taken;
|
||||
if (slots <= 0) {
|
||||
return { promoted: 0, failed: 0 };
|
||||
}
|
||||
|
||||
// Oldest waiters first; the page size is a safety bound, the loop stops
|
||||
// at `slots` anyway.
|
||||
const registrations = await strapi
|
||||
.documents(REGISTRATION_UID)
|
||||
.findMany({
|
||||
filters: {
|
||||
event: { documentId: eventDocumentId },
|
||||
registrationState: { $eq: 'waitlisted' },
|
||||
},
|
||||
sort: 'submittedAt:asc',
|
||||
pagination: { page: 1, pageSize: PAGE_SIZE },
|
||||
});
|
||||
|
||||
let promoted = 0;
|
||||
let failed = 0;
|
||||
|
||||
for (const registration of registrations) {
|
||||
if (slots <= 0) break;
|
||||
|
||||
const email = (registration as { email?: unknown }).email;
|
||||
if (typeof email !== 'string' || email.length === 0) {
|
||||
strapi.log.warn(
|
||||
`${LOG_PREFIX} registration ${registration.documentId} has no email; left waitlisted.`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const ok = await sendRegistrationConfirmation({
|
||||
strapi,
|
||||
to: email,
|
||||
state: 'confirmed',
|
||||
eventReference: { documentId: eventDocumentId },
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
failed += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
await strapi
|
||||
.documents(REGISTRATION_UID)
|
||||
.update({
|
||||
documentId: registration.documentId as string,
|
||||
data: { registrationState: 'confirmed' },
|
||||
});
|
||||
|
||||
promoted += 1;
|
||||
slots -= 1;
|
||||
}
|
||||
|
||||
return { promoted, failed };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run one pass over every event that still has waitlisted registrations.
|
||||
* One failing event never stops the batch; totals are logged.
|
||||
*/
|
||||
export async function runWaitlistPromotion({
|
||||
strapi,
|
||||
}: {
|
||||
strapi: Core.Strapi;
|
||||
}): Promise<void> {
|
||||
const eventIds = new Set<string>();
|
||||
|
||||
let page = 1;
|
||||
while (true) {
|
||||
const registrations = await strapi
|
||||
.documents(REGISTRATION_UID)
|
||||
.findMany({
|
||||
filters: { registrationState: { $eq: 'waitlisted' } },
|
||||
populate: { event: true },
|
||||
pagination: { page, pageSize: PAGE_SIZE },
|
||||
});
|
||||
|
||||
for (const registration of registrations) {
|
||||
const event = (registration as { event?: { documentId?: unknown } }).event;
|
||||
if (typeof event?.documentId === 'string' && event.documentId.length > 0) {
|
||||
eventIds.add(event.documentId);
|
||||
}
|
||||
}
|
||||
|
||||
if (registrations.length < PAGE_SIZE) break;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
if (eventIds.size === 0) {
|
||||
strapi.log.info(`${LOG_PREFIX} no events with a waitlist, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let promotedTotal = 0;
|
||||
let failedTotal = 0;
|
||||
|
||||
for (const eventId of eventIds) {
|
||||
try {
|
||||
const { promoted, failed } = await promoteEvent(strapi, eventId);
|
||||
promotedTotal += promoted;
|
||||
failedTotal += failed;
|
||||
} catch (error) {
|
||||
failedTotal += 1;
|
||||
strapi.log.error(
|
||||
`${LOG_PREFIX} failed for event ${eventId}: ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
strapi.log.info(
|
||||
`${LOG_PREFIX} finished: ${promotedTotal} waitlisted registration(s) promoted, ${failedTotal} mail(s) failed (retried next run).`,
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user