fix: FIFO queue for waitlisting; send email for application form; EU logo in contact page in light mode
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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(', ')}.`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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).`,
|
||||
);
|
||||
}
|
||||
+2
-2
@@ -707,7 +707,7 @@ export interface ApiApplicationApplication extends Struct.CollectionTypeSchema {
|
||||
> &
|
||||
Schema.Attribute.Private;
|
||||
opportunity: Schema.Attribute.Relation<
|
||||
'oneToOne',
|
||||
'manyToOne',
|
||||
'api::opportunity.opportunity'
|
||||
>;
|
||||
organisation: Schema.Attribute.String &
|
||||
@@ -871,7 +871,7 @@ export interface ApiEventRegistrationEventRegistration
|
||||
Schema.Attribute.Private;
|
||||
dietaryOrAccess: Schema.Attribute.Text;
|
||||
email: Schema.Attribute.Email & Schema.Attribute.Required;
|
||||
event: Schema.Attribute.Relation<'oneToOne', 'api::event.event'>;
|
||||
event: Schema.Attribute.Relation<'manyToOne', 'api::event.event'>;
|
||||
fullName: Schema.Attribute.String &
|
||||
Schema.Attribute.Required &
|
||||
Schema.Attribute.SetMinMaxLength<{
|
||||
|
||||
@@ -120,8 +120,8 @@ export function resetServerEnv(): void {
|
||||
* the incoming request is trusted, honouring the standard forwarding headers.
|
||||
*/
|
||||
export function getSiteUrl(request: Request): string {
|
||||
const { SITE_URL } = getServerEnv();
|
||||
if (process.env.SITE_URL?.trim()) return SITE_URL;
|
||||
const env = getServerEnv();
|
||||
if (process.env.SITE_URL?.trim() || env.isProduction) return env.SITE_URL;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const forwardedHost =
|
||||
|
||||
@@ -114,9 +114,8 @@ const EN = {
|
||||
uploaded here (see `documentsNote`), so no limit is quoted at all.
|
||||
* "an automatic acknowledgment with your reference number" - there is no
|
||||
reference-number field anywhere in the CMS, so no number is promised.
|
||||
The acknowledgement itself IS modelled, as
|
||||
`application.acknowledgementSentAt`, so it is the only half that
|
||||
survives.
|
||||
The acknowledgement itself IS sent: the CMS mails it on submission
|
||||
and stamps `application.acknowledgementSentAt` when it went out.
|
||||
* "follows up within four weeks" - that response time appears in no
|
||||
approved document; §8 of the draft marks it "[X weeks] - TBD". The
|
||||
follow-up is stated without a period attached, exactly as the per-call
|
||||
@@ -168,7 +167,7 @@ const EN = {
|
||||
*/
|
||||
documentsNote:
|
||||
"Documents are not uploaded through this form. Describe any supporting material in the summary above.",
|
||||
/** `application.acknowledgementSentAt` - modelled, so it can be said. */
|
||||
/** `application.acknowledgementSentAt` - sent, not just modelled. */
|
||||
acknowledgement: "Your submission is acknowledged automatically by email.",
|
||||
submit: "Submit application",
|
||||
errorSummaryTitle: "Please check the form",
|
||||
|
||||
@@ -147,6 +147,7 @@ function toCard(entry: StrapiEvent, now: Date): EventCardData {
|
||||
registrationStatus:
|
||||
oneOf(entry.registrationStatus, EVENT_REGISTRATION_STATUSES) ??
|
||||
"not-required",
|
||||
registrationDeadline: text(entry.registrationDeadline),
|
||||
timeStatus: deriveTimeStatus({ start, end, allDay }, now),
|
||||
recapUrl: text(entry.recapUrl),
|
||||
image: toImage(entry.coverImage, "medium"),
|
||||
|
||||
@@ -114,6 +114,7 @@ export interface EventCardData {
|
||||
/** Name of the related region entry, not a free-text string. */
|
||||
readonly region?: string;
|
||||
readonly registrationStatus: EventRegistrationStatus;
|
||||
readonly registrationDeadline?: string;
|
||||
readonly timeStatus: EventTimeStatus;
|
||||
/** Set on a past event that has a write-up - drives the "Read recap" label. */
|
||||
readonly recapUrl?: string;
|
||||
@@ -126,7 +127,6 @@ export interface EventDetailData extends EventCardData {
|
||||
/** The long body. There is no external registration portal field in the CMS. */
|
||||
readonly description?: CmsRichText;
|
||||
readonly onlineUrl?: string;
|
||||
readonly registrationDeadline?: string;
|
||||
readonly venue?: string;
|
||||
/** Editorial: the CMS offers a waitlist once the event is full. */
|
||||
readonly waitlistEnabled: boolean;
|
||||
@@ -330,8 +330,16 @@ export function collectRegions(
|
||||
* reader simply turns up, so there is nothing to submit; "closed" speaks for
|
||||
* itself; "full" and "waitlist" still take names for the waiting list.
|
||||
*/
|
||||
export function isRegistrationOpen(event: EventCardData): boolean {
|
||||
export function isRegistrationOpen(
|
||||
event: EventCardData,
|
||||
now = Date.now(),
|
||||
): boolean {
|
||||
const deadline = event.registrationDeadline
|
||||
? Date.parse(event.registrationDeadline)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
(deadline === undefined || (Number.isFinite(deadline) && now < deadline)) &&
|
||||
event.timeStatus !== "completed" &&
|
||||
(event.registrationStatus === "open" ||
|
||||
event.registrationStatus === "full" ||
|
||||
|
||||
@@ -19,6 +19,7 @@ const DEFAULT_TIMEOUT_MS = 10_000;
|
||||
export interface StrapiWriteInit {
|
||||
readonly timeoutMs?: number;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly forwardedFor?: string;
|
||||
}
|
||||
|
||||
interface StrapiErrorEnvelope {
|
||||
@@ -49,6 +50,7 @@ export async function createStrapiEntry<T>(
|
||||
accept: "application/json",
|
||||
};
|
||||
if (env.STRAPI_TOKEN) headers.authorization = `Bearer ${env.STRAPI_TOKEN}`;
|
||||
if (init.forwardedFor) headers["x-forwarded-for"] = init.forwardedFor;
|
||||
|
||||
const timeoutMs = init.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
||||
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
||||
|
||||
@@ -68,26 +68,43 @@ export interface RegistrationSubmission {
|
||||
readonly values: RegistrationValues;
|
||||
/** documentId of the event being registered for. */
|
||||
readonly eventId: string;
|
||||
readonly forwardedFor?: string;
|
||||
}
|
||||
|
||||
export async function submitRegistration(
|
||||
input: RegistrationSubmission,
|
||||
): Promise<void> {
|
||||
await createStrapiEntry(EVENT_REGISTRATIONS_RESOURCE, {
|
||||
event: input.eventId,
|
||||
fullName: input.values.fullName,
|
||||
email: input.values.email,
|
||||
phone: optional(input.values.phone),
|
||||
organisation: optional(input.values.organisation),
|
||||
// Required in the schema: without it every POST is a 400.
|
||||
consentGdpr: input.values.consentGdpr,
|
||||
});
|
||||
): Promise<"confirmed" | "waitlisted"> {
|
||||
const created = await createStrapiEntry<{
|
||||
readonly registrationState?: unknown;
|
||||
}>(
|
||||
EVENT_REGISTRATIONS_RESOURCE,
|
||||
{
|
||||
event: input.eventId,
|
||||
fullName: input.values.fullName,
|
||||
email: input.values.email,
|
||||
phone: optional(input.values.phone),
|
||||
organisation: optional(input.values.organisation),
|
||||
// Required in the schema: without it every POST is a 400.
|
||||
consentGdpr: input.values.consentGdpr,
|
||||
},
|
||||
{ forwardedFor: input.forwardedFor },
|
||||
);
|
||||
|
||||
if (
|
||||
created?.registrationState !== "confirmed" &&
|
||||
created?.registrationState !== "waitlisted"
|
||||
) {
|
||||
throw new Error("Strapi returned an invalid registration state.");
|
||||
}
|
||||
|
||||
return created.registrationState;
|
||||
}
|
||||
|
||||
export interface ApplicationSubmission {
|
||||
readonly values: ApplicationValues;
|
||||
/** documentId of the opportunity being applied to. */
|
||||
readonly opportunityId: string;
|
||||
readonly forwardedFor?: string;
|
||||
}
|
||||
|
||||
/** The key the free-text answer is filed under inside `answers` (json). */
|
||||
@@ -96,17 +113,21 @@ export const APPLICATION_MESSAGE_KEY = "message";
|
||||
export async function submitApplication(
|
||||
input: ApplicationSubmission,
|
||||
): Promise<void> {
|
||||
await createStrapiEntry(APPLICATIONS_RESOURCE, {
|
||||
opportunity: input.opportunityId,
|
||||
// `fullName` is the registration's field name; an application's is
|
||||
// `applicantName`.
|
||||
applicantName: input.values.fullName,
|
||||
email: input.values.email,
|
||||
organisation: optional(input.values.organisation),
|
||||
// `answers` is a json field - the form's one free-text question goes in
|
||||
// it, keyed, so more questions can be added without another migration.
|
||||
answers: { [APPLICATION_MESSAGE_KEY]: input.values.message },
|
||||
});
|
||||
await createStrapiEntry(
|
||||
APPLICATIONS_RESOURCE,
|
||||
{
|
||||
opportunity: input.opportunityId,
|
||||
// `fullName` is the registration's field name; an application's is
|
||||
// `applicantName`.
|
||||
applicantName: input.values.fullName,
|
||||
email: input.values.email,
|
||||
organisation: optional(input.values.organisation),
|
||||
// `answers` is a json field - the form's one free-text question goes in
|
||||
// it, keyed, so more questions can be added without another migration.
|
||||
answers: { [APPLICATION_MESSAGE_KEY]: input.values.message },
|
||||
},
|
||||
{ forwardedFor: input.forwardedFor },
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
@@ -134,6 +155,7 @@ export const APPLICATION_ANSWER_KEYS = {
|
||||
|
||||
export interface PathwayApplicationSubmission {
|
||||
readonly values: PathwayApplicationValues;
|
||||
readonly forwardedFor?: string;
|
||||
/**
|
||||
* documentId of the opportunity this application belongs to, when one is
|
||||
* known. The Get Involved form is NOT tied to a single call - it is the
|
||||
@@ -159,22 +181,26 @@ export async function submitPathwayApplication(
|
||||
): Promise<void> {
|
||||
const { values } = input;
|
||||
|
||||
await createStrapiEntry(APPLICATIONS_RESOURCE, {
|
||||
...(input.opportunityId ? { opportunity: input.opportunityId } : {}),
|
||||
applicantName: values.fullName,
|
||||
email: values.email,
|
||||
organisation: optional(values.organisation),
|
||||
answers: {
|
||||
[APPLICATION_ANSWER_KEYS.route]: values.route,
|
||||
// Only the answers actually given: an empty string in the admin panel
|
||||
// reads as "answered, with nothing", which is not what happened.
|
||||
...(values.region
|
||||
? { [APPLICATION_ANSWER_KEYS.region]: values.region }
|
||||
: {}),
|
||||
...(values.yearsOperating
|
||||
? { [APPLICATION_ANSWER_KEYS.yearsOperating]: values.yearsOperating }
|
||||
: {}),
|
||||
[APPLICATION_ANSWER_KEYS.summary]: values.summary,
|
||||
await createStrapiEntry(
|
||||
APPLICATIONS_RESOURCE,
|
||||
{
|
||||
...(input.opportunityId ? { opportunity: input.opportunityId } : {}),
|
||||
applicantName: values.fullName,
|
||||
email: values.email,
|
||||
organisation: optional(values.organisation),
|
||||
answers: {
|
||||
[APPLICATION_ANSWER_KEYS.route]: values.route,
|
||||
// Only the answers actually given: an empty string in the admin panel
|
||||
// reads as "answered, with nothing", which is not what happened.
|
||||
...(values.region
|
||||
? { [APPLICATION_ANSWER_KEYS.region]: values.region }
|
||||
: {}),
|
||||
...(values.yearsOperating
|
||||
? { [APPLICATION_ANSWER_KEYS.yearsOperating]: values.yearsOperating }
|
||||
: {}),
|
||||
[APPLICATION_ANSWER_KEYS.summary]: values.summary,
|
||||
},
|
||||
},
|
||||
});
|
||||
{ forwardedFor: input.forwardedFor },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,7 +127,11 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
try {
|
||||
await submitApplication({ values, opportunityId: opportunity.documentId });
|
||||
await submitApplication({
|
||||
values,
|
||||
opportunityId: opportunity.documentId,
|
||||
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isStrapiError(error)) throw error;
|
||||
console.error(
|
||||
|
||||
@@ -46,8 +46,7 @@
|
||||
uploaded here at all (see `documentsNote`);
|
||||
* "an automatic acknowledgment with your reference number" - no
|
||||
reference-number field exists anywhere in the CMS. The acknowledgement
|
||||
is modelled (`application.acknowledgementSentAt`) and survives; the
|
||||
number does not;
|
||||
IS sent (the CMS mails it on submission) and the number does not;
|
||||
* "follows up within four weeks" - a response time that appears in no
|
||||
approved document. §8 of the draft marks it "[X weeks] - TBD".
|
||||
|
||||
@@ -226,7 +225,10 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
try {
|
||||
await submitPathwayApplication({ values });
|
||||
await submitPathwayApplication({
|
||||
values,
|
||||
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isStrapiError(error)) throw error;
|
||||
console.error(
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { SITE_FACEBOOK_URL } from "~/lib/site-links";
|
||||
import { data, Form, Link, useLocation, useNavigation } from "react-router";
|
||||
import { data, Form, Link, useLocation, useNavigation, useRouteLoaderData } from "react-router";
|
||||
|
||||
import { ArrowIcon, Button } from "~/components/ui/Button";
|
||||
import { EuBadge } from "~/components/ui/EuBadge";
|
||||
@@ -60,6 +60,7 @@ import {
|
||||
} from "./_page-kit/sections";
|
||||
|
||||
import "./_page-kit/motion.css";
|
||||
import { DEFAULT_THEME, type AppTheme } from "~/lib/theme";
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
LOADER
|
||||
@@ -230,7 +231,10 @@ function validate(values: ContactValues, locale: Locale): ContactErrors {
|
||||
* though the browser decides triage state, and the value would be silently
|
||||
* discarded anyway - so the body below is the whitelist and only it.
|
||||
*/
|
||||
async function createContactSubmission(values: ContactValues): Promise<void> {
|
||||
async function createContactSubmission(
|
||||
values: ContactValues,
|
||||
forwardedFor?: string,
|
||||
): Promise<void> {
|
||||
const env = getServerEnv();
|
||||
const url = `${env.STRAPI_URL.replace(/\/+$/, "")}/api/contact-submissions`;
|
||||
|
||||
@@ -240,6 +244,7 @@ async function createContactSubmission(values: ContactValues): Promise<void> {
|
||||
"content-type": "application/json",
|
||||
accept: "application/json",
|
||||
...(env.STRAPI_TOKEN ? { authorization: `Bearer ${env.STRAPI_TOKEN}` } : {}),
|
||||
...(forwardedFor ? { "x-forwarded-for": forwardedFor } : {}),
|
||||
},
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
@@ -282,7 +287,10 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
try {
|
||||
await createContactSubmission(values);
|
||||
await createContactSubmission(
|
||||
values,
|
||||
request.headers.get("x-forwarded-for") ?? undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("[contact] submission failed", error);
|
||||
const t = createTranslator(locale);
|
||||
@@ -370,6 +378,9 @@ export default function Contact({
|
||||
const { pathname } = useLocation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
const root = useRouteLoaderData<{ theme?: AppTheme }>("root");
|
||||
const theme: AppTheme = root?.theme ?? DEFAULT_THEME;
|
||||
|
||||
const errors = actionData?.errors ?? {};
|
||||
const values = actionData?.values ?? EMPTY_VALUES;
|
||||
const formError =
|
||||
@@ -681,7 +692,7 @@ export default function Contact({
|
||||
size="sm"
|
||||
statement={t("site.euStatement")}
|
||||
locale={locale}
|
||||
surface="dark"
|
||||
surface={theme}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -128,8 +128,6 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
const { event } = requireEntity(await fetchEvent(slug, locale));
|
||||
const form = await request.formData();
|
||||
|
||||
const waitlist = isWaitlisted(event);
|
||||
|
||||
/* A form nobody can submit through the page must not be submittable through
|
||||
a POST either - registration state is checked again on the server. */
|
||||
if (!isRegistrationOpen(event)) {
|
||||
@@ -138,7 +136,7 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
|
||||
/* A bot that fills every field gets the acknowledgement and writes nothing. */
|
||||
if (isHoneypotFilled(form)) {
|
||||
return { ok: true, waitlist } satisfies ActionResult;
|
||||
return { ok: true, waitlist: isWaitlisted(event) } satisfies ActionResult;
|
||||
}
|
||||
|
||||
const { values, errors, valid } = validateRegistration(form);
|
||||
@@ -150,6 +148,7 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
let registrationState: "confirmed" | "waitlisted";
|
||||
try {
|
||||
/* Only the fields the CMS whitelist accepts are sent - see
|
||||
./_events-opportunities/submissions.server.ts. `registrationState` is
|
||||
@@ -159,7 +158,11 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
`waitlist` flag below is therefore presentational - it decides which
|
||||
sentence the reader sees, and it is read from the same event fields the
|
||||
middleware weighs, so the two agree. */
|
||||
await submitRegistration({ values, eventId: event.documentId });
|
||||
registrationState = await submitRegistration({
|
||||
values,
|
||||
eventId: event.documentId,
|
||||
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isStrapiError(error)) throw error;
|
||||
/* A 4xx here is the CMS refusing the registration on its own terms - the
|
||||
@@ -175,7 +178,10 @@ export async function action({ params, request }: Route.ActionArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
return { ok: true, waitlist } satisfies ActionResult;
|
||||
return {
|
||||
ok: true,
|
||||
waitlist: registrationState === "waitlisted",
|
||||
} satisfies ActionResult;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user