feat(forms): schema + RLS for custom forms and waivers (Phase 1)

Adds the forms and form_submissions tables behind a custom form/waiver
builder. Phase 1 is schema + RLS only; server actions, builder UI, the
public /f/[slug] page, onboarding integration, and retro-linking follow
in later phases.

- scripts/026_forms.sql (idempotent), mirrored as schema.sql Section 17
- forms: globally-unique slug, kind (form/waiver), visibility
  (public_anon/public_auth/members), status, jsonb field schema,
  legal_text, version
- form_submissions: per-row snapshot of schema/legal-text/version for
  immutable waiver records, plus ip/user_agent for e-sign defensibility
- RLS additive + default-deny: forms write gated by forms.manage via
  user_has_permission; form_submissions has no write policy, so it is
  service-client-only and immutable; anon gets no grant on forms
- new forms.manage permission seeded to board + backfilled
- types/database.ts and DATABASE_SCHEMA/DB_SCHEMA_MAP/ARCHITECTURE/HANDOFF
  updated
This commit is contained in:
Moheeb Zara
2026-05-16 14:38:28 -07:00
parent 3ace1afa17
commit 3a2d3ca13f
8 changed files with 302 additions and 5 deletions
+5 -1
View File
@@ -390,10 +390,12 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
| 011 | Renamed tasks `type` column to `task_type` (canonical enum column name) | | 011 | Renamed tasks `type` column to `task_type` (canonical enum column name) |
| 012 | Canonical sync — task_type enum, cleaned duplicate columns | | 012 | Canonical sync — task_type enum, cleaned duplicate columns |
| 013 | Added `space_members.last_paid_at` timestamptz — required by importMembers and linkPaymentToMember | | 013 | Added `space_members.last_paid_at` timestamptz — required by importMembers and linkPaymentToMember |
| 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) |
--- ---
## Tables added by migrations 016-024 (quick map) ## Tables added by migrations 016-026 (quick map)
| Table | Key columns | Purpose | | Table | Key columns | Purpose |
|-------|-------------|---------| |-------|-------------|---------|
@@ -410,6 +412,8 @@ Default channels created by trigger on space INSERT: `general`, `announcements`,
| `space_onboarding_steps` | space_id, step_key, step_type, title, body, config, is_enabled, is_required | Configurable member onboarding | | `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 | | `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) | | `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) |
| `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`. 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`.
+19
View File
@@ -415,6 +415,25 @@ Integration credentials stored in `integrations.config` (JSONB):
| 010 | Fixed channel trigger (removed description column) | | 010 | Fixed channel trigger (removed description column) |
| 011 | Fixed enum values (active→current, pending→unverified) | | 011 | Fixed enum values (active→current, pending→unverified) |
Migrations 012-026 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).
### Forms & waivers (migration 026 — Phase 1: schema + RLS)
`forms` and `form_submissions` back a custom form/waiver builder. Phase 1 is
schema + RLS only; server actions, the builder UI, the public `/f/[slug]` page,
onboarding integration, and retro-linking are later phases. Security shape:
all submissions (anonymous, public-authenticated, or members) are written by a
single validated server action using the service client AFTER server-side
schema validation and snapshotting (same pattern as `finishOnboarding`); the
`form_submissions` table has no INSERT/UPDATE/DELETE policy, so RLS hard-denies
every non-service client and submissions are immutable. The public fill page is
served by a service-client server action, so the `anon` Postgres role gets no
grant on `forms`. Form management is gated by the new additive `forms.manage`
permission via `user_has_permission`.
--- ---
## 13. Known Limitations ## 13. Known Limitations
+2 -1
View File
@@ -686,7 +686,7 @@ CREATE INDEX idx_activity_space ON activity_log(space_id, created_at);
--- ---
## Migrations 014-025 (additions since the 13-table baseline) ## Migrations 014-026 (additions since the 13-table baseline)
`scripts/schema.sql` is the canonical idempotent schema; each numbered `scripts/schema.sql` is the canonical idempotent schema; each numbered
migration is mirrored as a section in it. Tables/columns added: migration is mirrored as a section in it. Tables/columns added:
@@ -705,6 +705,7 @@ 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) | | 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` | | 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) | | 025 | Re-asserts the hardened `incidents_insert` policy verbatim (production convergence; access-neutral, no schema change) |
| 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 = space members, 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 |
New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`. New enum: `comment_entity_type` = `forum_thread | proposal | incident | policy`.
+29
View File
@@ -4,6 +4,35 @@ Append-only. Newest entries on top. Keep each entry to one screen.
--- ---
## 2026-05-16 (pass 18) — Forms + waivers Phase 1 (schema + RLS), LOCAL, awaiting deploy approval
Branch: `main`. Migration: `026` (not yet deployed). `pnpm build` + `pnpm exec vitest run` gate pending in this entry's session.
### State reconciliation (pass-17 prose was stale)
- Pass-17 said Tier 2+3 (`a1ea92a`, `b7461f0`) were "LOCAL/unpushed". They are in fact DEPLOYED: `git log origin/main..main` is empty, and Actions run `25973229017` (commit `3ace1af`) succeeded. So at pass-18 start: `origin/main == main`, clean, all UX Tiers 1-3 live. The pass-17 entry is left as-written (append-only); this is the correction of record.
- Branches present: `main`, `ux-polish-wip` (deletable backup), `hotfix-incident` (stale, not deleted — flag for user).
### Incident-filing RLS open item — STILL OPEN
- User confirms it has NOT been retried on prod since `025`. `025` only fixed cause #2 (policy drift). Item stays open: before any RLS change, retry on prod; if it still fails, run the discriminating SQL in the pass-17 entry to separate cause #1 (auth.uid() null in SSR) vs #3 (membership data drift). Permissions/RLS surface NOT to be modified without that diagnosis + user approval (guardrail).
### Forms + waivers Phase 1 (this session) — schema + RLS only, LOCAL
- New `scripts/026_forms.sql` (idempotent) + mirrored as `schema.sql` Section 17 + Section 15 seed-function updated.
- Tables: `forms` (space_id, globally-UNIQUE `slug`, `kind` form/waiver, `visibility` public_anon/public_auth/members, `status` draft/published/closed, `schema` jsonb, `legal_text`, `version`, `created_by`) and `form_submissions` (form_id, denormalized space_id, member_id, submitter_email, answers, `form_snapshot`/`legal_text_snapshot`/`form_version` immutable snapshots, ip, user_agent). Indexes on space/slug/form/email/member.
- RLS (additive, default-deny): `forms` SELECT = space members; INSERT/UPDATE/DELETE = `user_has_permission(..., 'forms.manage')`. `form_submissions` SELECT = `forms.manage` only; **no write policy at all** → RLS hard-denies every non-service client and makes submissions immutable. Every submission funnels through one validated service-client server action (Phase 2). Public `/f/[slug]` will be served by a service-client server action, so `anon` gets no grant on `forms` (user-chosen option).
- New `forms.manage` permission added to `lib/permissions-catalog.ts` (group Community) + board default; `seed_default_role_permissions()` extended; backfill `board -> forms.manage` for existing spaces (ON CONFLICT DO NOTHING).
- `types/database.ts`: `forms` + `form_submissions` added. Docs updated: DATABASE_SCHEMA (026 row), DB_SCHEMA_MAP (quick-map + migrations), ARCHITECTURE (forms note).
### Open / next
- **Awaiting user approval to deploy** (push to `main` = prod). Local commit only this session; do NOT push without explicit go.
- Phases 2-5 not started: server actions + Zod (`submitForm` via service client + email→member resolution + snapshot; list/results/CSV; linkSubmissionsForMember); builder UI + `/f/[slug]`; onboarding form step + required-waiver enforcement; retro-link hooks (join/signup/addMember/import + email verification).
- After an approved push: watch the Actions run, then smoke-test that `026` applied (`_migrations_applied` row) and a space's board has `forms.manage`.
---
## 2026-05-15/16 (pass 17) — UX polish Tier 1 (minus S3) + incident-RLS hotfix, both DEPLOYED ## 2026-05-15/16 (pass 17) — UX polish Tier 1 (minus S3) + incident-RLS hotfix, both DEPLOYED
Branch: `main`, clean. `origin/main` advanced `4c9b551` -> `1340b4c` (incident hotfix) -> `b8bb213` (UX Tier 1 minus S3 + docs), all deployed via GitHub Actions. Migrations applied through `025`. `pnpm build` clean, `pnpm exec vitest run` 271/271. Safety branch `ux-polish-wip` = pre-restructure stack (deletable once trust established). Branch: `main`, clean. `origin/main` advanced `4c9b551` -> `1340b4c` (incident hotfix) -> `b8bb213` (UX Tier 1 minus S3 + docs), all deployed via GitHub Actions. Migrations applied through `025`. `pnpm build` clean, `pnpm exec vitest run` 271/271. Safety branch `ux-polish-wip` = pre-restructure stack (deletable once trust established).
+2 -1
View File
@@ -21,6 +21,7 @@ export const PERMISSIONS = [
{ code: 'payments.manage', group: 'Finance', label: 'Manage payments' }, { code: 'payments.manage', group: 'Finance', label: 'Manage payments' },
{ code: 'governance.manage', group: 'Governance', label: 'Manage proposals, incidents, policies' }, { code: 'governance.manage', group: 'Governance', label: 'Manage proposals, incidents, policies' },
{ code: 'forum.moderate', group: 'Community', label: 'Moderate forum and comments' }, { code: 'forum.moderate', group: 'Community', label: 'Moderate forum and comments' },
{ code: 'forms.manage', group: 'Community', label: 'Manage forms and waivers' },
{ code: 'customize.manage', group: 'Admin', label: 'Customize roles, tiers, areas, invites, onboarding' }, { code: 'customize.manage', group: 'Admin', label: 'Customize roles, tiers, areas, invites, onboarding' },
{ code: 'settings.manage', group: 'Admin', label: 'Space settings, integrations, webhooks' }, { code: 'settings.manage', group: 'Admin', label: 'Space settings, integrations, webhooks' },
] as const ] as const
@@ -39,7 +40,7 @@ export const DEFAULT_ROLE_PERMISSIONS: Record<string, PermissionCode[]> = {
'ops.kb.read', 'ops.kb.write', 'ops.process.read', 'ops.process.write', 'ops.kb.read', 'ops.kb.write', 'ops.process.read', 'ops.process.write',
'ops.secrets.read', 'ops.secrets.write', 'ops.arealeads.manage', 'ops.secrets.read', 'ops.secrets.write', 'ops.arealeads.manage',
'members.manage', 'payments.manage', 'governance.manage', 'members.manage', 'payments.manage', 'governance.manage',
'forum.moderate', 'customize.manage', 'settings.manage', 'forum.moderate', 'forms.manage', 'customize.manage', 'settings.manage',
], ],
treasurer: ['payments.manage', 'ops.kb.read', 'ops.process.read'], treasurer: ['payments.manage', 'ops.kb.read', 'ops.process.read'],
member: ['ops.kb.read', 'ops.process.read'], member: ['ops.kb.read', 'ops.process.read'],
+139
View File
@@ -0,0 +1,139 @@
-- =============================================================================
-- 026: Custom forms and waivers (Phase 1 — schema + RLS only)
-- =============================================================================
-- Two tables:
--
-- forms a per-space form/waiver definition with a globally
-- unique public slug, a jsonb field schema, optional
-- legal text, a version counter, and a lifecycle status.
-- form_submissions an append-only record of a single submission. It
-- snapshots the form schema, legal text, and version at
-- submit time so a waiver stays valid against exactly what
-- the signer saw, regardless of later edits.
--
-- RLS posture (this is the security-sensitive part, kept additive and
-- default-deny):
--
-- * forms SELECT: any member of the space (powers the admin directory and
-- the in-app fill page). The public unauthenticated /f/[slug] page does
-- NOT read this table directly; it is served by a server action using the
-- service client, so the anon Postgres role gets no grant here.
-- * forms INSERT/UPDATE/DELETE: user_has_permission(..., 'forms.manage').
-- Additive — with no space_role_permissions rows, only admin (implicit-all)
-- can manage, which is the pre-feature behavior.
-- * form_submissions SELECT: user_has_permission(..., 'forms.manage') only.
-- Anonymous clients can never read submissions.
-- * form_submissions has NO INSERT/UPDATE/DELETE policy. With RLS enabled
-- that is a hard default-deny for every normal client. Every submission
-- (anon, public-auth, members) is written by one validated server action
-- using the service client (which bypasses RLS) AFTER server-side schema
-- validation and snapshotting. The absence of an UPDATE/DELETE policy also
-- makes submissions immutable for everyone except the service role, which
-- is the waiver-record guarantee enforced at the storage layer.
--
-- forms.manage is a new code in lib/permissions-catalog.ts. This migration
-- extends seed_default_role_permissions() so new spaces seed it to board, and
-- backfills board -> forms.manage for every existing space.
--
-- Idempotent: CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS,
-- DROP POLICY IF EXISTS then CREATE, CREATE OR REPLACE FUNCTION, and an
-- ON CONFLICT DO NOTHING backfill.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.forms (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
slug text NOT NULL UNIQUE
CHECK (slug ~ '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$'
AND char_length(slug) BETWEEN 1 AND 80),
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
description text,
kind text NOT NULL DEFAULT 'form'
CHECK (kind IN ('form','waiver')),
visibility text NOT NULL DEFAULT 'members'
CHECK (visibility IN ('public_anon','public_auth','members')),
status text NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','published','closed')),
schema jsonb NOT NULL DEFAULT '[]',
legal_text text,
version integer NOT NULL DEFAULT 1 CHECK (version >= 1),
created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_forms_space ON public.forms (space_id, status);
CREATE INDEX IF NOT EXISTS idx_forms_slug ON public.forms (slug);
CREATE TABLE IF NOT EXISTS public.form_submissions (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
form_id uuid NOT NULL REFERENCES public.forms(id) ON DELETE CASCADE,
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
member_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
submitter_email text,
answers jsonb NOT NULL DEFAULT '{}',
form_snapshot jsonb NOT NULL,
legal_text_snapshot text,
form_version integer NOT NULL,
ip inet,
user_agent text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_form_submissions_form ON public.form_submissions (form_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_form_submissions_space ON public.form_submissions (space_id);
CREATE INDEX IF NOT EXISTS idx_form_submissions_email ON public.form_submissions (submitter_email);
CREATE INDEX IF NOT EXISTS idx_form_submissions_member ON public.form_submissions (member_id);
ALTER TABLE public.forms ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.form_submissions ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS forms_select ON public.forms;
DROP POLICY IF EXISTS forms_insert ON public.forms;
DROP POLICY IF EXISTS forms_update ON public.forms;
DROP POLICY IF EXISTS forms_delete ON public.forms;
CREATE POLICY forms_select ON public.forms FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY forms_insert ON public.forms FOR INSERT
WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'forms.manage'));
CREATE POLICY forms_update ON public.forms FOR UPDATE
USING (public.user_has_permission(auth.uid(), space_id, 'forms.manage'));
CREATE POLICY forms_delete ON public.forms FOR DELETE
USING (public.user_has_permission(auth.uid(), space_id, 'forms.manage'));
-- SELECT only. No INSERT/UPDATE/DELETE policy is intentional: with RLS enabled
-- that denies every non-service client, which both funnels all writes through
-- the validated service-client server action and makes submissions immutable.
DROP POLICY IF EXISTS form_submissions_select ON public.form_submissions;
CREATE POLICY form_submissions_select ON public.form_submissions FOR SELECT
USING (public.user_has_permission(auth.uid(), space_id, 'forms.manage'));
DROP TRIGGER IF EXISTS trg_forms_touch ON public.forms;
CREATE TRIGGER trg_forms_touch
BEFORE UPDATE ON public.forms
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
-- Extend the per-space default-permission seed with forms.manage for board.
CREATE OR REPLACE FUNCTION public.seed_default_role_permissions()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public
AS $$
BEGIN
INSERT INTO public.space_role_permissions (space_id, subject, permission)
SELECT NEW.id, d.subject, d.permission
FROM (VALUES
('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'),
('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'),
('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'),
('board','governance.manage'),('board','forum.moderate'),('board','forms.manage'),
('board','customize.manage'),('board','settings.manage'),
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
('member','ops.kb.read'),('member','ops.process.read'),
('associate','ops.kb.read')
) AS d(subject, permission)
ON CONFLICT (space_id, subject, permission) DO NOTHING;
RETURN NEW;
END;
$$;
-- Backfill the new grant for spaces that already exist.
INSERT INTO public.space_role_permissions (space_id, subject, permission)
SELECT id, 'board', 'forms.manage' FROM public.spaces
ON CONFLICT (space_id, subject, permission) DO NOTHING;
+94 -2
View File
@@ -1821,8 +1821,8 @@ BEGIN
('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'), ('board','ops.kb.read'),('board','ops.kb.write'),('board','ops.process.read'),
('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'), ('board','ops.process.write'),('board','ops.secrets.read'),('board','ops.secrets.write'),
('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'), ('board','ops.arealeads.manage'),('board','members.manage'),('board','payments.manage'),
('board','governance.manage'),('board','forum.moderate'),('board','customize.manage'), ('board','governance.manage'),('board','forum.moderate'),('board','forms.manage'),
('board','settings.manage'), ('board','customize.manage'),('board','settings.manage'),
('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'), ('treasurer','payments.manage'),('treasurer','ops.kb.read'),('treasurer','ops.process.read'),
('member','ops.kb.read'),('member','ops.process.read'), ('member','ops.kb.read'),('member','ops.process.read'),
('associate','ops.kb.read') ('associate','ops.kb.read')
@@ -1874,3 +1874,95 @@ DROP TRIGGER IF EXISTS trg_prevent_member_self_role_change ON public.space_membe
CREATE TRIGGER trg_prevent_member_self_role_change CREATE TRIGGER trg_prevent_member_self_role_change
BEFORE UPDATE ON public.space_members BEFORE UPDATE ON public.space_members
FOR EACH ROW EXECUTE FUNCTION public.prevent_member_self_role_change(); FOR EACH ROW EXECUTE FUNCTION public.prevent_member_self_role_change();
-- =============================================================================
-- 17. Custom forms and waivers (schema + RLS).
-- Equivalent to scripts/026_forms.sql. The forms.manage seed for board is
-- already in seed_default_role_permissions() in Section 15; the backfill
-- below is a no-op on a clean database (no spaces yet) and idempotent on an
-- existing one.
--
-- RLS is additive and default-deny:
-- * forms SELECT = any member of the space. The public unauthenticated
-- /f/[slug] page is served by a service-client server action, so the
-- anon role gets no grant on this table.
-- * forms write = user_has_permission(..., 'forms.manage').
-- * form_submissions SELECT = user_has_permission(..., 'forms.manage');
-- anon can never read submissions.
-- * form_submissions has NO write policy: with RLS on, that denies every
-- non-service client, funnelling all writes through the validated
-- service-client server action and making submissions immutable.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.forms (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
slug text NOT NULL UNIQUE
CHECK (slug ~ '^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$'
AND char_length(slug) BETWEEN 1 AND 80),
title text NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
description text,
kind text NOT NULL DEFAULT 'form'
CHECK (kind IN ('form','waiver')),
visibility text NOT NULL DEFAULT 'members'
CHECK (visibility IN ('public_anon','public_auth','members')),
status text NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft','published','closed')),
schema jsonb NOT NULL DEFAULT '[]',
legal_text text,
version integer NOT NULL DEFAULT 1 CHECK (version >= 1),
created_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_forms_space ON public.forms (space_id, status);
CREATE INDEX IF NOT EXISTS idx_forms_slug ON public.forms (slug);
CREATE TABLE IF NOT EXISTS public.form_submissions (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
form_id uuid NOT NULL REFERENCES public.forms(id) ON DELETE CASCADE,
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
member_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
submitter_email text,
answers jsonb NOT NULL DEFAULT '{}',
form_snapshot jsonb NOT NULL,
legal_text_snapshot text,
form_version integer NOT NULL,
ip inet,
user_agent text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_form_submissions_form ON public.form_submissions (form_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_form_submissions_space ON public.form_submissions (space_id);
CREATE INDEX IF NOT EXISTS idx_form_submissions_email ON public.form_submissions (submitter_email);
CREATE INDEX IF NOT EXISTS idx_form_submissions_member ON public.form_submissions (member_id);
ALTER TABLE public.forms ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.form_submissions ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS forms_select ON public.forms;
DROP POLICY IF EXISTS forms_insert ON public.forms;
DROP POLICY IF EXISTS forms_update ON public.forms;
DROP POLICY IF EXISTS forms_delete ON public.forms;
CREATE POLICY forms_select ON public.forms FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY forms_insert ON public.forms FOR INSERT
WITH CHECK (public.user_has_permission(auth.uid(), space_id, 'forms.manage'));
CREATE POLICY forms_update ON public.forms FOR UPDATE
USING (public.user_has_permission(auth.uid(), space_id, 'forms.manage'));
CREATE POLICY forms_delete ON public.forms FOR DELETE
USING (public.user_has_permission(auth.uid(), space_id, 'forms.manage'));
DROP POLICY IF EXISTS form_submissions_select ON public.form_submissions;
CREATE POLICY form_submissions_select ON public.form_submissions FOR SELECT
USING (public.user_has_permission(auth.uid(), space_id, 'forms.manage'));
DROP TRIGGER IF EXISTS trg_forms_touch ON public.forms;
CREATE TRIGGER trg_forms_touch
BEFORE UPDATE ON public.forms
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
INSERT INTO public.space_role_permissions (space_id, subject, permission)
SELECT id, 'board', 'forms.manage' FROM public.spaces
ON CONFLICT (space_id, subject, permission) DO NOTHING;
+12
View File
@@ -94,6 +94,18 @@ export type Database = {
Update: { member_id?: string; custom_role_id?: string; assigned_at?: string } Update: { member_id?: string; custom_role_id?: string; assigned_at?: string }
Relationships: [] Relationships: []
} }
forms: {
Row: { id: string; space_id: string; slug: string; title: string; description: string | null; kind: string; visibility: string; status: string; schema: Json; legal_text: string | null; version: number; created_by: string | null; created_at: string; updated_at: string }
Insert: { id?: string; space_id: string; slug: string; title: string; description?: string | null; kind?: string; visibility?: string; status?: string; schema?: Json; legal_text?: string | null; version?: number; created_by?: string | null; created_at?: string; updated_at?: string }
Update: { id?: string; space_id?: string; slug?: string; title?: string; description?: string | null; kind?: string; visibility?: string; status?: string; schema?: Json; legal_text?: string | null; version?: number; created_by?: string | null; created_at?: string; updated_at?: string }
Relationships: []
}
form_submissions: {
Row: { id: string; form_id: string; space_id: string; member_id: string | null; submitter_email: string | null; answers: Json; form_snapshot: Json; legal_text_snapshot: string | null; form_version: number; ip: string | null; user_agent: string | null; created_at: string }
Insert: { id?: string; form_id: string; space_id: string; member_id?: string | null; submitter_email?: string | null; answers?: Json; form_snapshot: Json; legal_text_snapshot?: string | null; form_version: number; ip?: string | null; user_agent?: string | null; created_at?: string }
Update: { id?: string; form_id?: string; space_id?: string; member_id?: string | null; submitter_email?: string | null; answers?: Json; form_snapshot?: Json; legal_text_snapshot?: string | null; form_version?: number; ip?: string | null; user_agent?: string | null; created_at?: string }
Relationships: []
}
activity_log: { activity_log: {
Row: { Row: {
action: string action: string