+ )
+}
diff --git a/components/forms/new-form-client.tsx b/components/forms/new-form-client.tsx
new file mode 100644
index 0000000..982849f
--- /dev/null
+++ b/components/forms/new-form-client.tsx
@@ -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(null)
+
+ if (tpl) {
+ return (
+
+
+
+
+ )
+ }
+
+ return (
+
+
+ Start from a template or a blank form. You can change everything afterwards.
+
+
+ {FORM_TEMPLATES.map(t => (
+
+ ))}
+
+
+
+ )
+}
diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md
index 31d4a81..0c6a877 100644
--- a/docs/API_REFERENCE.md
+++ b/docs/API_REFERENCE.md
@@ -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.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index c20bc6f..77d0e85 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -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
diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md
index 17e0e9a..8dfc2ee 100644
--- a/docs/DATABASE_SCHEMA.md
+++ b/docs/DATABASE_SCHEMA.md
@@ -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`.
diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md
index d57a176..8e3e9bf 100644
--- a/docs/HANDOFF.md
+++ b/docs/HANDOFF.md
@@ -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//`; 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.
diff --git a/lib/form-templates.ts b/lib/form-templates.ts
new file mode 100644
index 0000000..613fe11
--- /dev/null
+++ b/lib/form-templates.ts
@@ -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 },
+ ],
+ },
+]