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.
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
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` }
|
||||
}
|
||||
Reference in New Issue
Block a user