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:
Moheeb Zara
2026-05-16 14:51:20 -07:00
parent 66dd9b7ec0
commit 897fa30ec1
8 changed files with 819 additions and 4 deletions
+440
View File
@@ -0,0 +1,440 @@
'use server'
import { revalidatePath } from 'next/cache'
import { headers } from 'next/headers'
import { createClient } from '@/lib/supabase/server'
import { createAdminClient } from '@/lib/supabase/admin'
import {
requireMember,
getAuthMember,
logActivity,
parseInput,
type Member,
type ServerSupabase,
} from '@/lib/auth-helpers'
import {
createFormSchema,
updateFormSchema,
setFormStatusSchema,
formIdSchema,
submitFormSchema,
linkSubmissionsSchema,
} from '@/lib/validations'
import { parseFormSchema, validateAnswers } from '@/lib/forms-schema'
const SUBMISSIONS_CAP = 5000
type Manager = { ok: true; supabase: ServerSupabase; member: Member } | { ok: false; error: string }
// forms.manage gate. Errors here are advisory UX; the database RLS on `forms`
// independently enforces the same permission, so a bypass cannot write.
async function requireFormsManager(): Promise<Manager> {
const supabase = await createClient()
const auth = await requireMember(supabase)
if (!auth.ok) return { ok: false, error: auth.error }
const { member } = auth
const { data: allowed, error } = await supabase.rpc('user_has_permission', {
uid: member.user_id as string,
sid: member.space_id,
perm: 'forms.manage',
})
if (error) return { ok: false, error: error.message }
if (!allowed) return { ok: false, error: 'You do not have permission to manage forms' }
return { ok: true, supabase, member }
}
function isUniqueViolation(message: string): boolean {
return /duplicate key value|already exists|unique constraint/i.test(message)
}
export async function createForm(input: unknown) {
const gate = await requireFormsManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const v = parseInput(createFormSchema, input)
if (!v.ok) return { error: v.error }
const f = v.data
const { data, error } = await supabase
.from('forms')
.insert({
space_id: member.space_id,
slug: f.slug,
title: f.title,
description: f.description ?? null,
kind: f.kind,
visibility: f.visibility,
schema: f.schema,
legal_text: f.legal_text ?? null,
created_by: member.id,
})
.select('id')
.single()
if (error) {
if (isUniqueViolation(error.message)) {
return { error: 'That slug is already taken. Pick a different one.' }
}
return { error: error.message }
}
await logActivity(supabase, member, 'created', 'form', data.id, f.title)
revalidatePath('/forms')
return { data: { id: data.id as string } }
}
export async function updateForm(input: unknown) {
const gate = await requireFormsManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const v = parseInput(updateFormSchema, input)
if (!v.ok) return { error: v.error }
const u = v.data
const { data: existing, error: loadErr } = await supabase
.from('forms')
.select('id, space_id, kind, status, version, legal_text')
.eq('id', u.formId)
.eq('space_id', member.space_id)
.single()
if (loadErr || !existing) return { error: 'Form not found' }
const patch: Record<string, unknown> = {}
if (u.title !== undefined) patch.title = u.title
if (u.description !== undefined) patch.description = u.description
if (u.visibility !== undefined) patch.visibility = u.visibility
if (u.schema !== undefined) patch.schema = u.schema
if (u.legal_text !== undefined) patch.legal_text = u.legal_text
// Non-blocking re-sign: bumping the version of a published waiver leaves
// existing submissions valid against their own snapshot. We only nudge new
// signers. Bump when the legal text or the field schema actually changes.
const legalChanged = u.legal_text !== undefined && u.legal_text !== existing.legal_text
const schemaChanged = u.schema !== undefined
if (
existing.kind === 'waiver' &&
existing.status === 'published' &&
(legalChanged || schemaChanged)
) {
patch.version = (existing.version as number) + 1
}
if (Object.keys(patch).length === 0) return { data: { id: u.formId } }
const { error } = await supabase
.from('forms')
.update(patch)
.eq('id', u.formId)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
await logActivity(supabase, member, 'updated', 'form', u.formId)
revalidatePath('/forms')
revalidatePath(`/forms/${u.formId}`)
return { data: { id: u.formId } }
}
export async function setFormStatus(input: unknown) {
const gate = await requireFormsManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const v = parseInput(setFormStatusSchema, input)
if (!v.ok) return { error: v.error }
const { error } = await supabase
.from('forms')
.update({ status: v.data.status })
.eq('id', v.data.formId)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
await logActivity(supabase, member, `status:${v.data.status}`, 'form', v.data.formId)
revalidatePath('/forms')
revalidatePath(`/forms/${v.data.formId}`)
return { success: true as const }
}
export async function deleteForm(input: unknown) {
const gate = await requireFormsManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const v = parseInput(formIdSchema, input)
if (!v.ok) return { error: v.error }
// Deleting a form cascades to form_submissions. Submissions are immutable
// waiver/response records, so refuse to delete a form that has any. Close
// it instead.
const admin = createAdminClient()
const { count } = await admin
.from('form_submissions')
.select('id', { count: 'exact', head: true })
.eq('form_id', v.data.formId)
.eq('space_id', member.space_id)
if ((count ?? 0) > 0) {
return { error: 'This form has submissions and cannot be deleted. Close it instead.' }
}
const { error } = await supabase
.from('forms')
.delete()
.eq('id', v.data.formId)
.eq('space_id', member.space_id)
if (error) return { error: error.message }
await logActivity(supabase, member, 'deleted', 'form', v.data.formId)
revalidatePath('/forms')
return { success: true as const }
}
export async function listForms() {
const gate = await requireFormsManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const { data, error } = await supabase
.from('forms')
.select('id, slug, title, kind, visibility, status, version, created_at, updated_at')
.eq('space_id', member.space_id)
.order('created_at', { ascending: false })
if (error) return { error: error.message }
return { data: data ?? [] }
}
export async function getFormResults(input: unknown) {
const gate = await requireFormsManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const v = parseInput(formIdSchema, input)
if (!v.ok) return { error: v.error }
const { data: form, error: formErr } = await supabase
.from('forms')
.select('id, slug, title, kind, schema, version')
.eq('id', v.data.formId)
.eq('space_id', member.space_id)
.single()
if (formErr || !form) return { error: 'Form not found' }
const { data: submissions, error: subErr } = await supabase
.from('form_submissions')
.select(
'id, member_id, submitter_email, answers, form_version, ip, user_agent, created_at',
)
.eq('form_id', v.data.formId)
.eq('space_id', member.space_id)
.order('created_at', { ascending: false })
.limit(SUBMISSIONS_CAP)
if (subErr) return { error: subErr.message }
return { data: { form, submissions: submissions ?? [] } }
}
function csvCell(value: unknown): string {
if (value === null || value === undefined) return ''
const s = typeof value === 'string' ? value : JSON.stringify(value)
if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`
return s
}
export async function exportFormResultsCsv(input: unknown) {
const gate = await requireFormsManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const v = parseInput(formIdSchema, input)
if (!v.ok) return { error: v.error }
const { data: form, error: formErr } = await supabase
.from('forms')
.select('id, slug, title, schema')
.eq('id', v.data.formId)
.eq('space_id', member.space_id)
.single()
if (formErr || !form) return { error: 'Form not found' }
const { data: submissions, error: subErr } = await supabase
.from('form_submissions')
.select('member_id, submitter_email, answers, form_version, ip, created_at')
.eq('form_id', v.data.formId)
.eq('space_id', member.space_id)
.order('created_at', { ascending: false })
.limit(SUBMISSIONS_CAP)
if (subErr) return { error: subErr.message }
const fields = parseFormSchema(form.schema)
const header = [
'submitted_at',
'form_version',
'member_id',
'submitter_email',
'ip',
...fields.map(f => f.key),
]
const lines = [header.map(csvCell).join(',')]
for (const s of submissions ?? []) {
const answers = (s.answers ?? {}) as Record<string, unknown>
lines.push(
[
s.created_at,
s.form_version,
s.member_id,
s.submitter_email,
s.ip,
...fields.map(f => answers[f.key]),
]
.map(csvCell)
.join(','),
)
}
await logActivity(supabase, member, 'results_exported', 'form', form.id, form.slug)
return {
data: { filename: `${form.slug}-responses.csv`, csv: lines.join('\r\n') },
}
}
const IP_RE = /^[0-9a-fA-F:.]{3,45}$/
function clientIp(h: Headers): string | null {
const fwd = h.get('x-forwarded-for')
const candidate = (fwd ? fwd.split(',')[0] : h.get('x-real-ip'))?.trim()
if (!candidate || !IP_RE.test(candidate)) return null
return candidate
}
/**
* Public submission entry point. Callable by anonymous visitors, signed-in
* non-members, or members depending on the form's visibility. Reads the form
* with the service client (anon has no grant on `forms`), enforces visibility
* and the form schema server-side, snapshots the schema/legal-text/version,
* and writes the row with the service client (form_submissions has no write
* policy, so this is the only path in).
*/
export async function submitForm(input: unknown) {
const v = parseInput(submitFormSchema, input)
if (!v.ok) return { error: v.error }
const body = v.data
const admin = createAdminClient()
let q = admin
.from('forms')
.select('id, space_id, slug, kind, visibility, status, schema, legal_text, version')
q = body.formId ? q.eq('id', body.formId) : q.eq('slug', body.slug as string)
const { data: form } = await q.single()
if (!form) return { error: 'Form not found' }
if (form.status !== 'published') {
return { error: 'This form is not accepting responses' }
}
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
let linkedMemberId: string | null = null
let submitterEmail: string | null = body.email ? body.email.toLowerCase() : null
if (form.visibility === 'members') {
const member = await getAuthMember(supabase)
if (!member || member.space_id !== form.space_id) {
return { error: 'You must be a member of this space to submit this form' }
}
linkedMemberId = member.id
submitterEmail = user?.email?.toLowerCase() ?? submitterEmail
} else if (form.visibility === 'public_auth') {
if (!user) return { error: 'You must be signed in to submit this form' }
const member = await getAuthMember(supabase)
if (member && member.space_id === form.space_id) linkedMemberId = member.id
submitterEmail = user.email?.toLowerCase() ?? submitterEmail
}
// public_anon: no auth required; submitter_email is whatever was provided.
if (form.kind === 'waiver' && body.consent !== true) {
return { error: 'You must agree to the waiver to continue' }
}
const fields = parseFormSchema(form.schema)
const answers = validateAnswers(fields, body.answers)
if (!answers.ok) return { error: answers.error }
const h = await headers()
const { error: insErr } = await admin.from('form_submissions').insert({
form_id: form.id,
space_id: form.space_id,
member_id: linkedMemberId,
submitter_email: submitterEmail,
answers: answers.value,
form_snapshot: form.schema,
legal_text_snapshot: form.legal_text ?? null,
form_version: form.version,
ip: clientIp(h),
user_agent: h.get('user-agent'),
})
if (insErr) return { error: insErr.message }
// Advisory audit. No member context for anon submitters, so write directly.
await admin.from('activity_log').insert({
space_id: form.space_id,
user_id: user?.id ?? null,
display_name: user?.email ?? 'Anonymous',
action: 'submitted',
entity_type: 'form',
entity_id: form.id,
details: form.slug,
})
return { success: true as const }
}
/**
* Retro-link prior anonymous submissions to a member by matching email.
*
* Phase 2 exposes this as the forms.manage "admin manual-link" tool. The
* automatic path (link on email verification) is Phase 5 and must only run
* with a verified email; this function trusts its caller to have established
* that the email belongs to the member.
*/
export async function linkSubmissionsForMember(input: unknown) {
const gate = await requireFormsManager()
if (!gate.ok) return { error: gate.error }
const { supabase, member } = gate
const v = parseInput(linkSubmissionsSchema, input)
if (!v.ok) return { error: v.error }
const email = v.data.email.toLowerCase()
const { data: target } = await supabase
.from('space_members')
.select('id')
.eq('id', v.data.memberId)
.eq('space_id', member.space_id)
.single()
if (!target) return { error: 'Member not found in this space' }
const admin = createAdminClient()
const { data: linked, error } = await admin
.from('form_submissions')
.update({ member_id: v.data.memberId })
.eq('space_id', member.space_id)
.is('member_id', null)
.eq('submitter_email', email)
.select('id')
if (error) return { error: error.message }
await logActivity(
supabase,
member,
'linked_submissions',
'space_member',
v.data.memberId,
`${linked?.length ?? 0} submission(s) for ${email}`,
)
return { data: { linked: linked?.length ?? 0 } }
}
+3
View File
@@ -44,3 +44,6 @@ export * from './onboarding'
// Customizable permissions, per-item Ops ACLs, area-lead roles
export * from './permissions'
// Custom forms and waivers
export * from './forms'
+111
View File
@@ -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` }
}
+113
View File
@@ -553,6 +553,119 @@ export const importPaymentsCsvSchema = z.array(
}),
).max(10000)
// ─── Custom forms and waivers ────────────────────────────────────────────────
export const formFieldTypes = [
'short_text',
'long_text',
'email',
'number',
'date',
'checkbox',
'select',
'radio',
] as const
export const formKinds = ['form', 'waiver'] as const
export const formVisibilities = ['public_anon', 'public_auth', 'members'] as const
export const formStatuses = ['draft', 'published', 'closed'] as const
const formSlug = z
.string()
.min(1, 'Slug is required')
.max(80, 'Slug must be 80 characters or fewer')
.regex(
/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/,
'Slug must be lowercase letters, numbers, and internal hyphens only',
)
export const formFieldSchema = z
.object({
key: z
.string()
.min(1)
.max(60)
.regex(/^[a-z0-9_]+$/, 'Field key must be lowercase letters, numbers, underscores'),
type: z.enum(formFieldTypes),
label: z.string().min(1, 'Field label is required').max(200),
help: z.string().max(1000).optional().nullable(),
required: z.boolean().optional().default(false),
options: z.array(z.string().min(1).max(200)).max(100).optional(),
})
.superRefine((f, ctx) => {
if ((f.type === 'select' || f.type === 'radio') && (!f.options || f.options.length === 0)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Field "${f.label}" needs at least one option`,
path: ['options'],
})
}
})
// The field array is rejected if two fields share a key (answers are keyed by
// field key, so duplicates would silently overwrite).
export const formSchemaArray = z
.array(formFieldSchema)
.max(200, 'A form cannot have more than 200 fields')
.superRefine((fields, ctx) => {
const seen = new Set<string>()
for (const f of fields) {
if (seen.has(f.key)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `Duplicate field key "${f.key}"`,
})
}
seen.add(f.key)
}
})
export const createFormSchema = z.object({
slug: formSlug,
title: z.string().min(1, 'Title is required').max(200),
description: z.string().max(2000).optional().nullable(),
kind: z.enum(formKinds).optional().default('form'),
visibility: z.enum(formVisibilities).optional().default('members'),
schema: formSchemaArray.optional().default([]),
legal_text: z.string().max(100000).optional().nullable(),
})
// slug is intentionally immutable after creation: a published form/waiver may
// already be linked from elsewhere and submissions reference it.
export const updateFormSchema = z.object({
formId: z.string().uuid('Invalid form ID'),
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional().nullable(),
visibility: z.enum(formVisibilities).optional(),
schema: formSchemaArray.optional(),
legal_text: z.string().max(100000).optional().nullable(),
})
export const setFormStatusSchema = z.object({
formId: z.string().uuid('Invalid form ID'),
status: z.enum(formStatuses),
})
export const formIdSchema = z.object({ formId: z.string().uuid('Invalid form ID') })
// Envelope only. The answers object is validated dynamically against the
// form's stored field schema inside submitForm (see lib/forms-schema.ts).
export const submitFormSchema = z
.object({
formId: z.string().uuid('Invalid form ID').optional(),
slug: formSlug.optional(),
answers: z.record(z.unknown()).default({}),
email: emailField().optional().nullable(),
consent: z.boolean().optional(),
})
.refine(d => Boolean(d.formId) || Boolean(d.slug), {
message: 'formId or slug is required',
})
export const linkSubmissionsSchema = z.object({
memberId: z.string().uuid('Invalid member ID'),
email: emailField(),
})
// ─── Generic ID schemas ──────────────────────────────────────────────────────
export const uuidSchema = z.string().uuid('Invalid ID format')