Files
Moheeb Zara 897fa30ec1 feat(forms): server actions, Zod schemas, and dynamic answer validation (Phase 2)
- lib/validations.ts: form field/array schemas (8 field types,
  duplicate-key and select-options refinements) and create/update/
  status/submit/link envelope schemas; slug immutable on update
- lib/forms-schema.ts: pure parseFormSchema + validateAnswers; unknown
  answer keys are discarded so a caller cannot inject arbitrary jsonb
- lib/actions/forms.ts: create/update/setStatus/delete/list/results/
  CSV export (forms.manage via user_has_permission + RLS), submitForm
  (service-client read+write, visibility + waiver consent enforced,
  schema/legal/version snapshot, IP/UA capture), and
  linkSubmissionsForMember (manual admin link; auto path is Phase 5)
- updateForm bumps a published waiver's version on legal/schema change
  (non-blocking; existing submissions stay valid against their snapshot)
- deleteForm refuses when submissions exist (immutable records)
- types/database.ts: type user_has_permission/user_effective_roles
  (stale since migration 023)
- __tests__/forms.test.ts (14); API_REFERENCE + HANDOFF updated

285/285 tests, build clean. No deploy.
2026-05-16 14:51:20 -07:00

112 lines
3.4 KiB
TypeScript

import { z } from 'zod'
import { formSchemaArray, formFieldSchema } from './validations'
export type FormField = z.infer<typeof formFieldSchema>
const SHORT_TEXT_MAX = 2000
const LONG_TEXT_MAX = 20000
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
/**
* Parse a stored form field schema (jsonb) into a typed, validated array.
* Schemas are validated on write, but stored jsonb is still untrusted input
* on read, so this re-validates and drops anything malformed.
*/
export function parseFormSchema(raw: unknown): FormField[] {
const parsed = formSchemaArray.safeParse(raw)
return parsed.success ? parsed.data : []
}
function isEmpty(v: unknown): boolean {
return v === undefined || v === null || (typeof v === 'string' && v.trim() === '')
}
export type ValidateAnswersResult =
| { ok: true; value: Record<string, unknown> }
| { ok: false; error: string }
/**
* Validate a raw answers object against a form's field schema.
*
* Returns a NEW object containing only keys that correspond to known fields,
* coerced to their declared type. Unknown keys in the input are discarded so
* a caller cannot smuggle arbitrary jsonb into a submission. The first failing
* field produces the error (one message, matching the parseInput convention).
*/
export function validateAnswers(
fields: FormField[],
answers: Record<string, unknown>,
): ValidateAnswersResult {
const value: Record<string, unknown> = {}
for (const field of fields) {
const raw = answers[field.key]
if (isEmpty(raw)) {
if (field.required) {
return { ok: false, error: `"${field.label}" is required` }
}
continue
}
switch (field.type) {
case 'short_text':
case 'long_text': {
if (typeof raw !== 'string') return bad(field)
const max = field.type === 'short_text' ? SHORT_TEXT_MAX : LONG_TEXT_MAX
const s = raw.trim()
if (s.length > max) {
return { ok: false, error: `"${field.label}" is too long` }
}
value[field.key] = s
break
}
case 'email': {
if (typeof raw !== 'string' || !EMAIL_RE.test(raw.trim())) {
return { ok: false, error: `"${field.label}" must be a valid email` }
}
value[field.key] = raw.trim().toLowerCase()
break
}
case 'number': {
const n = typeof raw === 'number' ? raw : Number(raw)
if (!Number.isFinite(n)) return bad(field)
value[field.key] = n
break
}
case 'date': {
if (typeof raw !== 'string' || Number.isNaN(Date.parse(raw))) {
return { ok: false, error: `"${field.label}" must be a valid date` }
}
value[field.key] = raw
break
}
case 'checkbox': {
const b = raw === true || raw === 'true' || raw === 'on' || raw === 1
if (field.required && !b) {
return { ok: false, error: `"${field.label}" must be checked` }
}
value[field.key] = b
break
}
case 'select':
case 'radio': {
const opts = field.options ?? []
if (typeof raw !== 'string' || !opts.includes(raw)) {
return { ok: false, error: `"${field.label}" has an invalid selection` }
}
value[field.key] = raw
break
}
default:
return bad(field)
}
}
return { ok: true, value }
}
function bad(field: FormField): { ok: false; error: string } {
return { ok: false, error: `"${field.label}" has an invalid value` }
}