Files
sharper.ly/apps/web/app/routes/_events-opportunities/submissions.server.ts
T
2026-09-11 02:46:56 +02:00

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 },
});
}