/** * What the site's two forms send — the general contact and an application to * a call — checked before it costs a round trip. * * Pure, so the two server routes share it and it can be tested without a * request: the API would answer 422 with the same verdict, and its throttle * counts the attempt either way. */ import type { SubmissionPayload } from './api'; export interface SubmissionBody { name?: string; email?: string; message?: string; organisation?: string | null; hours_per_week?: number | string | null; kind?: 'contribute' | 'request'; consent?: boolean; links?: Record; /** Honeypot. Never rendered visibly; a bot that fills it is filed as spam. */ website?: string | null; } export type SubmissionCheck = | { errors: Record; payload?: undefined } | { errors?: undefined; payload: SubmissionPayload }; /** * @param forCall An application to a call is always an offer of time: the API * refuses a `kind` on it, so none is sent, and no organisation is asked for. */ export function checkSubmission( body: SubmissionBody, { forCall = false } = {}, ): SubmissionCheck { const kind = !forCall && body.kind === 'request' ? 'request' : 'contribute'; const name = body.name?.trim() ?? ''; const email = body.email?.trim() ?? ''; const message = body.message?.trim() ?? ''; const organisation = body.organisation?.trim() ?? ''; const errors: Record = {}; if (name.length < 2) { errors['name'] = ['required']; } if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) { errors['email'] = ['required']; } // The API rejects anything shorter, and rightly: a one-word submission is // never anything but noise. if (message.length < 20) { errors['message'] = ['min']; } if (kind === 'request' && organisation.length === 0) { errors['organisation'] = ['required']; } if (body.consent !== true) { errors['consent'] = ['required']; } if (Object.keys(errors).length > 0) { return { errors }; } const hours = Number(body.hours_per_week); return { payload: { name, email, message, consent: true, ...(forCall ? {} : { kind }), organisation: organisation || null, hours_per_week: Number.isFinite(hours) && hours > 0 ? hours : null, ...(body.links && Object.keys(body.links).length > 0 ? { links: body.links } : {}), ...(body.website ? { website: body.website } : {}), }, }; }