feat(forms): obvious publish flow, member Forms surface, templates (P8)

- builder: draft/published/closed status banners; creating a form now
  lands on its edit page so Publish is immediately visible; a
  members-only form shows a "no public link" note instead of a dead URL
- member-facing forms: /my-forms list + /my-forms/[id] fill
  (MemberFillClient), "Forms" sidebar item for all members. Fixes the
  long-standing gap where members-only forms had no fill surface
- starter templates: lib/form-templates.ts + a picker on /forms/new
  (Liability waiver, Membership application, Event RSVP, Photo release,
  Blank) that seeds builder state (no table)
- forms-client relabels members-only forms (was showing a misleading
  /f/<space>/<slug> that always 404s for members-only)
- docs corrected per the audit pass: DATABASE_SCHEMA / DB_SCHEMA_MAP /
  ARCHITECTURE / API_REFERENCE now reflect migrations 014-029, per-space
  slug, /f/[space]/[slug], getPublicForm({space,slug}), invite roles;
  HANDOFF pass-19 added with residual gaps

337 tests; build verified (Compiled successfully, exit 0). No deploy.
This commit is contained in:
Moheeb Zara
2026-05-16 17:41:16 -07:00
parent df6951c323
commit 73eef7293a
14 changed files with 485 additions and 33 deletions
+5 -3
View File
@@ -393,10 +393,12 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
| 014-025 | See `docs/DATABASE_SCHEMA.md` migrations table (governance kernel, areas, forum/tiers/roles/invites, onboarding, permissions/ACLs, self-change hardening, incidents_insert re-assert) |
| 026 | `forms`, `form_submissions` + `forms.manage` permission (additive, default-deny RLS; submissions immutable + service-client-only) |
| 027 | `space_onboarding_steps.step_type` CHECK adds `'form'` (onboarding can embed a form/waiver; form id in step `config.form_id`) |
| 028 | Form slug unique PER SPACE (`UNIQUE(space_id, slug)`, drops global `UNIQUE(slug)`); public URL `/f/[space]/[slug]` |
| 029 | `space_invites.role` (member_role enum, default `member`); invites can grant a role; admin-granting invites are admin-only (app-enforced) |
---
## Tables added by migrations 016-026 (quick map)
## Tables added by migrations 016-029 (quick map)
| Table | Key columns | Purpose |
|-------|-------------|---------|
@@ -409,11 +411,11 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
| `space_tiers` | space_id, slug, name, monthly_price_cents, billing_cadence, is_system | Custom membership tiers (`space_members.tier_id` FK) |
| `space_role_labels` | space_id, role, display_name, color | Rename/recolor built-in roles |
| `space_custom_roles` / `space_member_custom_roles` | space_id, slug, name, color / member_id, custom_role_id | Custom non-privileged roles + assignment |
| `space_invites` | space_id, code, label, expires_at, max_uses, uses_count, is_enabled | Multi-code invites |
| `space_invites` | space_id, code, label, expires_at, max_uses, uses_count, is_enabled, role | Multi-code invites; `role` (member_role, default member) is the role the invite grants on join |
| `space_onboarding_steps` | space_id, step_key, step_type, title, body, config, is_enabled, is_required | Configurable member onboarding |
| `space_role_permissions` | space_id, subject, permission | Role/custom-role permission grants |
| `ops_acl` | space_id, entity_type, entity_id, role | Per-item Ops access list (secret/kb/process/area_lead) |
| `forms` | space_id, slug (global UNIQUE), kind (form/waiver), visibility (public_anon/public_auth/members), status (draft/published/closed), schema jsonb, legal_text, version | Custom forms + waivers. Write-gated by `forms.manage`; public page served via service-client server action (no anon grant) |
| `forms` | space_id, slug (UNIQUE per space_id), kind (form/waiver), visibility (public_anon/public_auth/members), status (draft/published/closed), schema jsonb, legal_text, version | Custom forms + waivers. Write-gated by `forms.manage`; public page `/f/[space]/[slug]` served via service-client server action (no anon grant) |
| `form_submissions` | form_id, space_id, member_id, submitter_email, answers jsonb, form_snapshot jsonb, legal_text_snapshot, form_version, ip, user_agent | Append-only submissions. Per-row snapshot = immutable waiver record. SELECT = `forms.manage`; NO write policy (service-client only; immutable) |
Column additions: `space_members.tier_id`, `onboarding_completed_at`, `onboarding_progress`; `secrets.encrypted_value`, `encryption_version`; `knowledge_base.render_markdown`, `is_meeting_minutes`, `meeting_date`. New enum `comment_entity_type`.
+5 -1
View File
@@ -65,7 +65,11 @@ export function FormsClient({ forms, spaceSlug }: { forms: FormRow[]; spaceSlug:
</Link>
{f.kind === 'waiver' && <Badge variant="secondary">Waiver</Badge>}
</div>
<p className="font-mono text-xs text-muted-foreground">/f/{spaceSlug}/{f.slug}</p>
<p className="font-mono text-xs text-muted-foreground">
{f.visibility === 'members'
? 'Members-only (filled from the in-app Forms page)'
: `/f/${spaceSlug}/${f.slug}`}
</p>
</div>
<Badge variant="outline">{VIS_LABEL[f.visibility] ?? f.visibility}</Badge>
<Badge variant={f.status === 'published' ? 'default' : 'secondary'}>
+2 -13
View File
@@ -1,6 +1,6 @@
import { requireFormsManagerPage } from '@/lib/forms-guard'
import { PageHeader, PageTitle } from '@/components/ui/page-title'
import { FormBuilder } from '@/components/forms/form-builder'
import { NewFormClient } from '@/components/forms/new-form-client'
export const dynamic = 'force-dynamic'
@@ -13,18 +13,7 @@ export default async function NewFormPage() {
<PageTitle>New form</PageTitle>
</PageHeader>
<div className="p-4 md:p-6">
<FormBuilder
initial={{
spaceSlug,
slug: '',
title: '',
description: '',
kind: 'form',
visibility: 'members',
legal_text: '',
schema: [],
}}
/>
<NewFormClient spaceSlug={spaceSlug} />
</div>
</>
)
+54
View File
@@ -0,0 +1,54 @@
import { notFound, redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { PageHeader, PageTitle } from '@/components/ui/page-title'
import { parseFormSchema } from '@/lib/forms-schema'
import { MemberFillClient } from '@/components/forms/member-fill-client'
export const dynamic = 'force-dynamic'
export default async function MemberFillPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: member } = await supabase
.from('space_members')
.select('space_id')
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
.single()
if (!member) redirect('/login')
const { data: form } = await supabase
.from('forms')
.select('id, title, description, kind, legal_text, schema, status, space_id')
.eq('id', id)
.eq('space_id', member.space_id)
.eq('status', 'published')
.maybeSingle()
if (!form) notFound()
return (
<>
<PageHeader>
<PageTitle>{form.title}</PageTitle>
</PageHeader>
<div className="mx-auto max-w-2xl p-4 md:p-8">
<MemberFillClient
formId={form.id}
description={form.description}
kind={form.kind}
legalText={form.legal_text}
fields={parseFormSchema(form.schema)}
/>
</div>
</>
)
}
+81
View File
@@ -0,0 +1,81 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { PageHeader, PageTitle } from '@/components/ui/page-title'
import { Badge } from '@/components/ui/badge'
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty'
import { ClipboardList } from 'lucide-react'
export const dynamic = 'force-dynamic'
// Member-facing list of forms a member can fill. Auth + membership are already
// enforced by the (app) layout; RLS lets a member read published forms in
// their space.
export default async function MyFormsPage() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: member } = await supabase
.from('space_members')
.select('space_id')
.eq('user_id', user.id)
.in('status', ['current', 'unverified', 'late'])
.single()
if (!member) redirect('/login')
const { data: forms } = await supabase
.from('forms')
.select('id, title, description, kind, status')
.eq('space_id', member.space_id)
.eq('status', 'published')
.order('created_at', { ascending: false })
const list = forms ?? []
return (
<>
<PageHeader>
<PageTitle>Forms</PageTitle>
</PageHeader>
<div className="p-4 md:p-6">
{list.length === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<ClipboardList />
</EmptyMedia>
<EmptyTitle>Nothing to fill right now</EmptyTitle>
<EmptyDescription>
Forms and waivers your space publishes will show up here.
</EmptyDescription>
</EmptyHeader>
</Empty>
) : (
<div className="divide-y rounded-lg border">
{list.map(f => (
<Link
key={f.id}
href={`/my-forms/${f.id}`}
className="flex items-center gap-3 p-4 hover:bg-muted/40"
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{f.title}</span>
{f.kind === 'waiver' && <Badge variant="secondary">Waiver</Badge>}
</div>
{f.description && (
<p className="truncate text-sm text-muted-foreground">{f.description}</p>
)}
</div>
<span className="text-sm text-primary">Open →</span>
</Link>
))}
</div>
)}
</div>
</>
)
}
+2 -1
View File
@@ -14,7 +14,7 @@ import {
Menu, X, LayoutDashboard, ListChecks, FolderKanban, Settings2, Wrench,
MessageSquare, Users, CreditCard, BookUser, Download, LogOut,
Vote, ShieldAlert, ScrollText, LineChart, UserCircle, UserSearch,
MessagesSquare, SlidersHorizontal, ClipboardList,
MessagesSquare, SlidersHorizontal, ClipboardList, FileText,
} from 'lucide-react'
interface NavLinkProps {
@@ -133,6 +133,7 @@ export function AppSidebar({ member, roleName, taskBadge = 0, commsBadge = 0, pa
<NavLink href="/payments" label="Payments" icon={CreditCard} active={isActive('/payments')} badge={paymentBadge} onClick={onNav} />
<NavLink href="/financials" label="Financials" icon={LineChart} active={isActive('/financials')} onClick={onNav} />
<NavLink href="/contacts" label="Contacts" icon={BookUser} active={isActive('/contacts')} onClick={onNav} />
<NavLink href="/my-forms" label="Forms" icon={FileText} active={isActive('/my-forms')} onClick={onNav} />
<NavLink href="/profile" label="My profile" icon={UserCircle} active={isActive('/profile')} onClick={onNav} />
{isAdmin && (
<NavLink href="/recruitment" label="Recruitment" icon={UserSearch} active={isActive('/recruitment')} onClick={onNav} />
+37 -2
View File
@@ -135,8 +135,15 @@ export function FormBuilder({ initial }: { initial: BuilderForm }) {
toast.error(res.error || 'Could not save')
return
}
toast.success(isEdit ? 'Form saved' : 'Form created')
router.push('/forms')
toast.success(isEdit ? 'Form saved' : 'Form created — now publish it to make it live')
if (isEdit) {
router.push('/forms')
} else {
// Land on the new form's edit page so the Publish step is right there
// (creating a form leaves it as a draft).
const newId = 'data' in res && res.data ? res.data.id : undefined
router.push(newId ? `/forms/${newId}/edit` : '/forms')
}
router.refresh()
}
@@ -180,6 +187,28 @@ export function FormBuilder({ initial }: { initial: BuilderForm }) {
<div className="grid gap-6 lg:grid-cols-2">
{/* Editor */}
<div className="space-y-6">
{isEdit && initial.status === 'draft' && (
<div className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm">
<strong>Draft.</strong> This form is not visible to anyone yet. Use{' '}
<strong>Publish</strong> below to make it live.
</div>
)}
{isEdit && initial.status === 'published' && (
<div className="rounded-lg border border-emerald-500/40 bg-emerald-500/10 p-3 text-sm">
<strong>Published.</strong> This form is live and accepting responses.
</div>
)}
{isEdit && initial.status === 'closed' && (
<div className="rounded-lg border bg-muted/40 p-3 text-sm">
<strong>Closed.</strong> No longer accepting responses. Re-publish to reopen.
</div>
)}
{!isEdit && (
<div className="rounded-lg border bg-muted/40 p-3 text-sm text-muted-foreground">
Build your form, then <strong>Create</strong>. It starts as a draft; you publish it on
the next screen.
</div>
)}
{isEdit && (
<div className="flex flex-wrap items-center gap-2 rounded-lg border p-3">
<Badge variant={initial.status === 'published' ? 'default' : 'secondary'}>
@@ -221,6 +250,12 @@ export function FormBuilder({ initial }: { initial: BuilderForm }) {
</Button>
</div>
)}
{isEdit && initial.status === 'published' && visibility === 'members' && (
<div className="rounded-lg border bg-muted/40 p-3 text-sm text-muted-foreground">
Members-only: there is no public link. Members fill this from their{' '}
<strong>Forms</strong> page in the app.
</div>
)}
<div className="space-y-3">
<SectionTitle>Form details</SectionTitle>
+98
View File
@@ -0,0 +1,98 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { FormRenderer } from '@/components/forms/form-renderer'
import { submitForm } from '@/lib/actions'
import type { FormField } from '@/lib/forms-schema'
// Authenticated member fill surface for members-only / in-app forms. The
// member is already signed in, so submitForm's visibility check passes;
// submission is by formId (slugs are per-space now).
export function MemberFillClient({
formId,
description,
kind,
legalText,
fields,
}: {
formId: string
description: string | null
kind: string
legalText: string | null
fields: FormField[]
}) {
const router = useRouter()
const [values, setValues] = useState<Record<string, unknown>>({})
const [consent, setConsent] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [done, setDone] = useState(false)
const isWaiver = kind === 'waiver'
async function submit() {
setError(null)
setSubmitting(true)
const res = await submitForm({
formId,
answers: values,
consent: isWaiver ? consent : undefined,
})
setSubmitting(false)
if ('error' in res && res.error) {
setError(res.error)
return
}
setDone(true)
}
if (done) {
return (
<div className="space-y-4 rounded-lg border p-8 text-center">
<h2 className="text-xl font-semibold">Thank you</h2>
<p className="text-sm text-muted-foreground">Your response has been recorded.</p>
<Button variant="secondary" onClick={() => router.push('/my-forms')}>
Back to forms
</Button>
</div>
)
}
return (
<div className="space-y-6">
{description && <p className="text-muted-foreground">{description}</p>}
{isWaiver && legalText && (
<div className="max-h-72 overflow-y-auto whitespace-pre-wrap rounded-lg border bg-muted/30 p-4 text-sm">
{legalText}
</div>
)}
<FormRenderer
fields={fields}
values={values}
onChange={(k, v) => setValues(p => ({ ...p, [k]: v }))}
/>
{isWaiver && (
<label className="flex items-start gap-2 text-sm">
<input
type="checkbox"
checked={consent}
onChange={e => setConsent(e.target.checked)}
className="mt-1"
/>
I have read and agree to the terms above.
</label>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button onClick={submit} disabled={submitting}>
{submitting ? 'Submitting…' : 'Submit'}
</Button>
</div>
)
}
+64
View File
@@ -0,0 +1,64 @@
'use client'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { FORM_TEMPLATES, type FormTemplate } from '@/lib/form-templates'
import { FormBuilder } from './form-builder'
// Template chooser shown first on /forms/new. Picking a template just seeds
// builder state (no DB row until the builder saves).
export function NewFormClient({ spaceSlug }: { spaceSlug: string }) {
const [tpl, setTpl] = useState<FormTemplate | null>(null)
if (tpl) {
return (
<div className="space-y-4">
<button
onClick={() => setTpl(null)}
className="text-sm text-muted-foreground hover:text-foreground"
>
← Choose a different template
</button>
<FormBuilder
initial={{
spaceSlug,
slug: '',
title: tpl.id === 'blank' ? '' : tpl.name,
description: '',
kind: tpl.kind,
visibility: 'members',
legal_text: tpl.legal_text,
schema: tpl.schema,
}}
/>
</div>
)
}
return (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Start from a template or a blank form. You can change everything afterwards.
</p>
<div className="grid gap-3 sm:grid-cols-2">
{FORM_TEMPLATES.map(t => (
<button
key={t.id}
onClick={() => setTpl(t)}
className="rounded-lg border p-4 text-left transition hover:border-primary"
>
<div className="flex items-center gap-2">
<span className="font-medium">{t.name}</span>
{t.kind === 'waiver' && <Badge variant="secondary">Waiver</Badge>}
</div>
<p className="mt-1 text-sm text-muted-foreground">{t.blurb}</p>
</button>
))}
</div>
<Button variant="ghost" onClick={() => setTpl(FORM_TEMPLATES[0])}>
Skip — start blank
</Button>
</div>
)
}
+3 -3
View File
@@ -874,7 +874,7 @@ validate input with Zod via `parseInput`, authorize via `requireMember` /
`createChannel` (any member), `renameChannel`, `deleteChannel` (creator or admin/board; default channels protected).
### Tiers / roles / invites — `tiers.ts`, `roles.ts`, `invites.ts`
`createTier`/`updateTier`/`deleteTier`; `upsertRoleLabel`, `createCustomRole`/`updateCustomRole`/`deleteCustomRole`, `assignCustomRole`/`unassignCustomRole`; `createInvite`/`updateInvite`/`deleteInvite`.
`createTier`/`updateTier`/`deleteTier`; `upsertRoleLabel`, `createCustomRole`/`updateCustomRole`/`deleteCustomRole`, `assignCustomRole`/`unassignCustomRole`; `createInvite`/`updateInvite`/`deleteInvite`. Invites (migration 029) carry a granted `role` and usage caps (`max_uses`, single-use); `joinSpace` applies the invite's role. Who may grant which role is enforced by `lib/invite-logic.ts` `canAssignInviteRole` (admin → any; board → anything except admin). Space-scoped link `/join/[space]?code=` hands off to `/signup?invite=`.
### Onboarding — `onboarding.ts`
`createOnboardingStep`/`updateOnboardingStep`/`deleteOnboardingStep` (admin/board); `markOnboardingStepDone`, `finishOnboarding`, `skipOnboarding` (member; completion writes via the service client after the server-side required-steps check, so the self-change trigger cannot be bypassed).
@@ -888,9 +888,9 @@ 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)
### Forms & waivers: `forms.ts` (migrations 026-029)
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`).
- `getPublicForm({ space, slug })` — service-client read for the public `/f/[space]/[slug]` page; returns only a published, non-members form. `createForm(input)` — friendly error on slug collision (slug is unique per space, migration 028). `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.
+11 -8
View File
@@ -415,20 +415,23 @@ Integration credentials stored in `integrations.config` (JSONB):
| 010 | Fixed channel trigger (removed description column) |
| 011 | Fixed enum values (active→current, pending→unverified) |
Migrations 012-026 are tracked authoritatively in `docs/DATABASE_SCHEMA.md`
Migrations 012-029 are tracked authoritatively in `docs/DATABASE_SCHEMA.md`
(governance kernel, areas, forum/tiers/roles/invites, configurable onboarding,
customizable permissions + Ops ACLs, self-change hardening, and `026` custom
forms + waivers).
customizable permissions + Ops ACLs, self-change hardening, `026`-`029` custom
forms + waivers + onboarding form step + per-space form slug + invite roles).
### Forms & waivers (migration 026 — Phase 1: schema + RLS)
### Forms & waivers (migrations 026-029; complete Phases 1-5)
`forms` and `form_submissions` back a custom form/waiver builder. The feature
is complete (Phases 1-5): schema + RLS; server actions in
`lib/actions/forms.ts`; the builder UI under `/forms` + the public `/f/[slug]`
page; onboarding `form` step type with required-waiver enforcement +
auto-satisfy (migration 027); and verified-email retro-link of anonymous
submissions (`claimMyAnonymousSubmissions`, hooked from `joinSpace` and
`lib/actions/forms.ts`; the builder UI under `/forms` (with starter
templates) + the public `/f/[space]/[slug]` page + a member-facing
`/my-forms` list; onboarding `form` step type with required-waiver
enforcement + auto-satisfy (migration 027); per-space form slug (028);
verified-email retro-link of anonymous submissions
(`claimMyAnonymousSubmissions`, hooked from `joinSpace` and
`finishOnboarding`; admin manual-link for unverified/admin-added members).
Invite codes can grant a role with usage caps (029; `/join/[space]`).
Security shape:
all submissions (anonymous, public-authenticated, or members) are written by a
single validated server action using the service client AFTER server-side
+4 -2
View File
@@ -686,7 +686,7 @@ CREATE INDEX idx_activity_space ON activity_log(space_id, created_at);
---
## Migrations 014-027 (additions since the 13-table baseline)
## Migrations 014-029 (additions since the 13-table baseline)
`scripts/schema.sql` is the canonical idempotent schema; each numbered
migration is mirrored as a section in it. Tables/columns added:
@@ -705,8 +705,10 @@ migration is mirrored as a section in it. Tables/columns added:
| 023 | `space_role_permissions`, `ops_acl`; `user_effective_roles()`, `user_has_permission()` (SECURITY DEFINER); `secrets`/`knowledge_base` SELECT rewritten as `existing-rule OR ops_acl-match` (additive) |
| 024 | `prevent_member_self_role_change()` extended to also block self-change of `tier_id` and `onboarding_completed_at` |
| 025 | Re-asserts the hardened `incidents_insert` policy verbatim (production convergence; access-neutral, no schema change) |
| 026 | `forms`, `form_submissions` (custom forms + waivers). jsonb field schema; per-submission snapshot of schema/legal-text/version for immutable waiver records. RLS additive + default-deny: forms SELECT = `forms.manage` holders (all) or members (published only), write = `user_has_permission(..., 'forms.manage')`; form_submissions SELECT = `forms.manage` only, NO write policy (every submission is written by one validated service-client server action, and submissions are immutable). New `forms.manage` permission seeded to board + backfilled |
| 027 | Adds `'form'` to the `space_onboarding_steps.step_type` CHECK so an onboarding step can embed a custom form/waiver (referenced form id stored in the step's existing `config` jsonb; no new column) |
| 026 | `forms`, `form_submissions` (custom forms + waivers). Globally-unique form slug; jsonb field schema; per-submission snapshot of schema/legal-text/version for immutable waiver records. RLS additive + default-deny: forms SELECT = `forms.manage` holders (all) or members (published only), write = `user_has_permission(..., 'forms.manage')`; form_submissions SELECT = `forms.manage` only, NO write policy (every submission is written by one validated service-client server action, and submissions are immutable). New `forms.manage` permission seeded to board + backfilled |
| 028 | Form slug is unique PER SPACE: drops the global `UNIQUE(forms.slug)`, adds `UNIQUE(space_id, slug)`. Public form URL is `/f/[space]/[slug]` |
| 029 | `space_invites.role` (member_role enum, default `member`) so an invite can grant a chosen role; legacy invites and the spaces.invite_code path keep granting `member`. Who may create an admin-granting invite is enforced in-app (only an admin) |
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
+27
View File
@@ -4,6 +4,33 @@ Append-only. Newest entries on top. Keep each entry to one screen.
---
## 2026-05-17 (pass 19) — Forms URL restructure + role invites + member surface + audit, LOCAL
User redirected after Phases 1-5 shipped. Decisions (memory `forms-feature` updated): per-space form slug + `/f/[space]/[slug]` (reverses the original global-slug lock), space-scoped invites incl. role-granting + usage caps, member-facing forms surface, starter templates, obvious publish flow.
### What shipped (local commits on top of `origin/main`)
- `5a49ecb` (PUSHED, deploy triggered, NEVER VERIFIED — superseded): `/f` added to middleware PUBLIC_ROUTES so the old `/f/[slug]` was anon-reachable. P6 replaces that route; re-verify the new one post-deploy.
- `8bbe99d` P6: migration 028 (drop global `UNIQUE(forms.slug)`, add `UNIQUE(space_id,slug)`); `getPublicForm({space,slug})`; route moved `app/f/[slug]` → `app/f/[space]/[slug]`; public client submits by `formId`; builder/list show `/f/<space>/<slug>`; forms-guard returns `spaceSlug`.
- `df6951c` P7 + audit hardening: migration 029 `space_invites.role` (member_role, default member); `lib/invite-logic.ts` `canAssignInviteRole` (admin→any, board→non-admin); createInvite/updateInvite enforce it; `joinSpace` applies invite role; invites panel role picker + single-use; `/join/[space]` landing (+ `/join` public); `/login` honors validated `?next=` (was hardcoded); `submitForm` requires `formId` (slug ambiguous post-028).
- P8 (this entry's commit): builder publish-flow UX (draft/published/closed banners, create→edit redirect so Publish is right there, members-only "no public link" note); member-facing `/my-forms` list + `/my-forms/[id]` fill (sidebar "Forms" for all members — fixes the long-standing members-only-forms-have-no-surface gap); starter templates picker on `/forms/new` (`lib/form-templates.ts`); forms-client relabels members-only forms (no misleading public URL).
- Docs corrected (two background audit agents ran): DATABASE_SCHEMA / DB_SCHEMA_MAP / ARCHITECTURE / API_REFERENCE — migration list 014-029, per-space slug, `/f/[space]/[slug]`, getPublicForm signature, invite roles. `next.config` `ignoreBuildErrors` noted (pre-existing).
### Audit results (parallel read-only agents + my review)
- Forms/invites P0: none. RLS sound, anon cannot read submissions/drafts, snapshot immutable, public route genuinely anon, retro-link verified-email-gated (no hijack), onboarding fail-open verified, permissions surface additive/untouched.
- Fixed this pass: `/login` `?next=` (P1), `submitForm` slug ambiguity (P1), members-only misleading public URL (P2).
### Open / residual gaps (honest)
- **NOTHING new deployed.** `origin/main` is at `5a49ecb` (unverified). P6/P7/P8 + doc commits are LOCAL. Migrations 026/027 deployed; 028/029 ship with the next push. Awaiting deploy approval.
- NOT browser-verified (no live browser): the whole builder→publish→public-submit→onboarding→retro-link→member-fill→role-invite→/join chain. Smoke-test list spans pass-18 + this entry.
- Systemic test gap: `__tests__/actions.test.ts` (49 "tests") largely asserts its own Supabase mock, not real actions. New pure logic is well covered (forms-logic, invite-logic, validateAnswers, schemas — 337 tests); action *orchestration* (visibility/RLS/retro-link IO) is NOT unit-tested (no mock harness in repo). Recommended discrete follow-up: a Supabase mock harness, then real action tests.
- Member fill has no "already submitted" indicator (members can't read form_submissions under RLS; would need a service-client probe). Duplicate member submissions possible; acceptable (waivers re-sign), noted.
- Production incident-filing RLS bug still open (not retried since `025`).
---
## 2026-05-16 (pass 18) — Forms + waivers Phases 1-5 COMPLETE (schema/RLS + actions + builder UI + onboarding step + retro-link), LOCAL, awaiting deploy approval
Branch: `main`. Migration: `026` (not yet deployed). `pnpm build` + `pnpm exec vitest run` gate pending in this entry's session.
+92
View File
@@ -0,0 +1,92 @@
// Code-defined starter templates for the form builder. No table: choosing a
// template just prefills builder state. Keep field keys valid
// (/^[a-z0-9_]+$/) and shapes aligned with formFieldSchema.
import type { FormField } from './forms-schema'
export interface FormTemplate {
id: string
name: string
blurb: string
kind: 'form' | 'waiver'
legal_text: string
schema: FormField[]
}
export const FORM_TEMPLATES: FormTemplate[] = [
{
id: 'blank',
name: 'Blank',
blurb: 'Start from scratch.',
kind: 'form',
legal_text: '',
schema: [],
},
{
id: 'liability_waiver',
name: 'Liability waiver',
blurb: 'Signed acknowledgement of risk for tools/space use.',
kind: 'waiver',
legal_text:
'I acknowledge that use of the space, its tools, and equipment carries inherent risks. I agree to follow all posted rules and training requirements, and I release the space, its operators, and its volunteers from liability for injury or loss to the fullest extent permitted by law.',
schema: [
{ key: 'full_name', type: 'short_text', label: 'Full legal name', required: true },
{ key: 'email', type: 'email', label: 'Email', required: true },
{ key: 'emergency_contact', type: 'short_text', label: 'Emergency contact (name & phone)', required: true },
{ key: 'date', type: 'date', label: 'Date', required: true },
],
},
{
id: 'membership_application',
name: 'Membership application',
blurb: 'Collect applicant details for a new member.',
kind: 'form',
legal_text: '',
schema: [
{ key: 'full_name', type: 'short_text', label: 'Full name', required: true },
{ key: 'email', type: 'email', label: 'Email', required: true },
{ key: 'phone', type: 'short_text', label: 'Phone', required: false },
{ key: 'interests', type: 'long_text', label: 'What do you want to work on?', required: false },
{
key: 'tier',
type: 'select',
label: 'Membership tier',
required: true,
options: ['Plus', 'Basic', 'Associate'],
},
{ key: 'agree_coc', type: 'checkbox', label: 'I agree to the code of conduct', required: true },
],
},
{
id: 'event_rsvp',
name: 'Event RSVP',
blurb: 'Headcount and details for an event.',
kind: 'form',
legal_text: '',
schema: [
{ key: 'name', type: 'short_text', label: 'Name', required: true },
{ key: 'email', type: 'email', label: 'Email', required: true },
{ key: 'guests', type: 'number', label: 'Number of guests', required: false },
{
key: 'attending',
type: 'radio',
label: 'Will you attend?',
required: true,
options: ['Yes', 'No', 'Maybe'],
},
],
},
{
id: 'photo_release',
name: 'Photo / media release',
blurb: 'Consent to use photos taken at the space.',
kind: 'waiver',
legal_text:
'I grant permission for photographs and recordings taken of me at the space to be used by the organization for documentation and promotional purposes. I understand I may withdraw this consent in writing at any time for future use.',
schema: [
{ key: 'full_name', type: 'short_text', label: 'Full name', required: true },
{ key: 'email', type: 'email', label: 'Email', required: true },
{ key: 'consent_scope', type: 'radio', label: 'Permitted use', required: true, options: ['All uses', 'Internal only'] },
{ key: 'date', type: 'date', label: 'Date', required: true },
],
},
]