181 lines
5.8 KiB
TypeScript
181 lines
5.8 KiB
TypeScript
/**
|
|||
|
|
* 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<EventCapacity | null> => {
|
||
|
|
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<number> =>
|
||
|
|
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<RegistrationState> => {
|
||
|
|
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<string, unknown> = {};
|
||
|
|
|
||
|
|
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;
|