fix: FIFO queue for waitlisting; send email for application form; EU logo in contact page in light mode

This commit is contained in:
2026-09-24 11:51:24 +02:00
parent e5bce55bf7
commit eba419bfc2
24 changed files with 762 additions and 244 deletions
@@ -13,7 +13,7 @@
"attributes": {
"opportunity": {
"type": "relation",
"relation": "oneToOne",
"relation": "manyToOne",
"target": "api::opportunity.opportunity"
},
"applicantName": {
@@ -1,7 +1,68 @@
/**
* application service
*
* `create` is overridden to fire the acknowledgement e-mail *after* the row is
* written, mirroring the event-registration service. The send is deliberately
* not awaited: a slow or failing SMTP call must never turn the application
* POST into a 502. The mail helper stamps `acknowledgementSentAt` only when
* the provider accepted the mail, and swallows and logs its own errors.
*/
import { factories } from '@strapi/strapi';
export default factories.createCoreService('api::application.application');
import { sendApplicationAcknowledgement } from '../../../lib/application-mail';
const APPLICATIONS_UID = 'api::application.application';
const LOG_PREFIX = '[application]';
/**
* The `opportunity` value a client may have sent is a documentId (what the
* REST API uses for relations), or a legacy numeric id. The Get Involved form
* sends none at all - an application is not always filed against a call.
*/
const readOpportunityReference = (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): { documentId?: unknown; email?: unknown } =>
(typeof result === 'object' && result !== null ? result : {}) as {
documentId?: unknown;
email?: unknown;
};
export default factories.createCoreService(APPLICATIONS_UID, ({ strapi }) => ({
async create(params: { data?: { opportunity?: unknown } } & Record<string, unknown>) {
const result = await super.create(params);
const { documentId, email } = readCreatedResult(result);
if (typeof documentId === 'string' && documentId.length > 0) {
if (typeof email === 'string' && email.length > 0) {
void sendApplicationAcknowledgement({
strapi,
to: email,
applicationDocumentId: documentId,
opportunityReference: readOpportunityReference(params.data?.opportunity),
});
} else {
strapi.log.warn(
`${LOG_PREFIX} no email on the created application; acknowledgement skipped.`,
);
}
} else {
strapi.log.warn(
`${LOG_PREFIX} no document id on the created application; acknowledgement skipped.`,
);
}
return result;
},
}));
@@ -13,7 +13,7 @@
"attributes": {
"event": {
"type": "relation",
"relation": "oneToOne",
"relation": "manyToOne",
"target": "api::event.event"
},
"fullName": {
@@ -21,10 +21,8 @@
* `curl`, and the count it depends on - how many places are already taken - is
* not something a client can be trusted to report.
*
* The count is read and the row is written without a transaction, so two
* registrations arriving in the same instant can both see the last free place.
* Overbooking by the number of concurrent requests is the accepted trade-off
* for a public form; staff see the overflow in the admin panel.
* A transaction locks the event row while the confirmed count and registration
* write run, so concurrent requests cannot claim the same final place.
*/
import type { Core } from '@strapi/strapi';
@@ -50,6 +48,7 @@ type EventCapacity = {
capacity: number | null;
waitlistEnabled: boolean | null;
registrationStatus: string | null;
registrationDeadline: string | Date | null;
};
/**
@@ -89,9 +88,9 @@ const findEvent = async (
? { documentId: reference.documentId }
: { id: reference.id };
return (await strapi.db.query(EVENT_UID).findOne({
where,
select: ['documentId', 'capacity', 'waitlistEnabled', 'registrationStatus'],
return (await strapi.documents(EVENT_UID).findFirst({
filters: where,
status: 'published',
})) as EventCapacity | null;
};
@@ -121,13 +120,22 @@ const resolveRegistrationState = async (
const event = await findEvent(strapi, readEventReference(eventValue));
if (!event) {
// Either no event was given or the id does not resolve. There is no
// capacity to weigh, and nothing to confirm a place on.
return 'waitlisted';
throw new errors.ApplicationError('A valid published event is required.');
}
if (event.registrationStatus === 'closed') {
throw new errors.ApplicationError('Registration for this event is closed.');
if (
event.registrationStatus !== 'open' &&
event.registrationStatus !== 'full' &&
event.registrationStatus !== 'waitlist'
) {
throw new errors.ApplicationError('Registration for this event is not open.');
}
if (event.registrationDeadline) {
const deadline = new Date(event.registrationDeadline).getTime();
if (!Number.isFinite(deadline) || Date.now() >= deadline) {
throw new errors.ApplicationError('The registration deadline has passed.');
}
}
const capacity =
@@ -168,13 +176,28 @@ const sanitizeSubmission: Core.MiddlewareFactory =
}
}
data.registrationState = await resolveRegistrationState(strapi, data.event);
data.submittedAt = new Date().toISOString();
data.reminderSentAt = null;
await strapi.db.transaction(async ({ trx }) => {
const event = await findEvent(strapi, readEventReference(data.event));
if (!event) {
throw new errors.ApplicationError('A valid published event is required.');
}
request.body = { data };
await trx('events')
.select('id')
.where({ document_id: event.documentId })
.orderBy('id')
.forUpdate()
.first();
await next();
data.registrationState = await resolveRegistrationState(strapi, {
documentId: event.documentId,
});
data.submittedAt = new Date().toISOString();
data.reminderSentAt = null;
request.body = { data };
await next();
});
};
export default sanitizeSubmission;
+19 -16
View File
@@ -35,11 +35,9 @@ const desiredActionsFor = (controllerName: string): string[] =>
* Grant the Public role read access to the public content (collection types and
* single types) and create access to the three submission content types.
*
* Only missing permissions are inserted, and permissions granted by hand in the
* admin panel are never removed - so this is safe to run on every boot. A read
* grant on a submission type is reported as a warning rather than deleted, so
* that an unexpected grant is visible without the boot silently undoing an
* administrator's decision.
* Missing public-content permissions are inserted. Submission types are kept
* create-only: any other public grant is revoked on boot because those entries
* contain personal data.
*/
export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void> => {
if (!strapi.plugins['users-permissions']) {
@@ -63,7 +61,7 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
.getActions();
const desiredActions = new Set<string>();
const forbiddenReadActions = new Set<string>();
const forbiddenSubmissionActions = new Set<string>();
for (const [namespace, { controllers }] of Object.entries(actionMap)) {
// Only the project's own APIs; plugin permissions are managed by Strapi.
@@ -80,11 +78,8 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
if (wantedActions.includes(action)) {
desiredActions.add(permission);
} else if (
PUBLIC_SUBMISSION_CONTENT_TYPES.includes(controllerName) &&
PUBLIC_READ_ACTIONS.includes(action)
) {
forbiddenReadActions.add(permission);
} else if (PUBLIC_SUBMISSION_CONTENT_TYPES.includes(controllerName)) {
forbiddenSubmissionActions.add(permission);
}
}
}
@@ -94,7 +89,7 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
.query('plugin::users-permissions.permission')
.findMany({
where: { role: { id: publicRole.id } },
select: ['action'],
select: ['id', 'action'],
});
const existingActions = new Set(existingPermissions.map(({ action }) => action));
@@ -112,12 +107,20 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
);
}
const leakedActions = [...forbiddenReadActions].filter((action) => existingActions.has(action));
const forbiddenPermissions = existingPermissions.filter(({ action }) =>
forbiddenSubmissionActions.has(action)
);
if (forbiddenPermissions.length > 0) {
await strapi.db.query('plugin::users-permissions.permission').deleteMany({
where: { id: { $in: forbiddenPermissions.map(({ id }) => id) } },
});
if (leakedActions.length > 0) {
strapi.log.warn(
`${LOG_PREFIX} the Public role can read submission data: ${leakedActions.join(', ')}. ` +
'Revoke these in Settings > Roles > Public unless this is intentional.'
`${LOG_PREFIX} revoked forbidden Public submission permission(s): ${forbiddenPermissions
.map(({ action }) => action)
.sort()
.join(', ')}.`
);
}
};
+6 -1
View File
@@ -2,6 +2,7 @@ import type { Core } from '@strapi/strapi';
import { runBootstrap } from './bootstrap';
import { runEventReminders } from './lib/event-reminders';
import { runWaitlistPromotion } from './lib/waitlist-promotion';
export default {
/**
@@ -33,8 +34,12 @@ export default {
task: runEventReminders,
options: { rule: '0 * * * *', tz: 'Africa/Tripoli' },
},
'run-waitlist-promotion': {
task: runWaitlistPromotion,
options: { rule: '0 * * * *', tz: 'Africa/Tripoli' },
},
});
strapi.log.info('[index] event reminder cron job registered.');
strapi.log.info('[index] event reminder + waitlist promotion cron jobs registered.');
}
},
};
+134
View File
@@ -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)}`);
}
}
+19 -4
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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;
}
}
+203
View File
@@ -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).`,
);
}