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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user