/** * event-registration sanitize-submission middleware * * The public role may create event registrations, so the request body is * attacker-controlled. Only the fields a registrant is allowed to supply are * copied out of it; the workflow state and the timestamps are set here, on the * server, and anything else the caller sent is dropped. * * REGISTRATION STATE IS DERIVED, NEVER SENT * `registrationState` used to be hard-set to `confirmed`, which meant a FULL * event silently confirmed everyone and staff had to unpick it by hand. It is * now worked out here, from the event itself: * * capacity reached (or the editors marked the event `full`) * + a waitlist -> `waitlisted` * + no waitlist -> the request is REFUSED with a 400 * otherwise -> `confirmed` * * This is the only place that decision can safely be made. Putting it in the * browser would let anyone confirm a place on a sold-out event with one * `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. */ import type { Core } from '@strapi/strapi'; import { errors } from '@strapi/utils'; const CLIENT_FIELDS = [ 'event', 'fullName', 'email', 'phone', 'organisation', 'role', 'dietaryOrAccess', 'consentGdpr', ] as const; const EVENT_UID = 'api::event.event'; const REGISTRATION_UID = 'api::event-registration.event-registration'; /** The subset of the event that decides whether there is a place left. */ type EventCapacity = { documentId: string; capacity: number | null; waitlistEnabled: boolean | null; registrationStatus: string | null; }; /** * The `event` value a client may send is a documentId (what the REST API uses * for relations), and older clients may send a numeric id. Both are accepted; * anything else is treated as "no event given". */ const readEventReference = (value: unknown): { documentId?: string; id?: number } => { if (typeof value === 'string' && value.length > 0) { return { documentId: value }; } if (typeof value === 'number' && Number.isInteger(value)) { return { id: value }; } // `{ connect: [...] }` / `{ set: [...] }` and arrays are relation-write // shapes a public form has no business sending, so they resolve to nothing // and the registration is treated as having no known event. return {}; }; /** * `capacity`, `waitlistEnabled` and `registrationStatus` are not localised, so * any row sharing the documentId carries the same values and the first match * is enough. */ const findEvent = async ( strapi: Core.Strapi, reference: { documentId?: string; id?: number } ): Promise => { if (!reference.documentId && reference.id === undefined) { return null; } const where = reference.documentId ? { documentId: reference.documentId } : { id: reference.id }; return (await strapi.db.query(EVENT_UID).findOne({ where, select: ['documentId', 'capacity', 'waitlistEnabled', 'registrationStatus'], })) as EventCapacity | null; }; /** How many places on this event are already taken. */ const countConfirmed = async (strapi: Core.Strapi, documentId: string): Promise => strapi.db.query(REGISTRATION_UID).count({ where: { event: { documentId }, registrationState: 'confirmed', }, }); type RegistrationState = 'confirmed' | 'waitlisted'; /** * Work out the state this registration is entitled to. * * Throws `ApplicationError` (HTTP 400) when the event has no place and no * waitlist to offer - the honest answer to "may I register?" is no, and * storing a row that says `confirmed` would be a lie to both the registrant * and the staff reading the list. */ const resolveRegistrationState = async ( strapi: Core.Strapi, eventValue: unknown ): Promise => { 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'; } if (event.registrationStatus === 'closed') { throw new errors.ApplicationError('Registration for this event is closed.'); } const capacity = typeof event.capacity === 'number' && event.capacity > 0 ? event.capacity : null; const taken = capacity === null ? 0 : await countConfirmed(strapi, event.documentId); const isFull = event.registrationStatus === 'full' || (capacity !== null && taken >= capacity); if (!isFull) { return 'confirmed'; } const waitlistOpen = event.waitlistEnabled === true || event.registrationStatus === 'waitlist'; if (!waitlistOpen) { throw new errors.ApplicationError('This event is full and has no waitlist.'); } return 'waitlisted'; }; const sanitizeSubmission: Core.MiddlewareFactory = (_config, { strapi }) => async (ctx, next) => { const request = ctx.request as typeof ctx.request & { body?: unknown }; const body = (request.body ?? {}) as { data?: unknown }; const input = (typeof body.data === 'object' && body.data !== null ? body.data : {}) as Record< string, unknown >; const data: Record = {}; for (const field of CLIENT_FIELDS) { if (field in input) { data[field] = input[field]; } } data.registrationState = await resolveRegistrationState(strapi, data.event); data.submittedAt = new Date().toISOString(); data.reminderSentAt = null; request.body = { data }; await next(); }; export default sanitizeSubmission;