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
+4
View File
@@ -896,6 +896,10 @@ Whichever port you pick, `SITE_URL` stays the public origin (`https://sharper.ly
| `DATABASE_*` | cms | managed PostgreSQL; set `DATABASE_SSL=true` for a hosted database | | `DATABASE_*` | cms | managed PostgreSQL; set `DATABASE_SSL=true` for a hosted database |
| `APP_KEYS` and the other five secrets | cms | **different values from staging**, generated with `./scripts/generate-secrets.sh` | | `APP_KEYS` and the other five secrets | cms | **different values from staging**, generated with `./scripts/generate-secrets.sh` |
For per-visitor submission limits, the edge proxy must overwrite `X-Forwarded-For` with
the verified client address (not append to a value supplied by the client). The web server
relays that header to Strapi, which trusts it only when `IS_PROXIED=true`.
Uploaded media is written to `apps/cms/public/uploads` by the default provider, so give the Uploaded media is written to `apps/cms/public/uploads` by the default provider, so give the
CMS a persistent volume — or switch to S3, which `apps/cms/config/plugins.ts` documents step CMS a persistent volume — or switch to S3, which `apps/cms/config/plugins.ts` documents step
by step (remember to add the bucket host to `img-src` / `media-src` in by step (remember to add the bucket host to `img-src` / `media-src` in
@@ -13,7 +13,7 @@
"attributes": { "attributes": {
"opportunity": { "opportunity": {
"type": "relation", "type": "relation",
"relation": "oneToOne", "relation": "manyToOne",
"target": "api::opportunity.opportunity" "target": "api::opportunity.opportunity"
}, },
"applicantName": { "applicantName": {
@@ -1,7 +1,68 @@
/** /**
* application service * 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'; 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": { "attributes": {
"event": { "event": {
"type": "relation", "type": "relation",
"relation": "oneToOne", "relation": "manyToOne",
"target": "api::event.event" "target": "api::event.event"
}, },
"fullName": { "fullName": {
@@ -21,10 +21,8 @@
* `curl`, and the count it depends on - how many places are already taken - is * `curl`, and the count it depends on - how many places are already taken - is
* not something a client can be trusted to report. * not something a client can be trusted to report.
* *
* The count is read and the row is written without a transaction, so two * A transaction locks the event row while the confirmed count and registration
* registrations arriving in the same instant can both see the last free place. * write run, so concurrent requests cannot claim the same final 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.
*/ */
import type { Core } from '@strapi/strapi'; import type { Core } from '@strapi/strapi';
@@ -50,6 +48,7 @@ type EventCapacity = {
capacity: number | null; capacity: number | null;
waitlistEnabled: boolean | null; waitlistEnabled: boolean | null;
registrationStatus: string | null; registrationStatus: string | null;
registrationDeadline: string | Date | null;
}; };
/** /**
@@ -89,9 +88,9 @@ const findEvent = async (
? { documentId: reference.documentId } ? { documentId: reference.documentId }
: { id: reference.id }; : { id: reference.id };
return (await strapi.db.query(EVENT_UID).findOne({ return (await strapi.documents(EVENT_UID).findFirst({
where, filters: where,
select: ['documentId', 'capacity', 'waitlistEnabled', 'registrationStatus'], status: 'published',
})) as EventCapacity | null; })) as EventCapacity | null;
}; };
@@ -121,13 +120,22 @@ const resolveRegistrationState = async (
const event = await findEvent(strapi, readEventReference(eventValue)); const event = await findEvent(strapi, readEventReference(eventValue));
if (!event) { if (!event) {
// Either no event was given or the id does not resolve. There is no throw new errors.ApplicationError('A valid published event is required.');
// capacity to weigh, and nothing to confirm a place on.
return 'waitlisted';
} }
if (event.registrationStatus === 'closed') { if (
throw new errors.ApplicationError('Registration for this event is closed.'); 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 = const capacity =
@@ -168,13 +176,28 @@ const sanitizeSubmission: Core.MiddlewareFactory =
} }
} }
data.registrationState = await resolveRegistrationState(strapi, data.event); 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.');
}
await trx('events')
.select('id')
.where({ document_id: event.documentId })
.orderBy('id')
.forUpdate()
.first();
data.registrationState = await resolveRegistrationState(strapi, {
documentId: event.documentId,
});
data.submittedAt = new Date().toISOString(); data.submittedAt = new Date().toISOString();
data.reminderSentAt = null; data.reminderSentAt = null;
request.body = { data }; request.body = { data };
await next(); await next();
});
}; };
export default sanitizeSubmission; 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 * Grant the Public role read access to the public content (collection types and
* single types) and create access to the three submission content types. * single types) and create access to the three submission content types.
* *
* Only missing permissions are inserted, and permissions granted by hand in the * Missing public-content permissions are inserted. Submission types are kept
* admin panel are never removed - so this is safe to run on every boot. A read * create-only: any other public grant is revoked on boot because those entries
* grant on a submission type is reported as a warning rather than deleted, so * contain personal data.
* that an unexpected grant is visible without the boot silently undoing an
* administrator's decision.
*/ */
export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void> => { export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void> => {
if (!strapi.plugins['users-permissions']) { if (!strapi.plugins['users-permissions']) {
@@ -63,7 +61,7 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
.getActions(); .getActions();
const desiredActions = new Set<string>(); const desiredActions = new Set<string>();
const forbiddenReadActions = new Set<string>(); const forbiddenSubmissionActions = new Set<string>();
for (const [namespace, { controllers }] of Object.entries(actionMap)) { for (const [namespace, { controllers }] of Object.entries(actionMap)) {
// Only the project's own APIs; plugin permissions are managed by Strapi. // 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)) { if (wantedActions.includes(action)) {
desiredActions.add(permission); desiredActions.add(permission);
} else if ( } else if (PUBLIC_SUBMISSION_CONTENT_TYPES.includes(controllerName)) {
PUBLIC_SUBMISSION_CONTENT_TYPES.includes(controllerName) && forbiddenSubmissionActions.add(permission);
PUBLIC_READ_ACTIONS.includes(action)
) {
forbiddenReadActions.add(permission);
} }
} }
} }
@@ -94,7 +89,7 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise<void
.query('plugin::users-permissions.permission') .query('plugin::users-permissions.permission')
.findMany({ .findMany({
where: { role: { id: publicRole.id } }, where: { role: { id: publicRole.id } },
select: ['action'], select: ['id', 'action'],
}); });
const existingActions = new Set(existingPermissions.map(({ action }) => 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( strapi.log.warn(
`${LOG_PREFIX} the Public role can read submission data: ${leakedActions.join(', ')}. ` + `${LOG_PREFIX} revoked forbidden Public submission permission(s): ${forbiddenPermissions
'Revoke these in Settings > Roles > Public unless this is intentional.' .map(({ action }) => action)
.sort()
.join(', ')}.`
); );
} }
}; };
+6 -1
View File
@@ -2,6 +2,7 @@ import type { Core } from '@strapi/strapi';
import { runBootstrap } from './bootstrap'; import { runBootstrap } from './bootstrap';
import { runEventReminders } from './lib/event-reminders'; import { runEventReminders } from './lib/event-reminders';
import { runWaitlistPromotion } from './lib/waitlist-promotion';
export default { export default {
/** /**
@@ -33,8 +34,12 @@ export default {
task: runEventReminders, task: runEventReminders,
options: { rule: '0 * * * *', tz: 'Africa/Tripoli' }, 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 = const BASE_FONT =
"font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif;"; "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 { function wrapParagraphs(text: string): string {
return text return text
.trim() .trim()
.split(/\n\n+/) .split(/\n\n+/)
.map((paragraph) => `<p>${paragraph.trim()}</p>`) .map((paragraph) => `<p>${escapeHtml(paragraph.trim())}</p>`)
.join(''); .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. * Build and send the confirmation (or waitlist) e-mail for a registration.
* Best-effort: logs failures, never throws into the request path. * 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({ export async function sendRegistrationConfirmation({
strapi, strapi,
@@ -312,7 +326,7 @@ export async function sendRegistrationConfirmation({
to: string; to: string;
state: RegistrationState; state: RegistrationState;
eventReference: EventReference; eventReference: EventReference;
}): Promise<void> { }): Promise<boolean> {
try { try {
const event = await fetchBilingualEvent(strapi, eventReference); const event = await fetchBilingualEvent(strapi, eventReference);
@@ -320,7 +334,7 @@ export async function sendRegistrationConfirmation({
strapi.log.warn( strapi.log.warn(
'[event-mail] confirmation skipped: no published event for the registration.', '[event-mail] confirmation skipped: no published event for the registration.',
); );
return; return false;
} }
const timeZone = event.en?.timezone || event.ar?.timezone || DEFAULT_TIMEZONE; const timeZone = event.en?.timezone || event.ar?.timezone || DEFAULT_TIMEZONE;
@@ -355,9 +369,10 @@ export async function sendRegistrationConfirmation({
locationLine: locationLineFor(event), locationLine: locationLineFor(event),
}); });
await trySendMail(strapi, { to, ...mail }); return trySendMail(strapi, { to, ...mail });
} catch (error) { } catch (error) {
strapi.log.error(`[event-mail] confirmation failed: ${String(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).`,
);
}
+2 -2
View File
@@ -707,7 +707,7 @@ export interface ApiApplicationApplication extends Struct.CollectionTypeSchema {
> & > &
Schema.Attribute.Private; Schema.Attribute.Private;
opportunity: Schema.Attribute.Relation< opportunity: Schema.Attribute.Relation<
'oneToOne', 'manyToOne',
'api::opportunity.opportunity' 'api::opportunity.opportunity'
>; >;
organisation: Schema.Attribute.String & organisation: Schema.Attribute.String &
@@ -871,7 +871,7 @@ export interface ApiEventRegistrationEventRegistration
Schema.Attribute.Private; Schema.Attribute.Private;
dietaryOrAccess: Schema.Attribute.Text; dietaryOrAccess: Schema.Attribute.Text;
email: Schema.Attribute.Email & Schema.Attribute.Required; 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 & fullName: Schema.Attribute.String &
Schema.Attribute.Required & Schema.Attribute.Required &
Schema.Attribute.SetMinMaxLength<{ Schema.Attribute.SetMinMaxLength<{
+2 -2
View File
@@ -120,8 +120,8 @@ export function resetServerEnv(): void {
* the incoming request is trusted, honouring the standard forwarding headers. * the incoming request is trusted, honouring the standard forwarding headers.
*/ */
export function getSiteUrl(request: Request): string { export function getSiteUrl(request: Request): string {
const { SITE_URL } = getServerEnv(); const env = getServerEnv();
if (process.env.SITE_URL?.trim()) return SITE_URL; if (process.env.SITE_URL?.trim() || env.isProduction) return env.SITE_URL;
const url = new URL(request.url); const url = new URL(request.url);
const forwardedHost = const forwardedHost =
@@ -114,9 +114,8 @@ const EN = {
uploaded here (see `documentsNote`), so no limit is quoted at all. uploaded here (see `documentsNote`), so no limit is quoted at all.
* "an automatic acknowledgment with your reference number" - there is no * "an automatic acknowledgment with your reference number" - there is no
reference-number field anywhere in the CMS, so no number is promised. reference-number field anywhere in the CMS, so no number is promised.
The acknowledgement itself IS modelled, as The acknowledgement itself IS sent: the CMS mails it on submission
`application.acknowledgementSentAt`, so it is the only half that and stamps `application.acknowledgementSentAt` when it went out.
survives.
* "follows up within four weeks" - that response time appears in no * "follows up within four weeks" - that response time appears in no
approved document; §8 of the draft marks it "[X weeks] - TBD". The 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 follow-up is stated without a period attached, exactly as the per-call
@@ -168,7 +167,7 @@ const EN = {
*/ */
documentsNote: documentsNote:
"Documents are not uploaded through this form. Describe any supporting material in the summary above.", "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.", acknowledgement: "Your submission is acknowledged automatically by email.",
submit: "Submit application", submit: "Submit application",
errorSummaryTitle: "Please check the form", errorSummaryTitle: "Please check the form",
@@ -147,6 +147,7 @@ function toCard(entry: StrapiEvent, now: Date): EventCardData {
registrationStatus: registrationStatus:
oneOf(entry.registrationStatus, EVENT_REGISTRATION_STATUSES) ?? oneOf(entry.registrationStatus, EVENT_REGISTRATION_STATUSES) ??
"not-required", "not-required",
registrationDeadline: text(entry.registrationDeadline),
timeStatus: deriveTimeStatus({ start, end, allDay }, now), timeStatus: deriveTimeStatus({ start, end, allDay }, now),
recapUrl: text(entry.recapUrl), recapUrl: text(entry.recapUrl),
image: toImage(entry.coverImage, "medium"), image: toImage(entry.coverImage, "medium"),
@@ -114,6 +114,7 @@ export interface EventCardData {
/** Name of the related region entry, not a free-text string. */ /** Name of the related region entry, not a free-text string. */
readonly region?: string; readonly region?: string;
readonly registrationStatus: EventRegistrationStatus; readonly registrationStatus: EventRegistrationStatus;
readonly registrationDeadline?: string;
readonly timeStatus: EventTimeStatus; readonly timeStatus: EventTimeStatus;
/** Set on a past event that has a write-up - drives the "Read recap" label. */ /** Set on a past event that has a write-up - drives the "Read recap" label. */
readonly recapUrl?: string; 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. */ /** The long body. There is no external registration portal field in the CMS. */
readonly description?: CmsRichText; readonly description?: CmsRichText;
readonly onlineUrl?: string; readonly onlineUrl?: string;
readonly registrationDeadline?: string;
readonly venue?: string; readonly venue?: string;
/** Editorial: the CMS offers a waitlist once the event is full. */ /** Editorial: the CMS offers a waitlist once the event is full. */
readonly waitlistEnabled: boolean; readonly waitlistEnabled: boolean;
@@ -330,8 +330,16 @@ export function collectRegions(
* reader simply turns up, so there is nothing to submit; "closed" speaks for * reader simply turns up, so there is nothing to submit; "closed" speaks for
* itself; "full" and "waitlist" still take names for the waiting list. * 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 ( return (
(deadline === undefined || (Number.isFinite(deadline) && now < deadline)) &&
event.timeStatus !== "completed" && event.timeStatus !== "completed" &&
(event.registrationStatus === "open" || (event.registrationStatus === "open" ||
event.registrationStatus === "full" || event.registrationStatus === "full" ||
@@ -19,6 +19,7 @@ const DEFAULT_TIMEOUT_MS = 10_000;
export interface StrapiWriteInit { export interface StrapiWriteInit {
readonly timeoutMs?: number; readonly timeoutMs?: number;
readonly signal?: AbortSignal; readonly signal?: AbortSignal;
readonly forwardedFor?: string;
} }
interface StrapiErrorEnvelope { interface StrapiErrorEnvelope {
@@ -49,6 +50,7 @@ export async function createStrapiEntry<T>(
accept: "application/json", accept: "application/json",
}; };
if (env.STRAPI_TOKEN) headers.authorization = `Bearer ${env.STRAPI_TOKEN}`; 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 timeoutMs = init.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const timeoutSignal = AbortSignal.timeout(timeoutMs); const timeoutSignal = AbortSignal.timeout(timeoutMs);
@@ -68,12 +68,17 @@ export interface RegistrationSubmission {
readonly values: RegistrationValues; readonly values: RegistrationValues;
/** documentId of the event being registered for. */ /** documentId of the event being registered for. */
readonly eventId: string; readonly eventId: string;
readonly forwardedFor?: string;
} }
export async function submitRegistration( export async function submitRegistration(
input: RegistrationSubmission, input: RegistrationSubmission,
): Promise<void> { ): Promise<"confirmed" | "waitlisted"> {
await createStrapiEntry(EVENT_REGISTRATIONS_RESOURCE, { const created = await createStrapiEntry<{
readonly registrationState?: unknown;
}>(
EVENT_REGISTRATIONS_RESOURCE,
{
event: input.eventId, event: input.eventId,
fullName: input.values.fullName, fullName: input.values.fullName,
email: input.values.email, email: input.values.email,
@@ -81,13 +86,25 @@ export async function submitRegistration(
organisation: optional(input.values.organisation), organisation: optional(input.values.organisation),
// Required in the schema: without it every POST is a 400. // Required in the schema: without it every POST is a 400.
consentGdpr: input.values.consentGdpr, 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 { export interface ApplicationSubmission {
readonly values: ApplicationValues; readonly values: ApplicationValues;
/** documentId of the opportunity being applied to. */ /** documentId of the opportunity being applied to. */
readonly opportunityId: string; readonly opportunityId: string;
readonly forwardedFor?: string;
} }
/** The key the free-text answer is filed under inside `answers` (json). */ /** The key the free-text answer is filed under inside `answers` (json). */
@@ -96,7 +113,9 @@ export const APPLICATION_MESSAGE_KEY = "message";
export async function submitApplication( export async function submitApplication(
input: ApplicationSubmission, input: ApplicationSubmission,
): Promise<void> { ): Promise<void> {
await createStrapiEntry(APPLICATIONS_RESOURCE, { await createStrapiEntry(
APPLICATIONS_RESOURCE,
{
opportunity: input.opportunityId, opportunity: input.opportunityId,
// `fullName` is the registration's field name; an application's is // `fullName` is the registration's field name; an application's is
// `applicantName`. // `applicantName`.
@@ -106,7 +125,9 @@ export async function submitApplication(
// `answers` is a json field - the form's one free-text question goes in // `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. // it, keyed, so more questions can be added without another migration.
answers: { [APPLICATION_MESSAGE_KEY]: input.values.message }, answers: { [APPLICATION_MESSAGE_KEY]: input.values.message },
}); },
{ forwardedFor: input.forwardedFor },
);
} }
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
@@ -134,6 +155,7 @@ export const APPLICATION_ANSWER_KEYS = {
export interface PathwayApplicationSubmission { export interface PathwayApplicationSubmission {
readonly values: PathwayApplicationValues; readonly values: PathwayApplicationValues;
readonly forwardedFor?: string;
/** /**
* documentId of the opportunity this application belongs to, when one is * 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 * known. The Get Involved form is NOT tied to a single call - it is the
@@ -159,7 +181,9 @@ export async function submitPathwayApplication(
): Promise<void> { ): Promise<void> {
const { values } = input; const { values } = input;
await createStrapiEntry(APPLICATIONS_RESOURCE, { await createStrapiEntry(
APPLICATIONS_RESOURCE,
{
...(input.opportunityId ? { opportunity: input.opportunityId } : {}), ...(input.opportunityId ? { opportunity: input.opportunityId } : {}),
applicantName: values.fullName, applicantName: values.fullName,
email: values.email, email: values.email,
@@ -176,5 +200,7 @@ export async function submitPathwayApplication(
: {}), : {}),
[APPLICATION_ANSWER_KEYS.summary]: values.summary, [APPLICATION_ANSWER_KEYS.summary]: values.summary,
}, },
}); },
{ forwardedFor: input.forwardedFor },
);
} }
@@ -127,7 +127,11 @@ export async function action({ params, request }: Route.ActionArgs) {
} }
try { try {
await submitApplication({ values, opportunityId: opportunity.documentId }); await submitApplication({
values,
opportunityId: opportunity.documentId,
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
});
} catch (error) { } catch (error) {
if (!isStrapiError(error)) throw error; if (!isStrapiError(error)) throw error;
console.error( console.error(
@@ -46,8 +46,7 @@
uploaded here at all (see `documentsNote`); uploaded here at all (see `documentsNote`);
* "an automatic acknowledgment with your reference number" - no * "an automatic acknowledgment with your reference number" - no
reference-number field exists anywhere in the CMS. The acknowledgement reference-number field exists anywhere in the CMS. The acknowledgement
is modelled (`application.acknowledgementSentAt`) and survives; the IS sent (the CMS mails it on submission) and the number does not;
number does not;
* "follows up within four weeks" - a response time that appears in no * "follows up within four weeks" - a response time that appears in no
approved document. §8 of the draft marks it "[X weeks] - TBD". approved document. §8 of the draft marks it "[X weeks] - TBD".
@@ -226,7 +225,10 @@ export async function action({ params, request }: Route.ActionArgs) {
} }
try { try {
await submitPathwayApplication({ values }); await submitPathwayApplication({
values,
forwardedFor: request.headers.get("x-forwarded-for") ?? undefined,
});
} catch (error) { } catch (error) {
if (!isStrapiError(error)) throw error; if (!isStrapiError(error)) throw error;
console.error( console.error(
+15 -4
View File
@@ -36,7 +36,7 @@
import { useEffect, useRef, type ReactNode } from "react"; import { useEffect, useRef, type ReactNode } from "react";
import { SITE_FACEBOOK_URL } from "~/lib/site-links"; 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 { ArrowIcon, Button } from "~/components/ui/Button";
import { EuBadge } from "~/components/ui/EuBadge"; import { EuBadge } from "~/components/ui/EuBadge";
@@ -60,6 +60,7 @@ import {
} from "./_page-kit/sections"; } from "./_page-kit/sections";
import "./_page-kit/motion.css"; import "./_page-kit/motion.css";
import { DEFAULT_THEME, type AppTheme } from "~/lib/theme";
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
LOADER LOADER
@@ -230,7 +231,10 @@ function validate(values: ContactValues, locale: Locale): ContactErrors {
* though the browser decides triage state, and the value would be silently * though the browser decides triage state, and the value would be silently
* discarded anyway - so the body below is the whitelist and only it. * 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 env = getServerEnv();
const url = `${env.STRAPI_URL.replace(/\/+$/, "")}/api/contact-submissions`; const url = `${env.STRAPI_URL.replace(/\/+$/, "")}/api/contact-submissions`;
@@ -240,6 +244,7 @@ async function createContactSubmission(values: ContactValues): Promise<void> {
"content-type": "application/json", "content-type": "application/json",
accept: "application/json", accept: "application/json",
...(env.STRAPI_TOKEN ? { authorization: `Bearer ${env.STRAPI_TOKEN}` } : {}), ...(env.STRAPI_TOKEN ? { authorization: `Bearer ${env.STRAPI_TOKEN}` } : {}),
...(forwardedFor ? { "x-forwarded-for": forwardedFor } : {}),
}, },
body: JSON.stringify({ body: JSON.stringify({
data: { data: {
@@ -282,7 +287,10 @@ export async function action({ params, request }: Route.ActionArgs) {
} }
try { try {
await createContactSubmission(values); await createContactSubmission(
values,
request.headers.get("x-forwarded-for") ?? undefined,
);
} catch (error) { } catch (error) {
console.error("[contact] submission failed", error); console.error("[contact] submission failed", error);
const t = createTranslator(locale); const t = createTranslator(locale);
@@ -370,6 +378,9 @@ export default function Contact({
const { pathname } = useLocation(); const { pathname } = useLocation();
const isSubmitting = navigation.state === "submitting"; const isSubmitting = navigation.state === "submitting";
const root = useRouteLoaderData<{ theme?: AppTheme }>("root");
const theme: AppTheme = root?.theme ?? DEFAULT_THEME;
const errors = actionData?.errors ?? {}; const errors = actionData?.errors ?? {};
const values = actionData?.values ?? EMPTY_VALUES; const values = actionData?.values ?? EMPTY_VALUES;
const formError = const formError =
@@ -681,7 +692,7 @@ export default function Contact({
size="sm" size="sm"
statement={t("site.euStatement")} statement={t("site.euStatement")}
locale={locale} locale={locale}
surface="dark" surface={theme}
/> />
</div> </div>
</div> </div>
+11 -5
View File
@@ -128,8 +128,6 @@ export async function action({ params, request }: Route.ActionArgs) {
const { event } = requireEntity(await fetchEvent(slug, locale)); const { event } = requireEntity(await fetchEvent(slug, locale));
const form = await request.formData(); const form = await request.formData();
const waitlist = isWaitlisted(event);
/* A form nobody can submit through the page must not be submittable through /* A form nobody can submit through the page must not be submittable through
a POST either - registration state is checked again on the server. */ a POST either - registration state is checked again on the server. */
if (!isRegistrationOpen(event)) { 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. */ /* A bot that fills every field gets the acknowledgement and writes nothing. */
if (isHoneypotFilled(form)) { if (isHoneypotFilled(form)) {
return { ok: true, waitlist } satisfies ActionResult; return { ok: true, waitlist: isWaitlisted(event) } satisfies ActionResult;
} }
const { values, errors, valid } = validateRegistration(form); const { values, errors, valid } = validateRegistration(form);
@@ -150,6 +148,7 @@ export async function action({ params, request }: Route.ActionArgs) {
); );
} }
let registrationState: "confirmed" | "waitlisted";
try { try {
/* Only the fields the CMS whitelist accepts are sent - see /* Only the fields the CMS whitelist accepts are sent - see
./_events-opportunities/submissions.server.ts. `registrationState` is ./_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 `waitlist` flag below is therefore presentational - it decides which
sentence the reader sees, and it is read from the same event fields the sentence the reader sees, and it is read from the same event fields the
middleware weighs, so the two agree. */ 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) { } catch (error) {
if (!isStrapiError(error)) throw error; if (!isStrapiError(error)) throw error;
/* A 4xx here is the CMS refusing the registration on its own terms - the /* 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;
} }
/* --------------------------------------------------------------------------- /* ---------------------------------------------------------------------------
+4 -3
View File
@@ -38,7 +38,7 @@ services:
ports: ports:
# Published on 5433 by default so it cannot collide with a PostgreSQL # Published on 5433 by default so it cannot collide with a PostgreSQL
# already running on the host's 5432. # already running on the host's 5432.
- "${POSTGRES_PUBLISHED_PORT:-5433}:5432" - "127.0.0.1:${POSTGRES_PUBLISHED_PORT:-5433}:5432"
healthcheck: healthcheck:
# pg_isready also fails while the entrypoint's temporary init server is # pg_isready also fails while the entrypoint's temporary init server is
# up, so `service_healthy` really does mean "ready for connections". # up, so `service_healthy` really does mean "ready for connections".
@@ -69,6 +69,7 @@ services:
NODE_ENV: production NODE_ENV: production
HOST: 0.0.0.0 HOST: 0.0.0.0
PORT: 1337 PORT: 1337
IS_PROXIED: ${IS_PROXIED:-false}
# Absolute URL the admin panel and generated links use. Change it # Absolute URL the admin panel and generated links use. Change it
# together with CMS_PORT, and set it to the public origin in production. # together with CMS_PORT, and set it to the public origin in production.
PUBLIC_URL: ${CMS_PUBLIC_URL:-http://localhost:1337} PUBLIC_URL: ${CMS_PUBLIC_URL:-http://localhost:1337}
@@ -99,7 +100,7 @@ services:
# only removed by `down -v`. # only removed by `down -v`.
- cms-uploads:/repo/apps/cms/public/uploads - cms-uploads:/repo/apps/cms/public/uploads
ports: ports:
- "${CMS_PORT:-1337}:1337" - "127.0.0.1:${CMS_PORT:-1337}:1337"
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# React Router 8 server-side renderer. Waits for a CMS that answers /_health, # React Router 8 server-side renderer. Waits for a CMS that answers /_health,
@@ -133,7 +134,7 @@ services:
# local container use them without ever baking them into an artefact. # local container use them without ever baking them into an artefact.
- ./apps/web/public/fonts:/repo/apps/web/build/client/fonts:ro - ./apps/web/public/fonts:/repo/apps/web/build/client/fonts:ro
ports: ports:
- "${WEB_PORT:-3000}:3000" - "127.0.0.1:${WEB_PORT:-3000}:3000"
volumes: volumes:
postgres-data: postgres-data:
+5
View File
@@ -17,6 +17,11 @@
"typecheck": "pnpm -r --stream typecheck" "typecheck": "pnpm -r --stream typecheck"
}, },
"pnpm": { "pnpm": {
"overrides": {
"nodemailer": "9.1.1",
"qs": "6.16.0",
"sharp": "0.35.4"
},
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@swc/core", "@swc/core",
"core-js-pure", "core-js-pure",
+144 -139
View File
@@ -4,6 +4,11 @@ settings:
autoInstallPeers: true autoInstallPeers: true
excludeLinksFromLockfile: false excludeLinksFromLockfile: false
overrides:
nodemailer: 9.1.1
qs: 6.16.0
sharp: 0.35.4
importers: importers:
.: {} .: {}
@@ -792,144 +797,144 @@ packages:
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'} engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.35.3': '@img/sharp-darwin-arm64@0.35.4':
resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@img/sharp-darwin-x64@0.35.3': '@img/sharp-darwin-x64@0.35.4':
resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@img/sharp-freebsd-wasm32@0.35.3': '@img/sharp-freebsd-wasm32@0.35.4':
resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
os: [freebsd] os: [freebsd]
'@img/sharp-libvips-darwin-arm64@1.3.2': '@img/sharp-libvips-darwin-arm64@1.3.3':
resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==}
cpu: [arm64] cpu: [arm64]
os: [darwin] os: [darwin]
'@img/sharp-libvips-darwin-x64@1.3.2': '@img/sharp-libvips-darwin-x64@1.3.3':
resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==}
cpu: [x64] cpu: [x64]
os: [darwin] os: [darwin]
'@img/sharp-libvips-linux-arm64@1.3.2': '@img/sharp-libvips-linux-arm64@1.3.3':
resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@img/sharp-libvips-linux-arm@1.3.2': '@img/sharp-libvips-linux-arm@1.3.3':
resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@img/sharp-libvips-linux-ppc64@1.3.2': '@img/sharp-libvips-linux-ppc64@1.3.3':
resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@img/sharp-libvips-linux-riscv64@1.3.2': '@img/sharp-libvips-linux-riscv64@1.3.3':
resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@img/sharp-libvips-linux-s390x@1.3.2': '@img/sharp-libvips-linux-s390x@1.3.3':
resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
'@img/sharp-libvips-linux-x64@1.3.2': '@img/sharp-libvips-linux-x64@1.3.3':
resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.3.2': '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.3.2': '@img/sharp-libvips-linuxmusl-x64@1.3.3':
resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@img/sharp-linux-arm64@0.35.3': '@img/sharp-linux-arm64@0.35.4':
resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@img/sharp-linux-arm@0.35.3': '@img/sharp-linux-arm@0.35.4':
resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
'@img/sharp-linux-ppc64@0.35.3': '@img/sharp-linux-ppc64@0.35.4':
resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
'@img/sharp-linux-riscv64@0.35.3': '@img/sharp-linux-riscv64@0.35.4':
resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
'@img/sharp-linux-s390x@0.35.3': '@img/sharp-linux-s390x@0.35.4':
resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
'@img/sharp-linux-x64@0.35.3': '@img/sharp-linux-x64@0.35.4':
resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@img/sharp-linuxmusl-arm64@0.35.3': '@img/sharp-linuxmusl-arm64@0.35.4':
resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
'@img/sharp-linuxmusl-x64@0.35.3': '@img/sharp-linuxmusl-x64@0.35.4':
resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
'@img/sharp-wasm32@0.35.3': '@img/sharp-wasm32@0.35.4':
resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
'@img/sharp-webcontainers-wasm32@0.35.3': '@img/sharp-webcontainers-wasm32@0.35.4':
resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [wasm32] cpu: [wasm32]
'@img/sharp-win32-arm64@0.35.3': '@img/sharp-win32-arm64@0.35.4':
resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [arm64] cpu: [arm64]
os: [win32] os: [win32]
'@img/sharp-win32-ia32@0.35.3': '@img/sharp-win32-ia32@0.35.4':
resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==}
engines: {node: ^20.9.0} engines: {node: ^20.9.0}
cpu: [ia32] cpu: [ia32]
os: [win32] os: [win32]
'@img/sharp-win32-x64@0.35.3': '@img/sharp-win32-x64@0.35.4':
resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
@@ -5106,8 +5111,8 @@ packages:
resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==} resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
nodemailer@9.0.1: nodemailer@9.1.1:
resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==} resolution: {integrity: sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==}
engines: {node: '>=6.0.0'} engines: {node: '>=6.0.0'}
normalize-package-data@2.5.0: normalize-package-data@2.5.0:
@@ -5520,8 +5525,8 @@ packages:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'} engines: {node: '>=6'}
qs@6.15.3: qs@6.16.0:
resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==}
engines: {node: '>=0.6'} engines: {node: '>=0.6'}
range-parser@1.3.0: range-parser@1.3.0:
@@ -5973,8 +5978,8 @@ packages:
resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==}
engines: {node: '>=8'} engines: {node: '>=8'}
sharp@0.35.3: sharp@0.35.4:
resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==}
engines: {node: '>=20.9.0'} engines: {node: '>=20.9.0'}
peerDependencies: peerDependencies:
'@types/node': '*' '@types/node': '*'
@@ -7476,108 +7481,108 @@ snapshots:
'@img/colour@1.1.0': {} '@img/colour@1.1.0': {}
'@img/sharp-darwin-arm64@0.35.3': '@img/sharp-darwin-arm64@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.3.2 '@img/sharp-libvips-darwin-arm64': 1.3.3
optional: true optional: true
'@img/sharp-darwin-x64@0.35.3': '@img/sharp-darwin-x64@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.3.2 '@img/sharp-libvips-darwin-x64': 1.3.3
optional: true optional: true
'@img/sharp-freebsd-wasm32@0.35.3': '@img/sharp-freebsd-wasm32@0.35.4':
dependencies: dependencies:
'@img/sharp-wasm32': 0.35.3 '@img/sharp-wasm32': 0.35.4
optional: true optional: true
'@img/sharp-libvips-darwin-arm64@1.3.2': '@img/sharp-libvips-darwin-arm64@1.3.3':
optional: true optional: true
'@img/sharp-libvips-darwin-x64@1.3.2': '@img/sharp-libvips-darwin-x64@1.3.3':
optional: true optional: true
'@img/sharp-libvips-linux-arm64@1.3.2': '@img/sharp-libvips-linux-arm64@1.3.3':
optional: true optional: true
'@img/sharp-libvips-linux-arm@1.3.2': '@img/sharp-libvips-linux-arm@1.3.3':
optional: true optional: true
'@img/sharp-libvips-linux-ppc64@1.3.2': '@img/sharp-libvips-linux-ppc64@1.3.3':
optional: true optional: true
'@img/sharp-libvips-linux-riscv64@1.3.2': '@img/sharp-libvips-linux-riscv64@1.3.3':
optional: true optional: true
'@img/sharp-libvips-linux-s390x@1.3.2': '@img/sharp-libvips-linux-s390x@1.3.3':
optional: true optional: true
'@img/sharp-libvips-linux-x64@1.3.2': '@img/sharp-libvips-linux-x64@1.3.3':
optional: true optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.3.2': '@img/sharp-libvips-linuxmusl-arm64@1.3.3':
optional: true optional: true
'@img/sharp-libvips-linuxmusl-x64@1.3.2': '@img/sharp-libvips-linuxmusl-x64@1.3.3':
optional: true optional: true
'@img/sharp-linux-arm64@0.35.3': '@img/sharp-linux-arm64@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.3.2 '@img/sharp-libvips-linux-arm64': 1.3.3
optional: true optional: true
'@img/sharp-linux-arm@0.35.3': '@img/sharp-linux-arm@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.3.2 '@img/sharp-libvips-linux-arm': 1.3.3
optional: true optional: true
'@img/sharp-linux-ppc64@0.35.3': '@img/sharp-linux-ppc64@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-ppc64': 1.3.2 '@img/sharp-libvips-linux-ppc64': 1.3.3
optional: true optional: true
'@img/sharp-linux-riscv64@0.35.3': '@img/sharp-linux-riscv64@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-riscv64': 1.3.2 '@img/sharp-libvips-linux-riscv64': 1.3.3
optional: true optional: true
'@img/sharp-linux-s390x@0.35.3': '@img/sharp-linux-s390x@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.3.2 '@img/sharp-libvips-linux-s390x': 1.3.3
optional: true optional: true
'@img/sharp-linux-x64@0.35.3': '@img/sharp-linux-x64@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.3.2 '@img/sharp-libvips-linux-x64': 1.3.3
optional: true optional: true
'@img/sharp-linuxmusl-arm64@0.35.3': '@img/sharp-linuxmusl-arm64@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2 '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
optional: true optional: true
'@img/sharp-linuxmusl-x64@0.35.3': '@img/sharp-linuxmusl-x64@0.35.4':
optionalDependencies: optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.3.2 '@img/sharp-libvips-linuxmusl-x64': 1.3.3
optional: true optional: true
'@img/sharp-wasm32@0.35.3': '@img/sharp-wasm32@0.35.4':
dependencies: dependencies:
'@emnapi/runtime': 1.11.3 '@emnapi/runtime': 1.11.3
optional: true optional: true
'@img/sharp-webcontainers-wasm32@0.35.3': '@img/sharp-webcontainers-wasm32@0.35.4':
dependencies: dependencies:
'@img/sharp-wasm32': 0.35.3 '@img/sharp-wasm32': 0.35.4
optional: true optional: true
'@img/sharp-win32-arm64@0.35.3': '@img/sharp-win32-arm64@0.35.4':
optional: true optional: true
'@img/sharp-win32-ia32@0.35.3': '@img/sharp-win32-ia32@0.35.4':
optional: true optional: true
'@img/sharp-win32-x64@0.35.3': '@img/sharp-win32-x64@0.35.4':
optional: true optional: true
'@inquirer/external-editor@1.0.3(@types/node@20.19.43)': '@inquirer/external-editor@1.0.3(@types/node@20.19.43)':
@@ -8707,7 +8712,7 @@ snapshots:
p-map: 4.0.0 p-map: 4.0.0
passport-local: 1.0.0 passport-local: 1.0.0
pluralize: 8.0.0 pluralize: 8.0.0
qs: 6.15.3 qs: 6.16.0
react: 18.3.1 react: 18.3.1
react-dnd: 16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@20.19.43)(@types/react@18.3.31)(react@18.3.1) react-dnd: 16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@20.19.43)(@types/react@18.3.31)(react@18.3.1)
react-dnd-html5-backend: 16.0.1 react-dnd-html5-backend: 16.0.1
@@ -8819,7 +8824,7 @@ snapshots:
markdown-it-sub: 1.0.0 markdown-it-sub: 1.0.0
markdown-it-sup: 1.0.0 markdown-it-sup: 1.0.0
prismjs: 1.30.0 prismjs: 1.30.0
qs: 6.15.3 qs: 6.16.0
react: 18.3.1 react: 18.3.1
react-dnd: 16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@20.19.43)(@types/react@18.3.31)(react@18.3.1) react-dnd: 16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@20.19.43)(@types/react@18.3.31)(react@18.3.1)
react-dnd-html5-backend: 16.0.1 react-dnd-html5-backend: 16.0.1
@@ -8875,7 +8880,7 @@ snapshots:
date-fns-tz: 2.0.1(date-fns@2.30.0) date-fns-tz: 2.0.1(date-fns@2.30.0)
formik: 2.4.9(@types/react@18.3.31)(react@18.3.1) formik: 2.4.9(@types/react@18.3.31)(react@18.3.1)
lodash: 4.18.1 lodash: 4.18.1
qs: 6.15.3 qs: 6.16.0
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
react-intl: 6.6.2(react@18.3.1)(typescript@5.9.3) react-intl: 6.6.2(react@18.3.1)(typescript@5.9.3)
@@ -8928,7 +8933,7 @@ snapshots:
lodash: 4.18.1 lodash: 4.18.1
micromatch: 4.0.8 micromatch: 4.0.8
pluralize: 8.0.0 pluralize: 8.0.0
qs: 6.15.3 qs: 6.16.0
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
react-dropzone: 14.3.8(react@18.3.1) react-dropzone: 14.3.8(react@18.3.1)
@@ -9011,7 +9016,7 @@ snapshots:
ora: 5.4.1 ora: 5.4.1
package-json: 10.0.1 package-json: 10.0.1
pkg-up: 3.1.0 pkg-up: 3.1.0
qs: 6.15.3 qs: 6.16.0
resolve.exports: 2.0.2 resolve.exports: 2.0.2
semver: 7.7.4 semver: 7.7.4
typescript: 5.9.3 typescript: 5.9.3
@@ -9217,7 +9222,7 @@ snapshots:
'@strapi/icons': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@strapi/icons': 2.2.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(styled-components@6.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1))
'@strapi/utils': 5.53.0(ts-toolbelt@9.6.0) '@strapi/utils': 5.53.0(ts-toolbelt@9.6.0)
lodash: 4.18.1 lodash: 4.18.1
qs: 6.15.3 qs: 6.16.0
react: 18.3.1 react: 18.3.1
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
react-intl: 6.6.2(react@18.3.1)(typescript@5.9.3) react-intl: 6.6.2(react@18.3.1)(typescript@5.9.3)
@@ -9265,7 +9270,7 @@ snapshots:
'@casl/ability': 6.7.5 '@casl/ability': 6.7.5
'@strapi/utils': 5.53.0(ts-toolbelt@9.6.0) '@strapi/utils': 5.53.0(ts-toolbelt@9.6.0)
lodash: 4.18.1 lodash: 4.18.1
qs: 6.15.3 qs: 6.16.0
sift: 16.0.1 sift: 16.0.1
transitivePeerDependencies: transitivePeerDependencies:
- ts-toolbelt - ts-toolbelt
@@ -9339,11 +9344,11 @@ snapshots:
'@strapi/provider-email-nodemailer@5.54.0': '@strapi/provider-email-nodemailer@5.54.0':
dependencies: dependencies:
nodemailer: 9.0.1 nodemailer: 9.1.1
'@strapi/provider-email-sendmail@5.53.0': '@strapi/provider-email-sendmail@5.53.0':
dependencies: dependencies:
nodemailer: 9.0.1 nodemailer: 9.1.1
'@strapi/provider-upload-local@5.53.0(ts-toolbelt@9.6.0)': '@strapi/provider-upload-local@5.53.0(ts-toolbelt@9.6.0)':
dependencies: dependencies:
@@ -9616,7 +9621,7 @@ snapshots:
lodash: 4.18.1 lodash: 4.18.1
mime-types: 2.1.35 mime-types: 2.1.35
prop-types: 15.8.1 prop-types: 15.8.1
qs: 6.15.3 qs: 6.16.0
react: 18.3.1 react: 18.3.1
react-dnd: 16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@20.19.43)(@types/react@18.3.31)(react@18.3.1) react-dnd: 16.0.1(@types/hoist-non-react-statics@3.3.7(@types/react@18.3.31))(@types/node@20.19.43)(@types/react@18.3.31)(react@18.3.1)
react-dom: 18.3.1(react@18.3.1) react-dom: 18.3.1(react@18.3.1)
@@ -9625,7 +9630,7 @@ snapshots:
react-redux: 8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1) react-redux: 8.1.3(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(redux@4.2.1)
react-router-dom: 6.30.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-router-dom: 6.30.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react-select: 5.8.0(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-select: 5.8.0(@types/react@18.3.31)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
sharp: 0.35.3(@types/node@20.19.43) sharp: 0.35.4(@types/node@20.19.43)
styled-components: 6.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1) styled-components: 6.5.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
yup: 0.32.9 yup: 0.32.9
zod: 4.4.3 zod: 4.4.3
@@ -10370,7 +10375,7 @@ snapshots:
http-errors: 2.0.1 http-errors: 2.0.1
iconv-lite: 0.7.3 iconv-lite: 0.7.3
on-finished: 2.4.1 on-finished: 2.4.1
qs: 6.15.3 qs: 6.16.0
raw-body: 3.0.2 raw-body: 3.0.2
type-is: 2.1.0 type-is: 2.1.0
transitivePeerDependencies: transitivePeerDependencies:
@@ -10554,7 +10559,7 @@ snapshots:
dependencies: dependencies:
'@hapi/bourne': 3.0.0 '@hapi/bourne': 3.0.0
inflation: 2.1.0 inflation: 2.1.0
qs: 6.15.3 qs: 6.16.0
raw-body: 2.5.3 raw-body: 2.5.3
type-is: 1.6.18 type-is: 1.6.18
@@ -11097,7 +11102,7 @@ snapshots:
once: 1.4.0 once: 1.4.0
parseurl: 1.3.3 parseurl: 1.3.3
proxy-addr: 2.0.7 proxy-addr: 2.0.7
qs: 6.15.3 qs: 6.16.0
range-parser: 1.3.0 range-parser: 1.3.0
router: 2.2.0 router: 2.2.0
send: 1.2.1 send: 1.2.1
@@ -11252,7 +11257,7 @@ snapshots:
'@paralleldrive/cuid2': 2.2.2 '@paralleldrive/cuid2': 2.2.2
dezalgo: 1.0.4 dezalgo: 1.0.4
once: 1.4.0 once: 1.4.0
qs: 6.15.3 qs: 6.16.0
formik@2.4.9(@types/react@18.3.31)(react@18.3.1): formik@2.4.9(@types/react@18.3.31)(react@18.3.1):
dependencies: dependencies:
@@ -12667,7 +12672,7 @@ snapshots:
long-timeout: 0.1.1 long-timeout: 0.1.1
sorted-array-functions: 1.3.0 sorted-array-functions: 1.3.0
nodemailer@9.0.1: {} nodemailer@9.1.1: {}
normalize-package-data@2.5.0: normalize-package-data@2.5.0:
dependencies: dependencies:
@@ -13069,7 +13074,7 @@ snapshots:
punycode@2.3.1: {} punycode@2.3.1: {}
qs@6.15.3: qs@6.16.0:
dependencies: dependencies:
es-define-property: 1.0.1 es-define-property: 1.0.1
side-channel: 1.1.1 side-channel: 1.1.1
@@ -13616,37 +13621,37 @@ snapshots:
dependencies: dependencies:
kind-of: 6.0.3 kind-of: 6.0.3
sharp@0.35.3(@types/node@20.19.43): sharp@0.35.4(@types/node@20.19.43):
dependencies: dependencies:
'@img/colour': 1.1.0 '@img/colour': 1.1.0
detect-libc: 2.1.2 detect-libc: 2.1.2
semver: 7.8.5 semver: 7.8.5
optionalDependencies: optionalDependencies:
'@img/sharp-darwin-arm64': 0.35.3 '@img/sharp-darwin-arm64': 0.35.4
'@img/sharp-darwin-x64': 0.35.3 '@img/sharp-darwin-x64': 0.35.4
'@img/sharp-freebsd-wasm32': 0.35.3 '@img/sharp-freebsd-wasm32': 0.35.4
'@img/sharp-libvips-darwin-arm64': 1.3.2 '@img/sharp-libvips-darwin-arm64': 1.3.3
'@img/sharp-libvips-darwin-x64': 1.3.2 '@img/sharp-libvips-darwin-x64': 1.3.3
'@img/sharp-libvips-linux-arm': 1.3.2 '@img/sharp-libvips-linux-arm': 1.3.3
'@img/sharp-libvips-linux-arm64': 1.3.2 '@img/sharp-libvips-linux-arm64': 1.3.3
'@img/sharp-libvips-linux-ppc64': 1.3.2 '@img/sharp-libvips-linux-ppc64': 1.3.3
'@img/sharp-libvips-linux-riscv64': 1.3.2 '@img/sharp-libvips-linux-riscv64': 1.3.3
'@img/sharp-libvips-linux-s390x': 1.3.2 '@img/sharp-libvips-linux-s390x': 1.3.3
'@img/sharp-libvips-linux-x64': 1.3.2 '@img/sharp-libvips-linux-x64': 1.3.3
'@img/sharp-libvips-linuxmusl-arm64': 1.3.2 '@img/sharp-libvips-linuxmusl-arm64': 1.3.3
'@img/sharp-libvips-linuxmusl-x64': 1.3.2 '@img/sharp-libvips-linuxmusl-x64': 1.3.3
'@img/sharp-linux-arm': 0.35.3 '@img/sharp-linux-arm': 0.35.4
'@img/sharp-linux-arm64': 0.35.3 '@img/sharp-linux-arm64': 0.35.4
'@img/sharp-linux-ppc64': 0.35.3 '@img/sharp-linux-ppc64': 0.35.4
'@img/sharp-linux-riscv64': 0.35.3 '@img/sharp-linux-riscv64': 0.35.4
'@img/sharp-linux-s390x': 0.35.3 '@img/sharp-linux-s390x': 0.35.4
'@img/sharp-linux-x64': 0.35.3 '@img/sharp-linux-x64': 0.35.4
'@img/sharp-linuxmusl-arm64': 0.35.3 '@img/sharp-linuxmusl-arm64': 0.35.4
'@img/sharp-linuxmusl-x64': 0.35.3 '@img/sharp-linuxmusl-x64': 0.35.4
'@img/sharp-webcontainers-wasm32': 0.35.3 '@img/sharp-webcontainers-wasm32': 0.35.4
'@img/sharp-win32-arm64': 0.35.3 '@img/sharp-win32-arm64': 0.35.4
'@img/sharp-win32-ia32': 0.35.3 '@img/sharp-win32-ia32': 0.35.4
'@img/sharp-win32-x64': 0.35.3 '@img/sharp-win32-x64': 0.35.4
'@types/node': 20.19.43 '@types/node': 20.19.43
shebang-command@2.0.0: shebang-command@2.0.0: