2026-09-11 02:46:56 +02:00
|
|
|
/* ============================================================================
|
|
|
|
|
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.
|
|
|
|
|
========================================================================= */
|
|
|
|
|
|
2026-09-16 20:32:56 +02:00
|
|
|
import type {
|
|
|
|
|
ApplicationValues,
|
|
|
|
|
PathwayApplicationValues,
|
|
|
|
|
RegistrationValues,
|
|
|
|
|
} from "./forms";
|
2026-09-11 02:46:56 +02:00
|
|
|
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;
|
2026-09-24 11:51:24 +02:00
|
|
|
readonly forwardedFor?: string;
|
2026-09-11 02:46:56 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function submitRegistration(
|
|
|
|
|
input: RegistrationSubmission,
|
2026-09-24 11:51:24 +02:00
|
|
|
): Promise<"confirmed" | "waitlisted"> {
|
|
|
|
|
const created = await createStrapiEntry<{
|
|
|
|
|
readonly registrationState?: unknown;
|
|
|
|
|
}>(
|
|
|
|
|
EVENT_REGISTRATIONS_RESOURCE,
|
|
|
|
|
{
|
|
|
|
|
event: input.eventId,
|
|
|
|
|
fullName: input.values.fullName,
|
|
|
|
|
email: input.values.email,
|
|
|
|
|
phone: optional(input.values.phone),
|
|
|
|
|
organisation: optional(input.values.organisation),
|
|
|
|
|
// Required in the schema: without it every POST is a 400.
|
|
|
|
|
consentGdpr: input.values.consentGdpr,
|
|
|
|
|
},
|
|
|
|
|
{ forwardedFor: input.forwardedFor },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
created?.registrationState !== "confirmed" &&
|
|
|
|
|
created?.registrationState !== "waitlisted"
|
|
|
|
|
) {
|
|
|
|
|
throw new Error("Strapi returned an invalid registration state.");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return created.registrationState;
|
2026-09-11 02:46:56 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ApplicationSubmission {
|
|
|
|
|
readonly values: ApplicationValues;
|
|
|
|
|
/** documentId of the opportunity being applied to. */
|
|
|
|
|
readonly opportunityId: string;
|
2026-09-24 11:51:24 +02:00
|
|
|
readonly forwardedFor?: string;
|
2026-09-11 02:46:56 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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> {
|
2026-09-24 11:51:24 +02:00
|
|
|
await createStrapiEntry(
|
|
|
|
|
APPLICATIONS_RESOURCE,
|
|
|
|
|
{
|
|
|
|
|
opportunity: input.opportunityId,
|
|
|
|
|
// `fullName` is the registration's field name; an application's is
|
|
|
|
|
// `applicantName`.
|
|
|
|
|
applicantName: input.values.fullName,
|
|
|
|
|
email: input.values.email,
|
|
|
|
|
organisation: optional(input.values.organisation),
|
|
|
|
|
// `answers` is a json field - the form's one free-text question goes in
|
|
|
|
|
// it, keyed, so more questions can be added without another migration.
|
|
|
|
|
answers: { [APPLICATION_MESSAGE_KEY]: input.values.message },
|
|
|
|
|
},
|
|
|
|
|
{ forwardedFor: input.forwardedFor },
|
|
|
|
|
);
|
2026-09-11 02:46:56 +02:00
|
|
|
}
|
2026-09-16 20:32:56 +02:00
|
|
|
|
|
|
|
|
/* ---------------------------------------------------------------------------
|
|
|
|
|
GET INVOLVED - the section page's own application
|
|
|
|
|
------------------------------------------------------------------------ */
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* The keys the Get Involved form files inside `answers` (json).
|
|
|
|
|
*
|
|
|
|
|
* They are named here rather than spelled at the call site because a reviewer
|
|
|
|
|
* reads them in the admin panel: renaming one silently orphans every answer
|
|
|
|
|
* already stored under the old key.
|
|
|
|
|
*
|
|
|
|
|
* `message` is deliberately the SAME key the per-call form writes
|
|
|
|
|
* (APPLICATION_MESSAGE_KEY above), so the free-text case for applying reads
|
|
|
|
|
* out of one place whichever form it arrived through.
|
|
|
|
|
*/
|
|
|
|
|
export const APPLICATION_ANSWER_KEYS = {
|
|
|
|
|
/** Which of the three pathways the applicant chose. */
|
|
|
|
|
route: "route",
|
|
|
|
|
region: "regionOfOperation",
|
|
|
|
|
yearsOperating: "yearsOperating",
|
|
|
|
|
summary: APPLICATION_MESSAGE_KEY,
|
|
|
|
|
} as const;
|
|
|
|
|
|
|
|
|
|
export interface PathwayApplicationSubmission {
|
|
|
|
|
readonly values: PathwayApplicationValues;
|
2026-09-24 11:51:24 +02:00
|
|
|
readonly forwardedFor?: string;
|
2026-09-16 20:32:56 +02:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
* section's general way in - so it is usually absent, and `opportunity` is
|
|
|
|
|
* optional on the content type precisely for that case. It is never guessed
|
|
|
|
|
* from "the nearest open call": an application filed against a call the
|
|
|
|
|
* applicant never named is a fabricated relation.
|
|
|
|
|
*/
|
|
|
|
|
readonly opportunityId?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Write a Get Involved application. The payload is the CMS whitelist and
|
|
|
|
|
* nothing more (see the header): the two extra answers the draft asks for ride
|
|
|
|
|
* inside `answers`, which is on that whitelist, so they need no migration.
|
|
|
|
|
*
|
|
|
|
|
* `attachments` is NOT sent, and the form does not offer an upload. See the
|
|
|
|
|
* header of this file and the `sanitize-submission` middleware in the CMS for
|
|
|
|
|
* why a public caller cannot supply one.
|
|
|
|
|
*/
|
|
|
|
|
export async function submitPathwayApplication(
|
|
|
|
|
input: PathwayApplicationSubmission,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
const { values } = input;
|
|
|
|
|
|
2026-09-24 11:51:24 +02:00
|
|
|
await createStrapiEntry(
|
|
|
|
|
APPLICATIONS_RESOURCE,
|
|
|
|
|
{
|
|
|
|
|
...(input.opportunityId ? { opportunity: input.opportunityId } : {}),
|
|
|
|
|
applicantName: values.fullName,
|
|
|
|
|
email: values.email,
|
|
|
|
|
organisation: optional(values.organisation),
|
|
|
|
|
answers: {
|
|
|
|
|
[APPLICATION_ANSWER_KEYS.route]: values.route,
|
|
|
|
|
// Only the answers actually given: an empty string in the admin panel
|
|
|
|
|
// reads as "answered, with nothing", which is not what happened.
|
|
|
|
|
...(values.region
|
|
|
|
|
? { [APPLICATION_ANSWER_KEYS.region]: values.region }
|
|
|
|
|
: {}),
|
|
|
|
|
...(values.yearsOperating
|
|
|
|
|
? { [APPLICATION_ANSWER_KEYS.yearsOperating]: values.yearsOperating }
|
|
|
|
|
: {}),
|
|
|
|
|
[APPLICATION_ANSWER_KEYS.summary]: values.summary,
|
|
|
|
|
},
|
2026-09-16 20:32:56 +02:00
|
|
|
},
|
2026-09-24 11:51:24 +02:00
|
|
|
{ forwardedFor: input.forwardedFor },
|
|
|
|
|
);
|
2026-09-16 20:32:56 +02:00
|
|
|
}
|