Files

207 lines
8.1 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,
PathwayApplicationValues,
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;
readonly forwardedFor?: string;
}
export async function submitRegistration(
input: RegistrationSubmission,
): 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;
}
export interface ApplicationSubmission {
readonly values: ApplicationValues;
/** documentId of the opportunity being applied to. */
readonly opportunityId: string;
readonly forwardedFor?: 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 },
},
{ forwardedFor: input.forwardedFor },
);
}
/* ---------------------------------------------------------------------------
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;
readonly forwardedFor?: string;
/**
* 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;
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,
},
},
{ forwardedFor: input.forwardedFor },
);
}