import 'server-only' import type { BasePayload } from 'payload' import { verifyTurnstile } from '../turnstile/index.js' import { checkRateLimit } from './rateLimit.js' import { validateSubmission } from './validateSubmission.js' export type SubmitFormArgs = { /** * Name of the GDPR consent checkbox. A field with this name must be checked * for the submission to succeed (enforced server-side). Defaults to 'consent'. */ consentFieldName?: string /** Submitted field data — shape matches the form's fields. */ data: Record /** Form-builder form ID this submission belongs to. */ formId: string /** Client IP — used for Turnstile and rate limiting. */ ip?: string /** * Rate limit: max submissions per IP per minute. Defaults to 5. * Set to 0 to disable (e.g. when a real limiter sits in front). */ maxPerMinute?: number payload: BasePayload /** Turnstile token; when present it is verified, when absent it is skipped. */ turnstileToken?: string } /** * Why a submission failed — a code, never a user-facing string. * * The plugin knows what went wrong; it deliberately doesn't decide how to say * it. The frontend maps these to its own copy, in its own language, and renders * whatever component fits — a field error, a toast, a full message. The plugin * has no business choosing the wording or the locale. * * - `rate_limited` — too many submissions from this IP * - `turnstile` — bot check failed * - `validation` — a field is missing/too long, or unknown keys were sent; * `field` and `kind` narrow it down when a specific field is * at fault (absent for whole-payload problems like unknown keys) * - `not_found` — no form with this id * - `consent` — a required GDPR consent checkbox was left unchecked * - `error` — persistence failed unexpectedly */ export type SubmitFailure = | { /** The offending field's name, when one field is at fault. */ field?: string /** What was wrong with it. */ kind?: 'required' | 'too_long' | 'unknown_fields' reason: 'validation' success: false } | { field?: string /** A GDPR consent field existed on the form but wasn't checked. */ reason: 'consent' success: false } | { reason: 'error'; success: false } | { reason: 'not_found'; success: false } | { reason: 'rate_limited'; success: false } | { reason: 'turnstile'; success: false } export type SubmitFormResult = { submissionId: number | string; success: true } | SubmitFailure /** * Handles a form submission end to end: rate limit, verify Turnstile, validate * against the form's own schema, then store. * * The order is cost-ascending on purpose — the cheapest checks reject first, so * a flood never reaches Turnstile's network call or the database. * * Emails aren't sent here. The form-builder sends whatever an editor configured * under the form's "Emails" tab (form-submissions hook → payload.sendEmail), * which goes out over panelSmtpAdapter. Storing the submission is enough. * * server-only: touches the Turnstile secret. */ export async function submitForm({ consentFieldName, data, formId, ip, maxPerMinute = 5, payload, turnstileToken, }: SubmitFormArgs): Promise { // 1. Rate limit — cheapest gate, drops a flood before any real work. if (maxPerMinute > 0 && ip) { if (!checkRateLimit({ key: ip, max: maxPerMinute })) { return { reason: 'rate_limited', success: false } } } // 2. Turnstile — verify when a token is supplied; reject on failure. if (turnstileToken !== undefined) { const ok = await verifyTurnstile({ ip, payload, token: turnstileToken }) if (!ok) { return { reason: 'turnstile', success: false } } } // 3. Validate against the form's schema. A public endpoint can't trust the // shape of `data` — drop unknown keys, enforce required, cap length. const validation = await validateSubmission(payload, formId, data, consentFieldName) if (!validation.ok) { if (validation.reason === 'not_found') { return { reason: 'not_found', success: false } } if (validation.reason === 'consent') { return { field: validation.field, reason: 'consent', success: false } } return { reason: 'validation', success: false, ...(validation.field ? { field: validation.field } : {}), ...(validation.kind ? { kind: validation.kind } : {}), } } // 4. Persist (form-builder shape: submissionData array). Triggers the email // hook. Only validated, known fields are stored. try { const submission = await payload.create({ collection: 'form-submissions', data: { form: formId, submissionData: Object.entries(validation.cleaned).map(([field, value]) => ({ field, value: value == null ? '' : String(value), })), } as never, }) return { submissionId: submission.id, success: true } } catch (err) { payload.logger.error(`[ipal] Form submission failed: ${(err as Error).message}`) return { reason: 'error', success: false } } }