diff --git a/DB_SCHEMA_MAP.md b/DB_SCHEMA_MAP.md index 6a6f79f..f852090 100644 --- a/DB_SCHEMA_MAP.md +++ b/DB_SCHEMA_MAP.md @@ -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`. diff --git a/app/(app)/forms/forms-client.tsx b/app/(app)/forms/forms-client.tsx index f8786ea..edb8e0c 100644 --- a/app/(app)/forms/forms-client.tsx +++ b/app/(app)/forms/forms-client.tsx @@ -65,7 +65,11 @@ export function FormsClient({ forms, spaceSlug }: { forms: FormRow[]; spaceSlug: {f.kind === 'waiver' && Waiver} -

/f/{spaceSlug}/{f.slug}

+

+ {f.visibility === 'members' + ? 'Members-only (filled from the in-app Forms page)' + : `/f/${spaceSlug}/${f.slug}`} +

{VIS_LABEL[f.visibility] ?? f.visibility} diff --git a/app/(app)/forms/new/page.tsx b/app/(app)/forms/new/page.tsx index 634df22..3f51ec3 100644 --- a/app/(app)/forms/new/page.tsx +++ b/app/(app)/forms/new/page.tsx @@ -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() { New form
- +
) diff --git a/app/(app)/my-forms/[id]/page.tsx b/app/(app)/my-forms/[id]/page.tsx new file mode 100644 index 0000000..68cdf18 --- /dev/null +++ b/app/(app)/my-forms/[id]/page.tsx @@ -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 ( + <> + + {form.title} + +
+ +
+ + ) +} diff --git a/app/(app)/my-forms/page.tsx b/app/(app)/my-forms/page.tsx new file mode 100644 index 0000000..2481a64 --- /dev/null +++ b/app/(app)/my-forms/page.tsx @@ -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 ( + <> + + Forms + +
+ {list.length === 0 ? ( + + + + + + Nothing to fill right now + + Forms and waivers your space publishes will show up here. + + + + ) : ( +
+ {list.map(f => ( + +
+
+ {f.title} + {f.kind === 'waiver' && Waiver} +
+ {f.description && ( +

{f.description}

+ )} +
+ Open → + + ))} +
+ )} +
+ + ) +} diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx index 37c108f..bfc0c8c 100644 --- a/components/app-sidebar.tsx +++ b/components/app-sidebar.tsx @@ -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 + {isAdmin && ( diff --git a/components/forms/form-builder.tsx b/components/forms/form-builder.tsx index fd8d694..6cd3e48 100644 --- a/components/forms/form-builder.tsx +++ b/components/forms/form-builder.tsx @@ -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 }) {
{/* Editor */}
+ {isEdit && initial.status === 'draft' && ( +
+ Draft. This form is not visible to anyone yet. Use{' '} + Publish below to make it live. +
+ )} + {isEdit && initial.status === 'published' && ( +
+ Published. This form is live and accepting responses. +
+ )} + {isEdit && initial.status === 'closed' && ( +
+ Closed. No longer accepting responses. Re-publish to reopen. +
+ )} + {!isEdit && ( +
+ Build your form, then Create. It starts as a draft; you publish it on + the next screen. +
+ )} {isEdit && (
@@ -221,6 +250,12 @@ export function FormBuilder({ initial }: { initial: BuilderForm }) {
)} + {isEdit && initial.status === 'published' && visibility === 'members' && ( +
+ Members-only: there is no public link. Members fill this from their{' '} + Forms page in the app. +
+ )}
Form details diff --git a/components/forms/member-fill-client.tsx b/components/forms/member-fill-client.tsx new file mode 100644 index 0000000..0ae995b --- /dev/null +++ b/components/forms/member-fill-client.tsx @@ -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>({}) + const [consent, setConsent] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(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 ( +
+

Thank you

+

Your response has been recorded.

+ +
+ ) + } + + return ( +
+ {description &&

{description}

} + + {isWaiver && legalText && ( +
+ {legalText} +
+ )} + + setValues(p => ({ ...p, [k]: v }))} + /> + + {isWaiver && ( + + )} + + {error &&

{error}

} + + +
+ ) +} 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 }, + ], + }, +]