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,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: '[email protected]',
|
||||
injected: 'evil',
|
||||
})
|
||||
expect(r.ok).toBe(true)
|
||||
if (r.ok) {
|
||||
expect(r.value).toEqual({ full_name: 'Ada', agree: true, contact: '[email protected]' })
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -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.
|
||||
|
||||
+15
-3
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
@@ -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` }
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user