Adds proposals + proposal_votes + policies + incidents + incident_updates
tables with full RLS, async voting flow, dismissal-appeal pathway,
versioned policies with provenance, and an anonymous-incident token mode.
Adds per-space configurable areas, financial visibility settings, member
self-profile + COI disclosure, and a mission statement that renders in
the app footer.
Reorganises lib/actions.ts into per-domain files (tasks, projects,
members, payments, contacts, knowledge-base, secrets, area-leads,
settings, imports, proposals, incidents, policies, areas) plus a barrel.
Adds lib/permissions.ts and lib/auth-helpers.ts so action bodies stop
duplicating the user-to-member-to-role check. Wires every action that has
a Zod schema; introduces a flexibleDateTime preprocessor so HTML date
pickers no longer hit "Invalid datetime".
Hardens RLS: drops NOT NULL on space_members.user_id for offline members,
adds a privilege-escalation trigger that blocks self-edits to role / tier
/ status / approved / has_card_access / space_id, closes four
cross-tenant holes in the governance kernel, and adds a partial unique
index so two admins cannot concurrently activate two drafts of the same
policy slug.
Ships every deployment artifact: Dockerfile, docker-compose,
.do/app.yaml, .env.example, /api/health, plus a managed-Supabase guide
(docs/DEPLOYMENT.md), an end-to-end self-hosted Droplet guide
(docs/DEPLOY_DO_SELFHOSTED.md), and a Supabase-CLI local-dev quickstart
(docs/LOCAL_DEV.md). Replaces middleware.ts with proxy.ts for Next.js 16.
Adds scripts/schema.sql as the canonical idempotent full deploy plus
fourteen numbered incremental migrations.
Adds scripts/seed.sql so a fresh local DB renders something instead of
empty pages: a demo space, five members across all roles, a v1 code of
conduct, three proposals in different states with seven votes already
cast, an open incident, a project, three tasks, a KB entry, and matching
activity-log rows.
Replaces v0-generated test scaffolding with 266 real tests across
__tests__/{actions,auth-helpers,components,governance,permissions,
security,utils,validations}.test.ts. Adds react-markdown + remark-gfm
for proposal / incident / policy body rendering. Deletes four dead v0
fix-* scripts, the unused components/app-shell.tsx, and the duplicate
app/auth/login/page.tsx.
Routes (31 total): /api/health, /api/paypal/sync, /auth/callback, /,
/login, /signup, /signup/confirm, /dashboard, /tasks, /projects,
/members, /payments, /financials, /contacts, /ops, /ops/[id], /ops/new,
/proposals, /proposals/[id], /proposals/new, /incidents, /incidents/[id],
/incidents/new, /policies, /policies/[slug], /policies/new, /profile,
/recruitment, /settings, /comms, /import.
Full pass-by-pass history is in docs/HANDOFF.md; the roadmap is in
docs/GOVERNANCE_FEATURES.md.
512 lines
24 KiB
PL/PgSQL
512 lines
24 KiB
PL/PgSQL
-- =============================================================================
|
|
-- 016: Governance kernel — proposals, incidents, policies
|
|
-- =============================================================================
|
|
-- Tier 1 of docs/GOVERNANCE_FEATURES.md.
|
|
--
|
|
-- This migration adds three tightly integrated entities:
|
|
--
|
|
-- proposals + proposal_votes — async voting (Patterns 3, 4, 5, 12)
|
|
-- incidents + incident_updates — CoC / safety report tracking (Pattern 1)
|
|
-- policies — versioned bylaws with provenance (Pattern 4)
|
|
--
|
|
-- Plus six new columns on `spaces` for per-space governance defaults
|
|
-- (quorum percent, voting window, incident SLA, mission statement, etc).
|
|
--
|
|
-- The three modules cross-reference: a proposal can cite a policy clause, an
|
|
-- incident appeal becomes a proposal, a passed bylaw-change proposal supersedes
|
|
-- a policy version. The circular FKs are added via ALTER TABLE after creation.
|
|
--
|
|
-- Safe to re-run: CREATE TABLE IF NOT EXISTS, DROP POLICY IF EXISTS before
|
|
-- CREATE POLICY, CREATE OR REPLACE FUNCTION, etc.
|
|
-- =============================================================================
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 1. ENUMS
|
|
-- -----------------------------------------------------------------------------
|
|
DO $$ BEGIN CREATE TYPE public.proposal_type AS ENUM ('bylaw_change','board_action','membership_vote','advisory_poll','recall','budget'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
DO $$ BEGIN CREATE TYPE public.proposal_status AS ENUM ('draft','open','decided','withdrawn','expired'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
DO $$ BEGIN CREATE TYPE public.threshold_rule AS ENUM ('simple_majority','two_thirds','three_fourths','unanimous'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
DO $$ BEGIN CREATE TYPE public.vote_position AS ENUM ('yes','no','abstain','recused'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
DO $$ BEGIN CREATE TYPE public.incident_status AS ENUM ('received','under_review','decided','appealed','closed'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
DO $$ BEGIN CREATE TYPE public.incident_severity AS ENUM ('low','medium','high','critical'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
DO $$ BEGIN CREATE TYPE public.incident_update_visibility AS ENUM ('reporter_only','all_parties','board_only'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
DO $$ BEGIN CREATE TYPE public.policy_status AS ENUM ('draft','active','deprecated','superseded'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 2. SPACES — per-space governance defaults
|
|
-- -----------------------------------------------------------------------------
|
|
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_quorum_percent integer NOT NULL DEFAULT 10;
|
|
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_quorum_floor integer NOT NULL DEFAULT 1;
|
|
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_voting_window_hours integer NOT NULL DEFAULT 216; -- 9 days
|
|
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS default_threshold public.threshold_rule NOT NULL DEFAULT 'simple_majority';
|
|
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS incident_sla_hours integer NOT NULL DEFAULT 72; -- 3 days
|
|
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS mission_statement text;
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 3. TABLES
|
|
-- Order matters because of the circular FKs. Create the tables first without
|
|
-- the circular FK columns enforced, then add constraints via ALTER TABLE.
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
-- proposals --------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS public.proposals (
|
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
|
proposer_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
|
proposer_name text,
|
|
title text NOT NULL,
|
|
body text NOT NULL DEFAULT '',
|
|
proposal_type public.proposal_type NOT NULL DEFAULT 'advisory_poll',
|
|
status public.proposal_status NOT NULL DEFAULT 'draft',
|
|
quorum_required integer NOT NULL DEFAULT 0,
|
|
quorum_percent integer NOT NULL DEFAULT 10,
|
|
quorum_floor integer NOT NULL DEFAULT 1,
|
|
threshold public.threshold_rule NOT NULL DEFAULT 'simple_majority',
|
|
voting_opens_at timestamptz,
|
|
voting_closes_at timestamptz,
|
|
policy_ref_id uuid,
|
|
parent_incident_id uuid,
|
|
outcome_yes integer NOT NULL DEFAULT 0,
|
|
outcome_no integer NOT NULL DEFAULT 0,
|
|
outcome_abstain integer NOT NULL DEFAULT 0,
|
|
outcome_recused integer NOT NULL DEFAULT 0,
|
|
total_voters integer NOT NULL DEFAULT 0,
|
|
quorum_met boolean,
|
|
passed boolean,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
decided_at timestamptz
|
|
);
|
|
|
|
-- proposal_votes ---------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS public.proposal_votes (
|
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
proposal_id uuid NOT NULL REFERENCES public.proposals(id) ON DELETE CASCADE,
|
|
member_id uuid NOT NULL REFERENCES public.space_members(id) ON DELETE CASCADE,
|
|
position public.vote_position NOT NULL,
|
|
recusal_reason text,
|
|
comment text,
|
|
voted_at timestamptz NOT NULL DEFAULT now(),
|
|
UNIQUE (proposal_id, member_id),
|
|
CONSTRAINT recused_requires_reason
|
|
CHECK (position <> 'recused' OR (recusal_reason IS NOT NULL AND length(trim(recusal_reason)) > 0))
|
|
);
|
|
|
|
-- incidents --------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS public.incidents (
|
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
|
reporter_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
|
reporter_token text UNIQUE,
|
|
is_anonymous boolean NOT NULL DEFAULT false,
|
|
subjects uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
|
category text NOT NULL DEFAULT 'general',
|
|
severity public.incident_severity NOT NULL DEFAULT 'medium',
|
|
title text NOT NULL,
|
|
body text NOT NULL,
|
|
status public.incident_status NOT NULL DEFAULT 'received',
|
|
disposition text,
|
|
decision_maker_ids uuid[] NOT NULL DEFAULT '{}'::uuid[],
|
|
appeal_proposal_id uuid,
|
|
sla_response_by timestamptz,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
acknowledged_at timestamptz,
|
|
decided_at timestamptz,
|
|
closed_at timestamptz
|
|
);
|
|
|
|
-- incident_updates -------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS public.incident_updates (
|
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
incident_id uuid NOT NULL REFERENCES public.incidents(id) ON DELETE CASCADE,
|
|
author_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
|
|
author_name text,
|
|
body text NOT NULL,
|
|
visibility public.incident_update_visibility NOT NULL DEFAULT 'all_parties',
|
|
created_at timestamptz NOT NULL DEFAULT now()
|
|
);
|
|
|
|
-- policies ---------------------------------------------------------------------
|
|
CREATE TABLE IF NOT EXISTS public.policies (
|
|
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
|
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
|
|
slug text NOT NULL,
|
|
section_ref text,
|
|
parent_policy_id uuid REFERENCES public.policies(id) ON DELETE SET NULL,
|
|
title text NOT NULL,
|
|
body_formal text NOT NULL DEFAULT '',
|
|
body_plain text,
|
|
version integer NOT NULL DEFAULT 1,
|
|
prior_version_id uuid REFERENCES public.policies(id) ON DELETE SET NULL,
|
|
status public.policy_status NOT NULL DEFAULT 'draft',
|
|
effective_at timestamptz,
|
|
adopted_by_proposal_id uuid,
|
|
created_at timestamptz NOT NULL DEFAULT now(),
|
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
UNIQUE (space_id, slug, version)
|
|
);
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 4. CIRCULAR FOREIGN KEYS — added after all three tables exist
|
|
-- -----------------------------------------------------------------------------
|
|
DO $$ BEGIN
|
|
ALTER TABLE public.proposals
|
|
ADD CONSTRAINT proposals_policy_ref_fk
|
|
FOREIGN KEY (policy_ref_id) REFERENCES public.policies(id) ON DELETE SET NULL;
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
|
|
DO $$ BEGIN
|
|
ALTER TABLE public.proposals
|
|
ADD CONSTRAINT proposals_parent_incident_fk
|
|
FOREIGN KEY (parent_incident_id) REFERENCES public.incidents(id) ON DELETE SET NULL;
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
|
|
DO $$ BEGIN
|
|
ALTER TABLE public.incidents
|
|
ADD CONSTRAINT incidents_appeal_proposal_fk
|
|
FOREIGN KEY (appeal_proposal_id) REFERENCES public.proposals(id) ON DELETE SET NULL;
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
|
|
DO $$ BEGIN
|
|
ALTER TABLE public.policies
|
|
ADD CONSTRAINT policies_adopted_by_proposal_fk
|
|
FOREIGN KEY (adopted_by_proposal_id) REFERENCES public.proposals(id) ON DELETE SET NULL;
|
|
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 5. INDEXES
|
|
-- -----------------------------------------------------------------------------
|
|
CREATE INDEX IF NOT EXISTS idx_proposals_space ON public.proposals (space_id);
|
|
CREATE INDEX IF NOT EXISTS idx_proposals_status ON public.proposals (status);
|
|
CREATE INDEX IF NOT EXISTS idx_proposals_proposer ON public.proposals (proposer_id);
|
|
CREATE INDEX IF NOT EXISTS idx_proposals_closes ON public.proposals (voting_closes_at);
|
|
CREATE INDEX IF NOT EXISTS idx_proposal_votes_proposal ON public.proposal_votes (proposal_id);
|
|
CREATE INDEX IF NOT EXISTS idx_proposal_votes_member ON public.proposal_votes (member_id);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_incidents_space ON public.incidents (space_id);
|
|
CREATE INDEX IF NOT EXISTS idx_incidents_reporter ON public.incidents (reporter_id);
|
|
CREATE INDEX IF NOT EXISTS idx_incidents_status ON public.incidents (status);
|
|
CREATE INDEX IF NOT EXISTS idx_incidents_subjects ON public.incidents USING GIN (subjects);
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_incidents_token_unique
|
|
ON public.incidents (reporter_token)
|
|
WHERE reporter_token IS NOT NULL;
|
|
CREATE INDEX IF NOT EXISTS idx_incident_updates_incident ON public.incident_updates (incident_id, created_at DESC);
|
|
|
|
CREATE INDEX IF NOT EXISTS idx_policies_space_slug ON public.policies (space_id, slug);
|
|
CREATE INDEX IF NOT EXISTS idx_policies_status ON public.policies (status);
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 6. TRIGGERS
|
|
-- -----------------------------------------------------------------------------
|
|
|
|
-- 6.1 Computes quorum_required and voting window when a proposal opens.
|
|
CREATE OR REPLACE FUNCTION public.compute_proposal_quorum()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
DECLARE
|
|
v_member_count integer;
|
|
v_quorum_percent integer;
|
|
v_quorum_floor integer;
|
|
v_voting_window_hours integer;
|
|
BEGIN
|
|
IF NEW.status = 'open' AND (TG_OP = 'INSERT' OR OLD.status = 'draft') THEN
|
|
SELECT default_quorum_percent, default_quorum_floor, default_voting_window_hours
|
|
INTO v_quorum_percent, v_quorum_floor, v_voting_window_hours
|
|
FROM public.spaces WHERE id = NEW.space_id;
|
|
|
|
SELECT count(*) INTO v_member_count
|
|
FROM public.space_members
|
|
WHERE space_id = NEW.space_id
|
|
AND status IN ('current','late')
|
|
AND approved = true;
|
|
|
|
NEW.quorum_percent := COALESCE(v_quorum_percent, 10);
|
|
NEW.quorum_floor := COALESCE(v_quorum_floor, 1);
|
|
NEW.quorum_required := GREATEST(
|
|
NEW.quorum_floor,
|
|
CEIL(v_member_count * NEW.quorum_percent / 100.0)::integer
|
|
);
|
|
|
|
IF NEW.voting_opens_at IS NULL THEN
|
|
NEW.voting_opens_at := now();
|
|
END IF;
|
|
IF NEW.voting_closes_at IS NULL THEN
|
|
NEW.voting_closes_at := NEW.voting_opens_at + (v_voting_window_hours || ' hours')::interval;
|
|
END IF;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
DROP TRIGGER IF EXISTS trg_compute_proposal_quorum ON public.proposals;
|
|
CREATE TRIGGER trg_compute_proposal_quorum
|
|
BEFORE INSERT OR UPDATE ON public.proposals
|
|
FOR EACH ROW EXECUTE FUNCTION public.compute_proposal_quorum();
|
|
|
|
|
|
-- 6.2 Recomputes proposal vote tallies on any vote insert / update / delete.
|
|
CREATE OR REPLACE FUNCTION public.refresh_proposal_tally()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
DECLARE
|
|
v_proposal_id uuid;
|
|
v_yes integer;
|
|
v_no integer;
|
|
v_abstain integer;
|
|
v_recused integer;
|
|
v_total integer;
|
|
v_quorum_required integer;
|
|
v_threshold public.threshold_rule;
|
|
v_status public.proposal_status;
|
|
v_quorum_met boolean;
|
|
v_passed boolean;
|
|
BEGIN
|
|
v_proposal_id := COALESCE(NEW.proposal_id, OLD.proposal_id);
|
|
|
|
SELECT
|
|
count(*) FILTER (WHERE position = 'yes'),
|
|
count(*) FILTER (WHERE position = 'no'),
|
|
count(*) FILTER (WHERE position = 'abstain'),
|
|
count(*) FILTER (WHERE position = 'recused'),
|
|
count(*)
|
|
INTO v_yes, v_no, v_abstain, v_recused, v_total
|
|
FROM public.proposal_votes
|
|
WHERE proposal_id = v_proposal_id;
|
|
|
|
SELECT quorum_required, threshold, status
|
|
INTO v_quorum_required, v_threshold, v_status
|
|
FROM public.proposals WHERE id = v_proposal_id;
|
|
|
|
-- For quorum, count yes+no+abstain (recused does not count toward quorum).
|
|
v_quorum_met := (v_yes + v_no + v_abstain) >= COALESCE(v_quorum_required, 0);
|
|
|
|
v_passed := CASE
|
|
WHEN NOT v_quorum_met THEN false
|
|
WHEN v_yes + v_no = 0 THEN false
|
|
WHEN v_threshold = 'simple_majority' THEN v_yes > v_no
|
|
WHEN v_threshold = 'two_thirds' THEN v_yes * 3 >= (v_yes + v_no) * 2
|
|
WHEN v_threshold = 'three_fourths' THEN v_yes * 4 >= (v_yes + v_no) * 3
|
|
WHEN v_threshold = 'unanimous' THEN v_no = 0 AND v_yes > 0
|
|
ELSE false
|
|
END;
|
|
|
|
UPDATE public.proposals
|
|
SET outcome_yes = v_yes,
|
|
outcome_no = v_no,
|
|
outcome_abstain = v_abstain,
|
|
outcome_recused = v_recused,
|
|
total_voters = v_total,
|
|
quorum_met = v_quorum_met,
|
|
passed = v_passed,
|
|
updated_at = now()
|
|
WHERE id = v_proposal_id;
|
|
|
|
RETURN COALESCE(NEW, OLD);
|
|
END;
|
|
$$;
|
|
|
|
DROP TRIGGER IF EXISTS trg_refresh_proposal_tally ON public.proposal_votes;
|
|
CREATE TRIGGER trg_refresh_proposal_tally
|
|
AFTER INSERT OR UPDATE OR DELETE ON public.proposal_votes
|
|
FOR EACH ROW EXECUTE FUNCTION public.refresh_proposal_tally();
|
|
|
|
|
|
-- 6.3 Sets incident.sla_response_by on insert if not provided.
|
|
CREATE OR REPLACE FUNCTION public.compute_incident_sla()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql SECURITY DEFINER
|
|
SET search_path = public
|
|
AS $$
|
|
DECLARE
|
|
v_hours integer;
|
|
BEGIN
|
|
IF NEW.sla_response_by IS NULL THEN
|
|
SELECT incident_sla_hours INTO v_hours FROM public.spaces WHERE id = NEW.space_id;
|
|
NEW.sla_response_by := COALESCE(NEW.created_at, now()) + (COALESCE(v_hours, 72) || ' hours')::interval;
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
DROP TRIGGER IF EXISTS trg_compute_incident_sla ON public.incidents;
|
|
CREATE TRIGGER trg_compute_incident_sla
|
|
BEFORE INSERT ON public.incidents
|
|
FOR EACH ROW EXECUTE FUNCTION public.compute_incident_sla();
|
|
|
|
|
|
-- 6.4 Updated-at maintenance.
|
|
CREATE OR REPLACE FUNCTION public.touch_updated_at()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
AS $$
|
|
BEGIN
|
|
NEW.updated_at := now();
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
DROP TRIGGER IF EXISTS trg_proposals_touch ON public.proposals;
|
|
CREATE TRIGGER trg_proposals_touch
|
|
BEFORE UPDATE ON public.proposals
|
|
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
|
|
|
DROP TRIGGER IF EXISTS trg_incidents_touch ON public.incidents;
|
|
CREATE TRIGGER trg_incidents_touch
|
|
BEFORE UPDATE ON public.incidents
|
|
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
|
|
|
DROP TRIGGER IF EXISTS trg_policies_touch ON public.policies;
|
|
CREATE TRIGGER trg_policies_touch
|
|
BEFORE UPDATE ON public.policies
|
|
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
|
|
|
|
|
-- -----------------------------------------------------------------------------
|
|
-- 7. ROW LEVEL SECURITY
|
|
-- -----------------------------------------------------------------------------
|
|
ALTER TABLE public.proposals ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE public.proposal_votes ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE public.incidents ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE public.incident_updates ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE public.policies ENABLE ROW LEVEL SECURITY;
|
|
|
|
-- Drop first so re-runs are safe.
|
|
DROP POLICY IF EXISTS proposals_select ON public.proposals;
|
|
DROP POLICY IF EXISTS proposals_insert ON public.proposals;
|
|
DROP POLICY IF EXISTS proposals_update ON public.proposals;
|
|
DROP POLICY IF EXISTS proposals_delete ON public.proposals;
|
|
DROP POLICY IF EXISTS votes_select ON public.proposal_votes;
|
|
DROP POLICY IF EXISTS votes_insert ON public.proposal_votes;
|
|
DROP POLICY IF EXISTS votes_update ON public.proposal_votes;
|
|
DROP POLICY IF EXISTS incidents_select ON public.incidents;
|
|
DROP POLICY IF EXISTS incidents_insert ON public.incidents;
|
|
DROP POLICY IF EXISTS incidents_update ON public.incidents;
|
|
DROP POLICY IF EXISTS incident_updates_select ON public.incident_updates;
|
|
DROP POLICY IF EXISTS incident_updates_insert ON public.incident_updates;
|
|
DROP POLICY IF EXISTS policies_select ON public.policies;
|
|
DROP POLICY IF EXISTS policies_insert ON public.policies;
|
|
DROP POLICY IF EXISTS policies_update ON public.policies;
|
|
|
|
-- proposals: any member can read and propose; proposer can edit while draft;
|
|
-- admin/board can transition status. Delete: admin/board only.
|
|
CREATE POLICY proposals_select ON public.proposals FOR SELECT
|
|
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
|
CREATE POLICY proposals_insert ON public.proposals FOR INSERT
|
|
WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
|
CREATE POLICY proposals_update ON public.proposals FOR UPDATE
|
|
USING (
|
|
public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
|
|
OR (
|
|
status = 'draft'
|
|
AND proposer_id IN (
|
|
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
|
)
|
|
)
|
|
);
|
|
CREATE POLICY proposals_delete ON public.proposals FOR DELETE
|
|
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
|
|
|
-- proposal_votes: all members see all votes (transparency). Members can only
|
|
-- write their own vote. Can update while proposal still open. Never deleted.
|
|
CREATE POLICY votes_select ON public.proposal_votes FOR SELECT
|
|
USING (
|
|
EXISTS (
|
|
SELECT 1 FROM public.proposals p
|
|
WHERE p.id = proposal_votes.proposal_id
|
|
AND p.space_id IN (SELECT public.get_user_space_ids(auth.uid()))
|
|
)
|
|
);
|
|
CREATE POLICY votes_insert ON public.proposal_votes FOR INSERT
|
|
WITH CHECK (
|
|
member_id IN (
|
|
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
|
)
|
|
AND EXISTS (
|
|
SELECT 1 FROM public.proposals p
|
|
WHERE p.id = proposal_votes.proposal_id
|
|
AND p.status = 'open'
|
|
AND p.voting_opens_at <= now()
|
|
AND p.voting_closes_at > now()
|
|
)
|
|
);
|
|
CREATE POLICY votes_update ON public.proposal_votes FOR UPDATE
|
|
USING (
|
|
member_id IN (
|
|
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
|
)
|
|
AND EXISTS (
|
|
SELECT 1 FROM public.proposals p
|
|
WHERE p.id = proposal_votes.proposal_id
|
|
AND p.status = 'open'
|
|
AND p.voting_closes_at > now()
|
|
)
|
|
);
|
|
|
|
-- incidents: admin/board see all; reporter sees their own; named subjects do
|
|
-- not see by default (board-only disposition until policy says otherwise).
|
|
CREATE POLICY incidents_select ON public.incidents FOR SELECT
|
|
USING (
|
|
public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
|
|
OR reporter_id IN (
|
|
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
|
)
|
|
);
|
|
CREATE POLICY incidents_insert ON public.incidents FOR INSERT
|
|
WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
|
CREATE POLICY incidents_update ON public.incidents FOR UPDATE
|
|
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
|
-- No DELETE policy: incidents are retained.
|
|
|
|
-- incident_updates: visible per the row's `visibility` field, with admin/board
|
|
-- always able to read.
|
|
CREATE POLICY incident_updates_select ON public.incident_updates FOR SELECT
|
|
USING (
|
|
EXISTS (
|
|
SELECT 1 FROM public.incidents i
|
|
WHERE i.id = incident_updates.incident_id
|
|
AND (
|
|
public.user_has_role_in_space(auth.uid(), i.space_id, ARRAY['admin','board'])
|
|
OR (
|
|
incident_updates.visibility <> 'board_only'
|
|
AND i.reporter_id IN (
|
|
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
|
)
|
|
)
|
|
)
|
|
)
|
|
);
|
|
CREATE POLICY incident_updates_insert ON public.incident_updates FOR INSERT
|
|
WITH CHECK (
|
|
EXISTS (
|
|
SELECT 1 FROM public.incidents i
|
|
WHERE i.id = incident_updates.incident_id
|
|
AND (
|
|
public.user_has_role_in_space(auth.uid(), i.space_id, ARRAY['admin','board'])
|
|
OR i.reporter_id IN (
|
|
SELECT id FROM public.space_members WHERE user_id = auth.uid()
|
|
)
|
|
)
|
|
)
|
|
);
|
|
|
|
-- policies: all members read; admin/board insert and supersede (which is an
|
|
-- INSERT of a new version row, not an UPDATE). UPDATE allowed only for admin/
|
|
-- board for status flips (deprecate, supersede). No DELETE.
|
|
CREATE POLICY policies_select ON public.policies FOR SELECT
|
|
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
|
|
CREATE POLICY policies_insert ON public.policies FOR INSERT
|
|
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|
|
CREATE POLICY policies_update ON public.policies FOR UPDATE
|
|
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
|