107 lines
4.7 KiB
TypeScript
107 lines
4.7 KiB
TypeScript
/* ============================================================================
|
|||
|
|
FORM SUBMISSIONS -> STRAPI. SERVER ONLY.
|
||
|
|
----------------------------------------------------------------------------
|
||
|
|
The two public forms of this section write into their own collections. Both
|
||
|
|
are ordinary <Form method="post"> actions, so they work with scripting off.
|
||
|
|
|
||
|
|
THE PAYLOAD IS THE WHITELIST, NOTHING MORE
|
||
|
|
Each collection has a `sanitize-submission` middleware in the CMS that
|
||
|
|
copies out a fixed set of fields and DROPS everything else, then sets the
|
||
|
|
workflow state and the timestamps server-side. Sending a field that is not
|
||
|
|
on the list is not merely ignored - a required field that is missing is a
|
||
|
|
400 and the reader loses what they typed. So the two payloads below are
|
||
|
|
exactly the whitelists, read from:
|
||
|
|
|
||
|
|
apps/cms/src/api/event-registration/middlewares/sanitize-submission.ts
|
||
|
|
event · fullName · email · phone · organisation · role ·
|
||
|
|
dietaryOrAccess · consentGdpr
|
||
|
|
|
||
|
|
apps/cms/src/api/application/middlewares/sanitize-submission.ts
|
||
|
|
opportunity · applicantName · email · phone · organisation ·
|
||
|
|
organisationType · answers
|
||
|
|
|
||
|
|
`attachments` exists on the `application` content type but is NOT on that
|
||
|
|
whitelist and must never be sent: it is a media relation, so its value is a
|
||
|
|
list of ids of files already in the Media Library, and an unauthenticated
|
||
|
|
caller who could set it would bind arbitrary uploads onto a row reviewers
|
||
|
|
open. A public upload flow needs its own authenticated endpoint first.
|
||
|
|
|
||
|
|
There is no `language` field on either type ("locale" is reserved by the
|
||
|
|
i18n plugin and `language` was never modelled), and no `waitlist` boolean.
|
||
|
|
|
||
|
|
THE ENDPOINTS
|
||
|
|
The URL is the content type's PLURAL name:
|
||
|
|
api::event-registration.event-registration -> /api/event-registrations
|
||
|
|
api::application.application -> /api/applications
|
||
|
|
(not "opportunity-applications", which does not exist and 404s).
|
||
|
|
|
||
|
|
WAITLIST IS DERIVED SERVER-SIDE, NEVER SENT
|
||
|
|
`registrationState` is not on the registration whitelist, and it is not an
|
||
|
|
omission to work around: the middleware works it out from the event itself -
|
||
|
|
places taken vs `capacity`, plus `registrationStatus` / `waitlistEnabled` -
|
||
|
|
and answers `confirmed`, `waitlisted`, or a 400 when the event is full with
|
||
|
|
no waitlist to offer. That decision needs a count no client can be trusted
|
||
|
|
to report, so this module sends only what the registrant actually supplied
|
||
|
|
and lets the CMS decide what it is worth.
|
||
|
|
|
||
|
|
PERMISSIONS
|
||
|
|
`create` on both collections is granted to the Public role; `find` /
|
||
|
|
`findOne` are NOT, or anyone could read everyone else's contact details.
|
||
|
|
========================================================================= */
|
||
|
|
|
||
|
|
import type { ApplicationValues, RegistrationValues } from "./forms";
|
||
|
|
import { createStrapiEntry } from "./strapi-write.server";
|
||
|
|
|
||
|
|
export const EVENT_REGISTRATIONS_RESOURCE = "event-registrations";
|
||
|
|
export const APPLICATIONS_RESOURCE = "applications";
|
||
|
|
|
||
|
|
/** Strapi rejects "" on an email field; absent is the honest value. */
|
||
|
|
function optional(value: string): string | undefined {
|
||
|
|
return value.length > 0 ? value : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface RegistrationSubmission {
|
||
|
|
readonly values: RegistrationValues;
|
||
|
|
/** documentId of the event being registered for. */
|
||
|
|
readonly eventId: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function submitRegistration(
|
||
|
|
input: RegistrationSubmission,
|
||
|
|
): Promise<void> {
|
||
|
|
await createStrapiEntry(EVENT_REGISTRATIONS_RESOURCE, {
|
||
|
|
event: input.eventId,
|
||
|
|
fullName: input.values.fullName,
|
||
|
|
email: input.values.email,
|
||
|
|
phone: optional(input.values.phone),
|
||
|
|
organisation: optional(input.values.organisation),
|
||
|
|
// Required in the schema: without it every POST is a 400.
|
||
|
|
consentGdpr: input.values.consentGdpr,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
export interface ApplicationSubmission {
|
||
|
|
readonly values: ApplicationValues;
|
||
|
|
/** documentId of the opportunity being applied to. */
|
||
|
|
readonly opportunityId: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** The key the free-text answer is filed under inside `answers` (json). */
|
||
|
|
export const APPLICATION_MESSAGE_KEY = "message";
|
||
|
|
|
||
|
|
export async function submitApplication(
|
||
|
|
input: ApplicationSubmission,
|
||
|
|
): Promise<void> {
|
||
|
|
await createStrapiEntry(APPLICATIONS_RESOURCE, {
|
||
|
|
opportunity: input.opportunityId,
|
||
|
|
// `fullName` is the registration's field name; an application's is
|
||
|
|
// `applicantName`.
|
||
|
|
applicantName: input.values.fullName,
|
||
|
|
email: input.values.email,
|
||
|
|
organisation: optional(input.values.organisation),
|
||
|
|
// `answers` is a json field - the form's one free-text question goes in
|
||
|
|
// it, keyed, so more questions can be added without another migration.
|
||
|
|
answers: { [APPLICATION_MESSAGE_KEY]: input.values.message },
|
||
|
|
});
|
||
|
|
}
|