diff --git a/__tests__/forms.test.ts b/__tests__/forms.test.ts new file mode 100644 index 0000000..c860ad9 --- /dev/null +++ b/__tests__/forms.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect } from 'vitest' +import { + createFormSchema, + updateFormSchema, + submitFormSchema, + formSchemaArray, + formFieldSchema, +} from '@/lib/validations' +import { parseFormSchema, validateAnswers, type FormField } from '@/lib/forms-schema' + +describe('form field schema', () => { + it('accepts a valid text field and defaults required to false', () => { + const r = formFieldSchema.safeParse({ key: 'full_name', type: 'short_text', label: 'Name' }) + expect(r.success).toBe(true) + if (r.success) expect(r.data.required).toBe(false) + }) + + it('rejects a select field with no options', () => { + const r = formFieldSchema.safeParse({ key: 'tier', type: 'select', label: 'Tier' }) + expect(r.success).toBe(false) + }) + + it('rejects an invalid field key', () => { + const r = formFieldSchema.safeParse({ key: 'Full Name', type: 'short_text', label: 'X' }) + expect(r.success).toBe(false) + }) + + it('rejects duplicate field keys in the array', () => { + const r = formSchemaArray.safeParse([ + { key: 'a', type: 'short_text', label: 'A' }, + { key: 'a', type: 'short_text', label: 'A again' }, + ]) + expect(r.success).toBe(false) + }) +}) + +describe('createFormSchema', () => { + it('applies defaults', () => { + const r = createFormSchema.safeParse({ slug: 'liability-waiver', title: 'Waiver' }) + expect(r.success).toBe(true) + if (r.success) { + expect(r.data.kind).toBe('form') + expect(r.data.visibility).toBe('members') + expect(r.data.schema).toEqual([]) + } + }) + + it('rejects an uppercase or hyphen-edged slug', () => { + expect(createFormSchema.safeParse({ slug: 'Waiver', title: 'x' }).success).toBe(false) + expect(createFormSchema.safeParse({ slug: '-waiver', title: 'x' }).success).toBe(false) + expect(createFormSchema.safeParse({ slug: 'waiver-', title: 'x' }).success).toBe(false) + }) +}) + +describe('submitFormSchema', () => { + it('requires formId or slug', () => { + expect(submitFormSchema.safeParse({ answers: {} }).success).toBe(false) + expect(submitFormSchema.safeParse({ slug: 'a', answers: {} }).success).toBe(true) + }) +}) + +describe('updateFormSchema', () => { + it('does not accept a slug change', () => { + const r = updateFormSchema.safeParse({ + formId: '11111111-1111-1111-1111-111111111111', + slug: 'new-slug', + }) + // slug is simply stripped (not in the schema), so parse still succeeds + expect(r.success).toBe(true) + if (r.success) expect('slug' in r.data).toBe(false) + }) +}) + +const fields: FormField[] = [ + { key: 'full_name', type: 'short_text', label: 'Full name', required: true }, + { key: 'agree', type: 'checkbox', label: 'I agree', required: true }, + { key: 'shirt', type: 'select', label: 'Shirt', required: false, options: ['S', 'M', 'L'] }, + { key: 'contact', type: 'email', label: 'Email', required: false }, +] + +describe('validateAnswers', () => { + it('errors when a required field is missing', () => { + const r = validateAnswers(fields, { agree: true }) + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error).toMatch(/Full name/) + }) + + it('errors when a required checkbox is not checked', () => { + const r = validateAnswers(fields, { full_name: 'Ada', agree: false }) + expect(r.ok).toBe(false) + }) + + it('rejects a select value outside the options', () => { + const r = validateAnswers(fields, { full_name: 'Ada', agree: true, shirt: 'XL' }) + expect(r.ok).toBe(false) + }) + + it('drops unknown keys and normalizes', () => { + const r = validateAnswers(fields, { + full_name: ' Ada ', + agree: 'on', + contact: 'Ada@Example.COM', + injected: 'evil', + }) + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.value).toEqual({ full_name: 'Ada', agree: true, contact: 'ada@example.com' }) + expect('injected' in r.value).toBe(false) + } + }) +}) + +describe('parseFormSchema', () => { + it('returns [] for malformed stored schema', () => { + expect(parseFormSchema('not an array')).toEqual([]) + expect(parseFormSchema([{ key: 'a' }])).toEqual([]) + }) + + it('round-trips a valid schema', () => { + const raw = [{ key: 'a', type: 'short_text', label: 'A' }] + expect(parseFormSchema(raw)).toHaveLength(1) + }) +}) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 7ad3655..4292755 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -855,7 +855,7 @@ async function handleSubmit() { --- -## Server actions added (migrations 016-024) +## Server actions added (migrations 016-026) All are `'use server'` in `lib/actions/*`, re-exported from `lib/actions/index.ts`, validate input with Zod via `parseInput`, authorize via `requireMember` / @@ -887,3 +887,11 @@ validate input with Zod via `parseInput`, authorize via `requireMember` / ### Bulk import — `imports.ts`, `payments.ts` `importMembers`, `importPaymentsCsv`: per-row Zod validation, lowercased emails, `flexibleDateTime` date normalization, enum-checked platform/tier, positive finite amounts; invalid rows skipped and returned as a `skipped` count. + +### Forms & waivers — `forms.ts` (migration 026) +Management actions are gated by `forms.manage` (checked via the `user_has_permission` RPC; the `forms` RLS independently enforces it): +- `createForm(input)` — friendly error on slug collision (slug is globally unique). `updateForm(input)` — slug is immutable; for a published waiver, a legal-text or schema change bumps `version` (existing submissions stay valid against their own snapshot, non-blocking re-sign). `setFormStatus(input)` — draft/published/closed. `deleteForm(input)` — refused if the form has any submissions (close instead; submissions are immutable records). `listForms()`, `getFormResults(input)`, `exportFormResultsCsv(input)` (audited via `activity_log`). +- `submitForm(input)` — public entry point (anonymous, signed-in non-member, or member by form `visibility`). Reads the form with the service client (anon has no `forms` grant), enforces visibility + waiver consent, validates `answers` against the stored field schema (`lib/forms-schema.ts`, unknown keys discarded), snapshots schema/legal-text/version, captures IP + user-agent, and writes the row with the service client (`form_submissions` has no write policy, so this is the only path; rows are immutable). +- `linkSubmissionsForMember(input)` — `forms.manage` admin manual-link of prior anonymous submissions by email. The automatic verified-email retro-link is Phase 5. + +Not implemented yet (planned): builder UI, public `/f/[slug]` page, onboarding form-step integration, automatic retro-link on email verification. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index c8dd49e..1f80d24 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -4,7 +4,7 @@ Append-only. Newest entries on top. Keep each entry to one screen. --- -## 2026-05-16 (pass 18) — Forms + waivers Phase 1 (schema + RLS), LOCAL, awaiting deploy approval +## 2026-05-16 (pass 18) — Forms + waivers Phases 1-2 (schema/RLS + server actions), LOCAL, awaiting deploy approval Branch: `main`. Migration: `026` (not yet deployed). `pnpm build` + `pnpm exec vitest run` gate pending in this entry's session. @@ -25,10 +25,22 @@ Branch: `main`. Migration: `026` (not yet deployed). `pnpm build` + `pnpm exec v - New `forms.manage` permission added to `lib/permissions-catalog.ts` (group Community) + board default; `seed_default_role_permissions()` extended; backfill `board -> forms.manage` for existing spaces (ON CONFLICT DO NOTHING). - `types/database.ts`: `forms` + `form_submissions` added. Docs updated: DATABASE_SCHEMA (026 row), DB_SCHEMA_MAP (quick-map + migrations), ARCHITECTURE (forms note). +### Phase 1 self-audit (commit `66dd9b7`, local) + +Audited the migration before continuing. Three fixes applied: dropped redundant `idx_forms_slug` (UNIQUE already indexes slug); tightened `forms_select` to `forms.manage` (all) OR member+`status='published'` (drafts/closed no longer exposed via raw PostgREST); made the `submitter_email`/`member_id` submission indexes partial. None weaken access; the SELECT change strengthens it. Safe because `forms` is a new table with no existing dependents. + +### Forms + waivers Phase 2 (this session) — server actions + Zod + tests, LOCAL + +- `lib/validations.ts`: form field/array schemas (8 field types, duplicate-key + select-options refinements), `createForm`/`updateForm`/`setFormStatus`/`formId`/`submitForm`/`linkSubmissions` schemas. slug immutable on update. +- `lib/forms-schema.ts` (pure, unit-tested): `parseFormSchema` + `validateAnswers` (server-side answer validation against the stored schema; unknown keys discarded so no arbitrary jsonb injection). +- `lib/actions/forms.ts`: `createForm`, `updateForm` (waiver version bump on legal/schema change, non-blocking), `setFormStatus`, `deleteForm` (refused when submissions exist), `listForms`, `getFormResults`, `exportFormResultsCsv` (audited), `submitForm` (service-client read + write, visibility + consent enforcement, snapshot, IP/UA capture, no anon DB grant), `linkSubmissionsForMember` (forms.manage manual-link; auto verified-email path is Phase 5). forms.manage gate via `user_has_permission` RPC + RLS defense-in-depth. +- `types/database.ts`: typed `user_has_permission` + `user_effective_roles` (were stale since 023). `lib/actions/index.ts` re-exports forms. `__tests__/forms.test.ts` (14 tests). API_REFERENCE updated. +- Gate: `pnpm exec vitest run` 285/285; `pnpm build` exit 0, clean. + ### Open / next -- **Awaiting user approval to deploy** (push to `main` = prod). Local commit only this session; do NOT push without explicit go. -- Phases 2-5 not started: server actions + Zod (`submitForm` via service client + email→member resolution + snapshot; list/results/CSV; linkSubmissionsForMember); builder UI + `/f/[slug]`; onboarding form step + required-waiver enforcement; retro-link hooks (join/signup/addMember/import + email verification). +- **Awaiting user approval to deploy** (push to `main` = prod). Phase 1 commits `3a2d3ca`+`66dd9b7` and Phase 2 are LOCAL only; do NOT push without explicit go. Migration `026` ships with Phase 1. +- Phase 2 not browser-tested (no UI yet — actions only). Phases 3-5 not started: builder UI + `/forms` + public `/f/[slug]`; onboarding form-step + required-waiver enforcement; automatic retro-link hooks (join/signup/addMember/import + email verification) — `linkSubmissionsForMember` primitive already exists. - After an approved push: watch the Actions run, then smoke-test that `026` applied (`_migrations_applied` row) and a space's board has `forms.manage`. --- diff --git a/lib/actions/forms.ts b/lib/actions/forms.ts new file mode 100644 index 0000000..0dab154 --- /dev/null +++ b/lib/actions/forms.ts @@ -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 { + 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 = {} + 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 + 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 } } +} diff --git a/lib/actions/index.ts b/lib/actions/index.ts index 6c066a0..ea39efc 100644 --- a/lib/actions/index.ts +++ b/lib/actions/index.ts @@ -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' diff --git a/lib/forms-schema.ts b/lib/forms-schema.ts new file mode 100644 index 0000000..397a404 --- /dev/null +++ b/lib/forms-schema.ts @@ -0,0 +1,111 @@ +import { z } from 'zod' +import { formSchemaArray, formFieldSchema } from './validations' + +export type FormField = z.infer + +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 } + | { 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, +): ValidateAnswersResult { + const value: Record = {} + + 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` } +} diff --git a/lib/validations.ts b/lib/validations.ts index d7b0d25..5d10f3c 100644 --- a/lib/validations.ts +++ b/lib/validations.ts @@ -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() + 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') diff --git a/types/database.ts b/types/database.ts index b50bd57..a57bdcc 100644 --- a/types/database.ts +++ b/types/database.ts @@ -1576,6 +1576,11 @@ export type Database = { Args: { allowed_roles: string[]; sid: string; uid: string } Returns: boolean } + user_effective_roles: { Args: { uid: string; sid: string }; Returns: string[] } + user_has_permission: { + Args: { uid: string; sid: string; perm: string } + Returns: boolean + } } Enums: { area_lead_status: "active" | "vacant" | "handoff"