From 3a2d3ca13f5d6daef91b628b9fa67e16dd2bd16d Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Sat, 16 May 2026 14:38:28 -0700 Subject: [PATCH] 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 --- DB_SCHEMA_MAP.md | 6 +- docs/ARCHITECTURE.md | 19 +++++ docs/DATABASE_SCHEMA.md | 3 +- docs/HANDOFF.md | 29 ++++++++ lib/permissions-catalog.ts | 3 +- scripts/026_forms.sql | 139 +++++++++++++++++++++++++++++++++++++ scripts/schema.sql | 96 ++++++++++++++++++++++++- types/database.ts | 12 ++++ 8 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 scripts/026_forms.sql diff --git a/DB_SCHEMA_MAP.md b/DB_SCHEMA_MAP.md index 85fe98c..d24eb8c 100644 --- a/DB_SCHEMA_MAP.md +++ b/DB_SCHEMA_MAP.md @@ -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) | | 012 | Canonical sync — task_type enum, cleaned duplicate columns | | 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 | |-------|-------------|---------| @@ -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_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) | +| `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/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 57ace6d..70d22fb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -415,6 +415,25 @@ 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` +(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 diff --git a/docs/DATABASE_SCHEMA.md b/docs/DATABASE_SCHEMA.md index b055e75..2caf367 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-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 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) | | 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). 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`. diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 29a0ebc..cf2b5d2 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -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 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). diff --git a/lib/permissions-catalog.ts b/lib/permissions-catalog.ts index 15aff9c..304a6e9 100644 --- a/lib/permissions-catalog.ts +++ b/lib/permissions-catalog.ts @@ -21,6 +21,7 @@ export const PERMISSIONS = [ { code: 'payments.manage', group: 'Finance', label: 'Manage payments' }, { code: 'governance.manage', group: 'Governance', label: 'Manage proposals, incidents, policies' }, { 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: 'settings.manage', group: 'Admin', label: 'Space settings, integrations, webhooks' }, ] as const @@ -39,7 +40,7 @@ export const DEFAULT_ROLE_PERMISSIONS: Record = { 'ops.kb.read', 'ops.kb.write', 'ops.process.read', 'ops.process.write', 'ops.secrets.read', 'ops.secrets.write', 'ops.arealeads.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'], member: ['ops.kb.read', 'ops.process.read'], diff --git a/scripts/026_forms.sql b/scripts/026_forms.sql new file mode 100644 index 0000000..2bc0997 --- /dev/null +++ b/scripts/026_forms.sql @@ -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; diff --git a/scripts/schema.sql b/scripts/schema.sql index 566417e..09e6829 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -1821,8 +1821,8 @@ BEGIN ('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','customize.manage'), - ('board','settings.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') @@ -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 BEFORE UPDATE ON public.space_members 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; diff --git a/types/database.ts b/types/database.ts index 72781e4..b50bd57 100644 --- a/types/database.ts +++ b/types/database.ts @@ -94,6 +94,18 @@ export type Database = { Update: { member_id?: string; custom_role_id?: string; assigned_at?: string } 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: { Row: { action: string