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:
+5
-1
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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<string, PermissionCode[]> = {
|
||||
'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'],
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user