fix: FIFO queue for waitlisting; send email for application form; EU logo in contact page in light mode
This commit is contained in:
@@ -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 }) => {
|
||||||
data.submittedAt = new Date().toISOString();
|
const event = await findEvent(strapi, readEventReference(data.event));
|
||||||
data.reminderSentAt = null;
|
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;
|
export default sanitizeSubmission;
|
||||||
|
|||||||
@@ -35,11 +35,9 @@ const desiredActionsFor = (controllerName: string): string[] =>
|
|||||||
* Grant the Public role read access to the public content (collection types and
|
* 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(', ')}.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* application-mail — bilingual acknowledgement e-mail for applications.
|
||||||
|
*
|
||||||
|
* The web copy promises every application is "acknowledged automatically by
|
||||||
|
* email" (apps/web/app/routes/_events-opportunities/copy.ts:
|
||||||
|
* `apply.acknowledgement` / `apply.receivedBody`), and the content type models
|
||||||
|
* that with `application.acknowledgementSentAt`. This module is the half that
|
||||||
|
* delivers: fired fire-and-forget from the application service's `create`
|
||||||
|
* override, mirroring the event-registration confirmation, and stamping
|
||||||
|
* `acknowledgementSentAt` ONLY after the mail was accepted, so a failure is
|
||||||
|
* retried by nothing today but is visible in the admin panel as a null.
|
||||||
|
*
|
||||||
|
* The (Get Involved) section form can submit an application with no
|
||||||
|
* opportunity relation. The mail then drops the call name and stays a plain
|
||||||
|
* acknowledgement, so the promise holds for both entry points.
|
||||||
|
*
|
||||||
|
* Copy is derived from the approved web copy; the footer is the same
|
||||||
|
* EU-visibility block the event mails use. Nothing here is invented.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Core } from '@strapi/strapi';
|
||||||
|
|
||||||
|
import { buildBilingualMail, trySendMail } from './event-mail';
|
||||||
|
|
||||||
|
const OPPORTUNITY_UID = 'api::opportunity.opportunity';
|
||||||
|
const APPLICATION_UID = 'api::application.application';
|
||||||
|
|
||||||
|
const LOG_PREFIX = '[application-mail]';
|
||||||
|
|
||||||
|
/** Identifier a public form may have sent for the opportunity relation. */
|
||||||
|
type OpportunityReference = { documentId?: string; id?: number } | undefined;
|
||||||
|
|
||||||
|
/** An opportunity looked up in both locales; missing locales are allowed. */
|
||||||
|
type BilingualOpportunity = { en?: { title?: string | null }; ar?: { title?: string | null } };
|
||||||
|
|
||||||
|
const ACKNOWLEDGEMENT_SUBJECT = {
|
||||||
|
ar: 'تم استلام الطلب',
|
||||||
|
en: 'Application received',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The approved wording this mirrors, from
|
||||||
|
* apps/web/app/routes/_events-opportunities/copy.ts:
|
||||||
|
* applicationReceivedBody en: "Thank you for your application. Our team
|
||||||
|
* will review it and follow up by email."
|
||||||
|
* ar: "شكرًا لتقديم طلبك. سيراجعه فريقنا ويتواصل
|
||||||
|
* معك عبر البريد الإلكتروني."
|
||||||
|
* receivedBody ar: "شكرًا لتقديمك على {route}..." (the "على"
|
||||||
|
* preposition is kept for the call name).
|
||||||
|
*/
|
||||||
|
const ACKNOWLEDGEMENT_BODY_WITH_CALL = {
|
||||||
|
ar: 'شكرًا لك — استلمنا طلبك على {call}. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
|
||||||
|
en: 'Thank you — we have received your application for {call}. Our team will review it and follow up by email.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ACKNOWLEDGEMENT_BODY = {
|
||||||
|
ar: 'شكرًا لك — استلمنا طلبك. سيراجع فريقنا طلبك ويتواصل معك عبر البريد الإلكتروني.',
|
||||||
|
en: 'Thank you — we have received your application. Our team will review it and follow up by email.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch an opportunity in both locales. Only published entries are ever
|
||||||
|
* fetched; either a documentId or a legacy numeric id is accepted, mirroring
|
||||||
|
* the event-registration middleware. Returns an empty record when there is no
|
||||||
|
* usable reference (the Get Involved form sends none).
|
||||||
|
*/
|
||||||
|
export async function fetchBilingualOpportunity(
|
||||||
|
strapi: Core.Strapi,
|
||||||
|
reference: OpportunityReference,
|
||||||
|
): Promise<BilingualOpportunity> {
|
||||||
|
const filters = reference?.documentId
|
||||||
|
? { documentId: reference.documentId }
|
||||||
|
: reference?.id !== undefined
|
||||||
|
? { id: reference.id }
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!filters) return {};
|
||||||
|
|
||||||
|
const [en, ar] = await Promise.all([
|
||||||
|
strapi.documents(OPPORTUNITY_UID).findFirst({ filters, locale: 'en' }),
|
||||||
|
strapi.documents(OPPORTUNITY_UID).findFirst({ filters, locale: 'ar' }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { en: en ?? undefined, ar: ar ?? undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build and send the acknowledgement for an application, and stamp
|
||||||
|
* `acknowledgementSentAt` when the provider accepted it. Best-effort and
|
||||||
|
* never awaited by the request path: a slow or failing SMTP call must not make
|
||||||
|
* the application POST fail.
|
||||||
|
*/
|
||||||
|
export async function sendApplicationAcknowledgement({
|
||||||
|
strapi,
|
||||||
|
to,
|
||||||
|
applicationDocumentId,
|
||||||
|
opportunityReference,
|
||||||
|
}: {
|
||||||
|
strapi: Core.Strapi;
|
||||||
|
to: string;
|
||||||
|
applicationDocumentId: string;
|
||||||
|
opportunityReference: OpportunityReference;
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const opportunity = await fetchBilingualOpportunity(strapi, opportunityReference);
|
||||||
|
|
||||||
|
const callEn = opportunity.en?.title || undefined;
|
||||||
|
const callAr = opportunity.ar?.title || opportunity.en?.title || undefined;
|
||||||
|
|
||||||
|
const mail = buildBilingualMail({
|
||||||
|
subjectAr: ACKNOWLEDGEMENT_SUBJECT.ar,
|
||||||
|
subjectEn: ACKNOWLEDGEMENT_SUBJECT.en,
|
||||||
|
bodyAr: (callAr ? ACKNOWLEDGEMENT_BODY_WITH_CALL.ar : ACKNOWLEDGEMENT_BODY.ar)
|
||||||
|
.replace(/\{call\}/g, callAr ?? ''),
|
||||||
|
bodyEn: (callEn ? ACKNOWLEDGEMENT_BODY_WITH_CALL.en : ACKNOWLEDGEMENT_BODY.en)
|
||||||
|
.replace(/\{call\}/g, callEn ?? ''),
|
||||||
|
});
|
||||||
|
|
||||||
|
const ok = await trySendMail(strapi, { to, ...mail });
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await strapi
|
||||||
|
.documents(APPLICATION_UID)
|
||||||
|
.update({
|
||||||
|
documentId: applicationDocumentId,
|
||||||
|
data: { acknowledgementSentAt: new Date().toISOString() },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
strapi.log.error(`${LOG_PREFIX} acknowledgement failed: ${String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,11 +136,20 @@ const FOOTER = {
|
|||||||
const BASE_FONT =
|
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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* waitlist-promotion — the cron task that honours the "we will email you if a
|
||||||
|
* place becomes available" promise.
|
||||||
|
*
|
||||||
|
* Registrations are stamped `confirmed` OR `waitlisted` at signup
|
||||||
|
* (event-registration/middlewares/sanitize-submission.ts). Nothing ever moved
|
||||||
|
* a `waitlisted` row to `confirmed` when a seat freed up, so the waitlist was
|
||||||
|
* a queue nobody drained. This job does it, first-come-first-served:
|
||||||
|
*
|
||||||
|
* 1. find every event that still has `waitlisted` registrations;
|
||||||
|
* 2. for each, lock the event row (the same serialisation the registration
|
||||||
|
* middleware uses, so a concurrent POST cannot race the count) and
|
||||||
|
* compute how many seats are left below capacity;
|
||||||
|
* 3. promote the OLDEST waitlisted registrations (`submittedAt` asc) into
|
||||||
|
* those seats, sending the standard confirmation mail first and flipping
|
||||||
|
* the row to `confirmed` ONLY after the mail was accepted.
|
||||||
|
*
|
||||||
|
* Semantics are at-least-once, like the reminder job: a row whose mail failed
|
||||||
|
* stays `waitlisted` and is retried on the next run. A waitlisted row with no
|
||||||
|
* email cannot be told anything, so it is left as-is for staff to deal with.
|
||||||
|
*
|
||||||
|
* Registered from src/index.ts only when `server.cron.enabled` is true.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Core } from '@strapi/strapi';
|
||||||
|
|
||||||
|
import { sendRegistrationConfirmation } from './event-mail';
|
||||||
|
|
||||||
|
const EVENT_UID = 'api::event.event';
|
||||||
|
const REGISTRATION_UID = 'api::event-registration.event-registration';
|
||||||
|
|
||||||
|
const LOG_PREFIX = '[waitlist-promotion]';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
/** The subset of the event that decides how many seats can be promoted into. */
|
||||||
|
type EventCapacity = {
|
||||||
|
documentId: string;
|
||||||
|
capacity: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** How many places on this event are already taken, mirroring the middleware. */
|
||||||
|
const countConfirmed = async (strapi: Core.Strapi, documentId: string): Promise<number> =>
|
||||||
|
strapi.db.query(REGISTRATION_UID).count({
|
||||||
|
where: {
|
||||||
|
event: { documentId },
|
||||||
|
registrationState: 'confirmed',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Promote the oldest waiting registrants of ONE event into its free seats.
|
||||||
|
*
|
||||||
|
* The lock mirrors `sanitize-submission`: registering and promoting both
|
||||||
|
* serialize on the event row, so a concurrent request cannot steal a seat the
|
||||||
|
* promotion just counted. Count and updates run inside the same transaction.
|
||||||
|
*/
|
||||||
|
async function promoteEvent(
|
||||||
|
strapi: Core.Strapi,
|
||||||
|
eventDocumentId: string,
|
||||||
|
): Promise<{ promoted: number; failed: number }> {
|
||||||
|
return strapi.db.transaction(async ({ trx }) => {
|
||||||
|
await trx('events')
|
||||||
|
.select('id')
|
||||||
|
.where({ document_id: eventDocumentId })
|
||||||
|
.orderBy('id')
|
||||||
|
.forUpdate()
|
||||||
|
.first();
|
||||||
|
|
||||||
|
const event = (await strapi.documents(EVENT_UID).findFirst({
|
||||||
|
filters: { documentId: eventDocumentId },
|
||||||
|
status: 'published',
|
||||||
|
})) as EventCapacity | null;
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return { promoted: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const capacity =
|
||||||
|
typeof event.capacity === 'number' && event.capacity > 0 ? event.capacity : null;
|
||||||
|
|
||||||
|
// No finite capacity means there is never a seat to free up.
|
||||||
|
if (capacity === null) {
|
||||||
|
return { promoted: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const taken = await countConfirmed(strapi, eventDocumentId);
|
||||||
|
let slots = capacity - taken;
|
||||||
|
if (slots <= 0) {
|
||||||
|
return { promoted: 0, failed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oldest waiters first; the page size is a safety bound, the loop stops
|
||||||
|
// at `slots` anyway.
|
||||||
|
const registrations = await strapi
|
||||||
|
.documents(REGISTRATION_UID)
|
||||||
|
.findMany({
|
||||||
|
filters: {
|
||||||
|
event: { documentId: eventDocumentId },
|
||||||
|
registrationState: { $eq: 'waitlisted' },
|
||||||
|
},
|
||||||
|
sort: 'submittedAt:asc',
|
||||||
|
pagination: { page: 1, pageSize: PAGE_SIZE },
|
||||||
|
});
|
||||||
|
|
||||||
|
let promoted = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (const registration of registrations) {
|
||||||
|
if (slots <= 0) break;
|
||||||
|
|
||||||
|
const email = (registration as { email?: unknown }).email;
|
||||||
|
if (typeof email !== 'string' || email.length === 0) {
|
||||||
|
strapi.log.warn(
|
||||||
|
`${LOG_PREFIX} registration ${registration.documentId} has no email; left waitlisted.`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = await sendRegistrationConfirmation({
|
||||||
|
strapi,
|
||||||
|
to: email,
|
||||||
|
state: 'confirmed',
|
||||||
|
eventReference: { documentId: eventDocumentId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!ok) {
|
||||||
|
failed += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await strapi
|
||||||
|
.documents(REGISTRATION_UID)
|
||||||
|
.update({
|
||||||
|
documentId: registration.documentId as string,
|
||||||
|
data: { registrationState: 'confirmed' },
|
||||||
|
});
|
||||||
|
|
||||||
|
promoted += 1;
|
||||||
|
slots -= 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { promoted, failed };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run one pass over every event that still has waitlisted registrations.
|
||||||
|
* One failing event never stops the batch; totals are logged.
|
||||||
|
*/
|
||||||
|
export async function runWaitlistPromotion({
|
||||||
|
strapi,
|
||||||
|
}: {
|
||||||
|
strapi: Core.Strapi;
|
||||||
|
}): Promise<void> {
|
||||||
|
const eventIds = new Set<string>();
|
||||||
|
|
||||||
|
let page = 1;
|
||||||
|
while (true) {
|
||||||
|
const registrations = await strapi
|
||||||
|
.documents(REGISTRATION_UID)
|
||||||
|
.findMany({
|
||||||
|
filters: { registrationState: { $eq: 'waitlisted' } },
|
||||||
|
populate: { event: true },
|
||||||
|
pagination: { page, pageSize: PAGE_SIZE },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const registration of registrations) {
|
||||||
|
const event = (registration as { event?: { documentId?: unknown } }).event;
|
||||||
|
if (typeof event?.documentId === 'string' && event.documentId.length > 0) {
|
||||||
|
eventIds.add(event.documentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registrations.length < PAGE_SIZE) break;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eventIds.size === 0) {
|
||||||
|
strapi.log.info(`${LOG_PREFIX} no events with a waitlist, skipping.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let promotedTotal = 0;
|
||||||
|
let failedTotal = 0;
|
||||||
|
|
||||||
|
for (const eventId of eventIds) {
|
||||||
|
try {
|
||||||
|
const { promoted, failed } = await promoteEvent(strapi, eventId);
|
||||||
|
promotedTotal += promoted;
|
||||||
|
failedTotal += failed;
|
||||||
|
} catch (error) {
|
||||||
|
failedTotal += 1;
|
||||||
|
strapi.log.error(
|
||||||
|
`${LOG_PREFIX} failed for event ${eventId}: ${String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
strapi.log.info(
|
||||||
|
`${LOG_PREFIX} finished: ${promotedTotal} waitlisted registration(s) promoted, ${failedTotal} mail(s) failed (retried next run).`,
|
||||||
|
);
|
||||||
|
}
|
||||||
+2
-2
@@ -707,7 +707,7 @@ export interface ApiApplicationApplication extends Struct.CollectionTypeSchema {
|
|||||||
> &
|
> &
|
||||||
Schema.Attribute.Private;
|
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<{
|
||||||
|
|||||||
@@ -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,26 +68,43 @@ 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<{
|
||||||
event: input.eventId,
|
readonly registrationState?: unknown;
|
||||||
fullName: input.values.fullName,
|
}>(
|
||||||
email: input.values.email,
|
EVENT_REGISTRATIONS_RESOURCE,
|
||||||
phone: optional(input.values.phone),
|
{
|
||||||
organisation: optional(input.values.organisation),
|
event: input.eventId,
|
||||||
// Required in the schema: without it every POST is a 400.
|
fullName: input.values.fullName,
|
||||||
consentGdpr: input.values.consentGdpr,
|
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 {
|
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,17 +113,21 @@ 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(
|
||||||
opportunity: input.opportunityId,
|
APPLICATIONS_RESOURCE,
|
||||||
// `fullName` is the registration's field name; an application's is
|
{
|
||||||
// `applicantName`.
|
opportunity: input.opportunityId,
|
||||||
applicantName: input.values.fullName,
|
// `fullName` is the registration's field name; an application's is
|
||||||
email: input.values.email,
|
// `applicantName`.
|
||||||
organisation: optional(input.values.organisation),
|
applicantName: input.values.fullName,
|
||||||
// `answers` is a json field - the form's one free-text question goes in
|
email: input.values.email,
|
||||||
// it, keyed, so more questions can be added without another migration.
|
organisation: optional(input.values.organisation),
|
||||||
answers: { [APPLICATION_MESSAGE_KEY]: input.values.message },
|
// `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 {
|
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,22 +181,26 @@ export async function submitPathwayApplication(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { values } = input;
|
const { values } = input;
|
||||||
|
|
||||||
await createStrapiEntry(APPLICATIONS_RESOURCE, {
|
await createStrapiEntry(
|
||||||
...(input.opportunityId ? { opportunity: input.opportunityId } : {}),
|
APPLICATIONS_RESOURCE,
|
||||||
applicantName: values.fullName,
|
{
|
||||||
email: values.email,
|
...(input.opportunityId ? { opportunity: input.opportunityId } : {}),
|
||||||
organisation: optional(values.organisation),
|
applicantName: values.fullName,
|
||||||
answers: {
|
email: values.email,
|
||||||
[APPLICATION_ANSWER_KEYS.route]: values.route,
|
organisation: optional(values.organisation),
|
||||||
// Only the answers actually given: an empty string in the admin panel
|
answers: {
|
||||||
// reads as "answered, with nothing", which is not what happened.
|
[APPLICATION_ANSWER_KEYS.route]: values.route,
|
||||||
...(values.region
|
// Only the answers actually given: an empty string in the admin panel
|
||||||
? { [APPLICATION_ANSWER_KEYS.region]: values.region }
|
// reads as "answered, with nothing", which is not what happened.
|
||||||
: {}),
|
...(values.region
|
||||||
...(values.yearsOperating
|
? { [APPLICATION_ANSWER_KEYS.region]: values.region }
|
||||||
? { [APPLICATION_ANSWER_KEYS.yearsOperating]: values.yearsOperating }
|
: {}),
|
||||||
: {}),
|
...(values.yearsOperating
|
||||||
[APPLICATION_ANSWER_KEYS.summary]: values.summary,
|
? { [APPLICATION_ANSWER_KEYS.yearsOperating]: values.yearsOperating }
|
||||||
|
: {}),
|
||||||
|
[APPLICATION_ANSWER_KEYS.summary]: values.summary,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
{ forwardedFor: input.forwardedFor },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,11 @@ export async function action({ params, request }: Route.ActionArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+144
-139
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user