From eba419bfc2cccfb2f55bc396148875f05af5a2a8 Mon Sep 17 00:00:00 2001 From: Hisham Aburaqibah Date: Thu, 24 Sep 2026 11:51:24 +0200 Subject: [PATCH] fix: FIFO queue for waitlisting; send email for application form; EU logo in contact page in light mode --- README.md | 4 + .../content-types/application/schema.json | 2 +- .../api/application/services/application.ts | 63 +++- .../event-registration/schema.json | 2 +- .../middlewares/sanitize-submission.ts | 57 ++-- apps/cms/src/bootstrap/public-permissions.ts | 35 ++- apps/cms/src/index.ts | 7 +- apps/cms/src/lib/application-mail.ts | 134 +++++++++ apps/cms/src/lib/event-mail.ts | 23 +- apps/cms/src/lib/waitlist-promotion.ts | 203 +++++++++++++ apps/cms/types/generated/contentTypes.d.ts | 4 +- apps/web/app/lib/env.server.ts | 4 +- .../app/routes/_events-opportunities/copy.ts | 7 +- .../_events-opportunities/events.server.ts | 1 + .../routes/_events-opportunities/events.ts | 12 +- .../strapi-write.server.ts | 2 + .../submissions.server.ts | 102 ++++--- .../applications-and-opportunities.$slug.tsx | 6 +- .../routes/applications-and-opportunities.tsx | 8 +- apps/web/app/routes/contact.tsx | 19 +- apps/web/app/routes/events-calendar.$slug.tsx | 16 +- docker-compose.yml | 7 +- package.json | 5 + pnpm-lock.yaml | 283 +++++++++--------- 24 files changed, 762 insertions(+), 244 deletions(-) create mode 100644 apps/cms/src/lib/application-mail.ts create mode 100644 apps/cms/src/lib/waitlist-promotion.ts diff --git a/README.md b/README.md index 7be48df..69276eb 100644 --- a/README.md +++ b/README.md @@ -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 | | `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 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 diff --git a/apps/cms/src/api/application/content-types/application/schema.json b/apps/cms/src/api/application/content-types/application/schema.json index 62c271f..de3634a 100644 --- a/apps/cms/src/api/application/content-types/application/schema.json +++ b/apps/cms/src/api/application/content-types/application/schema.json @@ -13,7 +13,7 @@ "attributes": { "opportunity": { "type": "relation", - "relation": "oneToOne", + "relation": "manyToOne", "target": "api::opportunity.opportunity" }, "applicantName": { diff --git a/apps/cms/src/api/application/services/application.ts b/apps/cms/src/api/application/services/application.ts index 0d286d1..15a2fc6 100644 --- a/apps/cms/src/api/application/services/application.ts +++ b/apps/cms/src/api/application/services/application.ts @@ -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) { + 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; + }, +})); diff --git a/apps/cms/src/api/event-registration/content-types/event-registration/schema.json b/apps/cms/src/api/event-registration/content-types/event-registration/schema.json index 3846458..efe3dc7 100644 --- a/apps/cms/src/api/event-registration/content-types/event-registration/schema.json +++ b/apps/cms/src/api/event-registration/content-types/event-registration/schema.json @@ -13,7 +13,7 @@ "attributes": { "event": { "type": "relation", - "relation": "oneToOne", + "relation": "manyToOne", "target": "api::event.event" }, "fullName": { diff --git a/apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts b/apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts index cbb52ec..53c0649 100644 --- a/apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts +++ b/apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts @@ -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; diff --git a/apps/cms/src/bootstrap/public-permissions.ts b/apps/cms/src/bootstrap/public-permissions.ts index e64dcfd..47fe0b4 100644 --- a/apps/cms/src/bootstrap/public-permissions.ts +++ b/apps/cms/src/bootstrap/public-permissions.ts @@ -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 => { if (!strapi.plugins['users-permissions']) { @@ -63,7 +61,7 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise(); - const forbiddenReadActions = new Set(); + const forbiddenSubmissionActions = new Set(); 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 action)); @@ -112,12 +107,20 @@ export const ensurePublicPermissions = async (strapi: Core.Strapi): Promise 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(', ')}.` ); } }; diff --git a/apps/cms/src/index.ts b/apps/cms/src/index.ts index ee4039e..52de915 100644 --- a/apps/cms/src/index.ts +++ b/apps/cms/src/index.ts @@ -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.'); } }, }; diff --git a/apps/cms/src/lib/application-mail.ts b/apps/cms/src/lib/application-mail.ts new file mode 100644 index 0000000..e494e93 --- /dev/null +++ b/apps/cms/src/lib/application-mail.ts @@ -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 { + 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 { + 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)}`); + } +} \ No newline at end of file diff --git a/apps/cms/src/lib/event-mail.ts b/apps/cms/src/lib/event-mail.ts index 6fc6602..44c96f0 100644 --- a/apps/cms/src/lib/event-mail.ts +++ b/apps/cms/src/lib/event-mail.ts @@ -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, '''); +} + function wrapParagraphs(text: string): string { return text .trim() .split(/\n\n+/) - .map((paragraph) => `

${paragraph.trim()}

`) + .map((paragraph) => `

${escapeHtml(paragraph.trim())}

`) .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 { +}): Promise { 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; } } diff --git a/apps/cms/src/lib/waitlist-promotion.ts b/apps/cms/src/lib/waitlist-promotion.ts new file mode 100644 index 0000000..3b66df4 --- /dev/null +++ b/apps/cms/src/lib/waitlist-promotion.ts @@ -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 => + 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 { + const eventIds = new Set(); + + 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).`, + ); +} \ No newline at end of file diff --git a/apps/cms/types/generated/contentTypes.d.ts b/apps/cms/types/generated/contentTypes.d.ts index 1219892..1161e57 100644 --- a/apps/cms/types/generated/contentTypes.d.ts +++ b/apps/cms/types/generated/contentTypes.d.ts @@ -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<{ diff --git a/apps/web/app/lib/env.server.ts b/apps/web/app/lib/env.server.ts index 5fc0b0c..75488b8 100644 --- a/apps/web/app/lib/env.server.ts +++ b/apps/web/app/lib/env.server.ts @@ -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 = diff --git a/apps/web/app/routes/_events-opportunities/copy.ts b/apps/web/app/routes/_events-opportunities/copy.ts index 16cdb24..87a98cc 100644 --- a/apps/web/app/routes/_events-opportunities/copy.ts +++ b/apps/web/app/routes/_events-opportunities/copy.ts @@ -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", diff --git a/apps/web/app/routes/_events-opportunities/events.server.ts b/apps/web/app/routes/_events-opportunities/events.server.ts index 545b2ab..b082c91 100644 --- a/apps/web/app/routes/_events-opportunities/events.server.ts +++ b/apps/web/app/routes/_events-opportunities/events.server.ts @@ -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"), diff --git a/apps/web/app/routes/_events-opportunities/events.ts b/apps/web/app/routes/_events-opportunities/events.ts index 650640c..2d617c7 100644 --- a/apps/web/app/routes/_events-opportunities/events.ts +++ b/apps/web/app/routes/_events-opportunities/events.ts @@ -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" || diff --git a/apps/web/app/routes/_events-opportunities/strapi-write.server.ts b/apps/web/app/routes/_events-opportunities/strapi-write.server.ts index 0fddffa..065c5f6 100644 --- a/apps/web/app/routes/_events-opportunities/strapi-write.server.ts +++ b/apps/web/app/routes/_events-opportunities/strapi-write.server.ts @@ -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( 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); diff --git a/apps/web/app/routes/_events-opportunities/submissions.server.ts b/apps/web/app/routes/_events-opportunities/submissions.server.ts index 2b4e787..c4be183 100644 --- a/apps/web/app/routes/_events-opportunities/submissions.server.ts +++ b/apps/web/app/routes/_events-opportunities/submissions.server.ts @@ -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 { - 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 { - 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 { 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 }, + ); } diff --git a/apps/web/app/routes/applications-and-opportunities.$slug.tsx b/apps/web/app/routes/applications-and-opportunities.$slug.tsx index 269de93..8d06f05 100644 --- a/apps/web/app/routes/applications-and-opportunities.$slug.tsx +++ b/apps/web/app/routes/applications-and-opportunities.$slug.tsx @@ -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( diff --git a/apps/web/app/routes/applications-and-opportunities.tsx b/apps/web/app/routes/applications-and-opportunities.tsx index f37727d..1325629 100644 --- a/apps/web/app/routes/applications-and-opportunities.tsx +++ b/apps/web/app/routes/applications-and-opportunities.tsx @@ -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( diff --git a/apps/web/app/routes/contact.tsx b/apps/web/app/routes/contact.tsx index 5955306..9df7f2a 100644 --- a/apps/web/app/routes/contact.tsx +++ b/apps/web/app/routes/contact.tsx @@ -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 { +async function createContactSubmission( + values: ContactValues, + forwardedFor?: string, +): Promise { const env = getServerEnv(); const url = `${env.STRAPI_URL.replace(/\/+$/, "")}/api/contact-submissions`; @@ -240,6 +244,7 @@ async function createContactSubmission(values: ContactValues): Promise { "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} /> diff --git a/apps/web/app/routes/events-calendar.$slug.tsx b/apps/web/app/routes/events-calendar.$slug.tsx index 3924168..a2a5e07 100644 --- a/apps/web/app/routes/events-calendar.$slug.tsx +++ b/apps/web/app/routes/events-calendar.$slug.tsx @@ -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; } /* --------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index c4f421c..560c1a5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,7 +38,7 @@ services: ports: # Published on 5433 by default so it cannot collide with a PostgreSQL # already running on the host's 5432. - - "${POSTGRES_PUBLISHED_PORT:-5433}:5432" + - "127.0.0.1:${POSTGRES_PUBLISHED_PORT:-5433}:5432" healthcheck: # pg_isready also fails while the entrypoint's temporary init server is # up, so `service_healthy` really does mean "ready for connections". @@ -69,6 +69,7 @@ services: NODE_ENV: production HOST: 0.0.0.0 PORT: 1337 + IS_PROXIED: ${IS_PROXIED:-false} # Absolute URL the admin panel and generated links use. Change it # together with CMS_PORT, and set it to the public origin in production. PUBLIC_URL: ${CMS_PUBLIC_URL:-http://localhost:1337} @@ -99,7 +100,7 @@ services: # only removed by `down -v`. - cms-uploads:/repo/apps/cms/public/uploads 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, @@ -133,7 +134,7 @@ services: # local container use them without ever baking them into an artefact. - ./apps/web/public/fonts:/repo/apps/web/build/client/fonts:ro ports: - - "${WEB_PORT:-3000}:3000" + - "127.0.0.1:${WEB_PORT:-3000}:3000" volumes: postgres-data: diff --git a/package.json b/package.json index c2b0cab..2f16604 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,11 @@ "typecheck": "pnpm -r --stream typecheck" }, "pnpm": { + "overrides": { + "nodemailer": "9.1.1", + "qs": "6.16.0", + "sharp": "0.35.4" + }, "onlyBuiltDependencies": [ "@swc/core", "core-js-pure", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c843485..243f2e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,11 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + nodemailer: 9.1.1 + qs: 6.16.0 + sharp: 0.35.4 + importers: .: {} @@ -792,144 +797,144 @@ packages: resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} - '@img/sharp-darwin-arm64@0.35.3': - resolution: {integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==} + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [darwin] - '@img/sharp-darwin-x64@0.35.3': - resolution: {integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==} + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} engines: {node: '>=20.9.0'} cpu: [x64] os: [darwin] - '@img/sharp-freebsd-wasm32@0.35.3': - resolution: {integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==} + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} engines: {node: '>=20.9.0'} os: [freebsd] - '@img/sharp-libvips-darwin-arm64@1.3.2': - resolution: {integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==} + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} cpu: [arm64] os: [darwin] - '@img/sharp-libvips-darwin-x64@1.3.2': - resolution: {integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==} + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} cpu: [x64] os: [darwin] - '@img/sharp-libvips-linux-arm64@1.3.2': - resolution: {integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==} + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linux-arm@1.3.2': - resolution: {integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==} + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} cpu: [arm] os: [linux] - '@img/sharp-libvips-linux-ppc64@1.3.2': - resolution: {integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==} + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} cpu: [ppc64] os: [linux] - '@img/sharp-libvips-linux-riscv64@1.3.2': - resolution: {integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==} + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} cpu: [riscv64] os: [linux] - '@img/sharp-libvips-linux-s390x@1.3.2': - resolution: {integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==} + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} cpu: [s390x] os: [linux] - '@img/sharp-libvips-linux-x64@1.3.2': - resolution: {integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==} + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} cpu: [x64] os: [linux] - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': - resolution: {integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==} + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} cpu: [arm64] os: [linux] - '@img/sharp-libvips-linuxmusl-x64@1.3.2': - resolution: {integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==} + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} cpu: [x64] os: [linux] - '@img/sharp-linux-arm64@0.35.3': - resolution: {integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==} + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linux-arm@0.35.3': - resolution: {integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==} + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} engines: {node: '>=20.9.0'} cpu: [arm] os: [linux] - '@img/sharp-linux-ppc64@0.35.3': - resolution: {integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==} + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} engines: {node: '>=20.9.0'} cpu: [ppc64] os: [linux] - '@img/sharp-linux-riscv64@0.35.3': - resolution: {integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==} + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} engines: {node: '>=20.9.0'} cpu: [riscv64] os: [linux] - '@img/sharp-linux-s390x@0.35.3': - resolution: {integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==} + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} engines: {node: '>=20.9.0'} cpu: [s390x] os: [linux] - '@img/sharp-linux-x64@0.35.3': - resolution: {integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==} + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-linuxmusl-arm64@0.35.3': - resolution: {integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==} + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [linux] - '@img/sharp-linuxmusl-x64@0.35.3': - resolution: {integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==} + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [linux] - '@img/sharp-wasm32@0.35.3': - resolution: {integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==} + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} engines: {node: '>=20.9.0'} - '@img/sharp-webcontainers-wasm32@0.35.3': - resolution: {integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==} + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} engines: {node: '>=20.9.0'} cpu: [wasm32] - '@img/sharp-win32-arm64@0.35.3': - resolution: {integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==} + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} engines: {node: '>=20.9.0'} cpu: [arm64] os: [win32] - '@img/sharp-win32-ia32@0.35.3': - resolution: {integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==} + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} engines: {node: ^20.9.0} cpu: [ia32] os: [win32] - '@img/sharp-win32-x64@0.35.3': - resolution: {integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==} + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} engines: {node: '>=20.9.0'} cpu: [x64] os: [win32] @@ -5106,8 +5111,8 @@ packages: resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==} engines: {node: '>=6'} - nodemailer@9.0.1: - resolution: {integrity: sha512-Gwv8SQewT616ZM/URn0H54b8PWo/Wum7md3EW2aWy1lO27+WZCX+Xyak3J+NlmHUjDh5ME+uesJUDRbR3Ye8Bw==} + nodemailer@9.1.1: + resolution: {integrity: sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ==} engines: {node: '>=6.0.0'} normalize-package-data@2.5.0: @@ -5520,8 +5525,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.15.3: - resolution: {integrity: sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==} + qs@6.16.0: + resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} engines: {node: '>=0.6'} range-parser@1.3.0: @@ -5973,8 +5978,8 @@ packages: resolution: {integrity: sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==} engines: {node: '>=8'} - sharp@0.35.3: - resolution: {integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} engines: {node: '>=20.9.0'} peerDependencies: '@types/node': '*' @@ -7476,108 +7481,108 @@ snapshots: '@img/colour@1.1.0': {} - '@img/sharp-darwin-arm64@0.35.3': + '@img/sharp-darwin-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-arm64': 1.3.2 + '@img/sharp-libvips-darwin-arm64': 1.3.3 optional: true - '@img/sharp-darwin-x64@0.35.3': + '@img/sharp-darwin-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-darwin-x64': 1.3.2 + '@img/sharp-libvips-darwin-x64': 1.3.3 optional: true - '@img/sharp-freebsd-wasm32@0.35.3': + '@img/sharp-freebsd-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-libvips-darwin-arm64@1.3.2': + '@img/sharp-libvips-darwin-arm64@1.3.3': optional: true - '@img/sharp-libvips-darwin-x64@1.3.2': + '@img/sharp-libvips-darwin-x64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm64@1.3.2': + '@img/sharp-libvips-linux-arm64@1.3.3': optional: true - '@img/sharp-libvips-linux-arm@1.3.2': + '@img/sharp-libvips-linux-arm@1.3.3': optional: true - '@img/sharp-libvips-linux-ppc64@1.3.2': + '@img/sharp-libvips-linux-ppc64@1.3.3': optional: true - '@img/sharp-libvips-linux-riscv64@1.3.2': + '@img/sharp-libvips-linux-riscv64@1.3.3': optional: true - '@img/sharp-libvips-linux-s390x@1.3.2': + '@img/sharp-libvips-linux-s390x@1.3.3': optional: true - '@img/sharp-libvips-linux-x64@1.3.2': + '@img/sharp-libvips-linux-x64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-arm64@1.3.2': + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': optional: true - '@img/sharp-libvips-linuxmusl-x64@1.3.2': + '@img/sharp-libvips-linuxmusl-x64@1.3.3': optional: true - '@img/sharp-linux-arm64@0.35.3': + '@img/sharp-linux-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm64': 1.3.2 + '@img/sharp-libvips-linux-arm64': 1.3.3 optional: true - '@img/sharp-linux-arm@0.35.3': + '@img/sharp-linux-arm@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-arm': 1.3.2 + '@img/sharp-libvips-linux-arm': 1.3.3 optional: true - '@img/sharp-linux-ppc64@0.35.3': + '@img/sharp-linux-ppc64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-ppc64': 1.3.2 + '@img/sharp-libvips-linux-ppc64': 1.3.3 optional: true - '@img/sharp-linux-riscv64@0.35.3': + '@img/sharp-linux-riscv64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-riscv64': 1.3.2 + '@img/sharp-libvips-linux-riscv64': 1.3.3 optional: true - '@img/sharp-linux-s390x@0.35.3': + '@img/sharp-linux-s390x@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-s390x': 1.3.2 + '@img/sharp-libvips-linux-s390x': 1.3.3 optional: true - '@img/sharp-linux-x64@0.35.3': + '@img/sharp-linux-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linux-x64': 1.3.2 + '@img/sharp-libvips-linux-x64': 1.3.3 optional: true - '@img/sharp-linuxmusl-arm64@0.35.3': + '@img/sharp-linuxmusl-arm64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 optional: true - '@img/sharp-linuxmusl-x64@0.35.3': + '@img/sharp-linuxmusl-x64@0.35.4': optionalDependencies: - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 optional: true - '@img/sharp-wasm32@0.35.3': + '@img/sharp-wasm32@0.35.4': dependencies: '@emnapi/runtime': 1.11.3 optional: true - '@img/sharp-webcontainers-wasm32@0.35.3': + '@img/sharp-webcontainers-wasm32@0.35.4': dependencies: - '@img/sharp-wasm32': 0.35.3 + '@img/sharp-wasm32': 0.35.4 optional: true - '@img/sharp-win32-arm64@0.35.3': + '@img/sharp-win32-arm64@0.35.4': optional: true - '@img/sharp-win32-ia32@0.35.3': + '@img/sharp-win32-ia32@0.35.4': optional: true - '@img/sharp-win32-x64@0.35.3': + '@img/sharp-win32-x64@0.35.4': optional: true '@inquirer/external-editor@1.0.3(@types/node@20.19.43)': @@ -8707,7 +8712,7 @@ snapshots: p-map: 4.0.0 passport-local: 1.0.0 pluralize: 8.0.0 - qs: 6.15.3 + qs: 6.16.0 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 @@ -8819,7 +8824,7 @@ snapshots: markdown-it-sub: 1.0.0 markdown-it-sup: 1.0.0 prismjs: 1.30.0 - qs: 6.15.3 + qs: 6.16.0 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 @@ -8875,7 +8880,7 @@ snapshots: date-fns-tz: 2.0.1(date-fns@2.30.0) formik: 2.4.9(@types/react@18.3.31)(react@18.3.1) lodash: 4.18.1 - qs: 6.15.3 + qs: 6.16.0 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) @@ -8928,7 +8933,7 @@ snapshots: lodash: 4.18.1 micromatch: 4.0.8 pluralize: 8.0.0 - qs: 6.15.3 + qs: 6.16.0 react: 18.3.1 react-dom: 18.3.1(react@18.3.1) react-dropzone: 14.3.8(react@18.3.1) @@ -9011,7 +9016,7 @@ snapshots: ora: 5.4.1 package-json: 10.0.1 pkg-up: 3.1.0 - qs: 6.15.3 + qs: 6.16.0 resolve.exports: 2.0.2 semver: 7.7.4 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/utils': 5.53.0(ts-toolbelt@9.6.0) lodash: 4.18.1 - qs: 6.15.3 + qs: 6.16.0 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) @@ -9265,7 +9270,7 @@ snapshots: '@casl/ability': 6.7.5 '@strapi/utils': 5.53.0(ts-toolbelt@9.6.0) lodash: 4.18.1 - qs: 6.15.3 + qs: 6.16.0 sift: 16.0.1 transitivePeerDependencies: - ts-toolbelt @@ -9339,11 +9344,11 @@ snapshots: '@strapi/provider-email-nodemailer@5.54.0': dependencies: - nodemailer: 9.0.1 + nodemailer: 9.1.1 '@strapi/provider-email-sendmail@5.53.0': dependencies: - nodemailer: 9.0.1 + nodemailer: 9.1.1 '@strapi/provider-upload-local@5.53.0(ts-toolbelt@9.6.0)': dependencies: @@ -9616,7 +9621,7 @@ snapshots: lodash: 4.18.1 mime-types: 2.1.35 prop-types: 15.8.1 - qs: 6.15.3 + qs: 6.16.0 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) @@ -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-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) - 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) yup: 0.32.9 zod: 4.4.3 @@ -10370,7 +10375,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.3 on-finished: 2.4.1 - qs: 6.15.3 + qs: 6.16.0 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -10554,7 +10559,7 @@ snapshots: dependencies: '@hapi/bourne': 3.0.0 inflation: 2.1.0 - qs: 6.15.3 + qs: 6.16.0 raw-body: 2.5.3 type-is: 1.6.18 @@ -11097,7 +11102,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.15.3 + qs: 6.16.0 range-parser: 1.3.0 router: 2.2.0 send: 1.2.1 @@ -11252,7 +11257,7 @@ snapshots: '@paralleldrive/cuid2': 2.2.2 dezalgo: 1.0.4 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): dependencies: @@ -12667,7 +12672,7 @@ snapshots: long-timeout: 0.1.1 sorted-array-functions: 1.3.0 - nodemailer@9.0.1: {} + nodemailer@9.1.1: {} normalize-package-data@2.5.0: dependencies: @@ -13069,7 +13074,7 @@ snapshots: punycode@2.3.1: {} - qs@6.15.3: + qs@6.16.0: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 @@ -13616,37 +13621,37 @@ snapshots: dependencies: kind-of: 6.0.3 - sharp@0.35.3(@types/node@20.19.43): + sharp@0.35.4(@types/node@20.19.43): dependencies: '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.8.5 optionalDependencies: - '@img/sharp-darwin-arm64': 0.35.3 - '@img/sharp-darwin-x64': 0.35.3 - '@img/sharp-freebsd-wasm32': 0.35.3 - '@img/sharp-libvips-darwin-arm64': 1.3.2 - '@img/sharp-libvips-darwin-x64': 1.3.2 - '@img/sharp-libvips-linux-arm': 1.3.2 - '@img/sharp-libvips-linux-arm64': 1.3.2 - '@img/sharp-libvips-linux-ppc64': 1.3.2 - '@img/sharp-libvips-linux-riscv64': 1.3.2 - '@img/sharp-libvips-linux-s390x': 1.3.2 - '@img/sharp-libvips-linux-x64': 1.3.2 - '@img/sharp-libvips-linuxmusl-arm64': 1.3.2 - '@img/sharp-libvips-linuxmusl-x64': 1.3.2 - '@img/sharp-linux-arm': 0.35.3 - '@img/sharp-linux-arm64': 0.35.3 - '@img/sharp-linux-ppc64': 0.35.3 - '@img/sharp-linux-riscv64': 0.35.3 - '@img/sharp-linux-s390x': 0.35.3 - '@img/sharp-linux-x64': 0.35.3 - '@img/sharp-linuxmusl-arm64': 0.35.3 - '@img/sharp-linuxmusl-x64': 0.35.3 - '@img/sharp-webcontainers-wasm32': 0.35.3 - '@img/sharp-win32-arm64': 0.35.3 - '@img/sharp-win32-ia32': 0.35.3 - '@img/sharp-win32-x64': 0.35.3 + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 '@types/node': 20.19.43 shebang-command@2.0.0: