Files
hackerspace.sh/scripts/016_governance_kernel.sql
T
Moheeb Zara ea8d7a5f91 feat: governance kernel, deployment hardening, local-dev stack
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.
2026-05-14 04:52:11 -07:00

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']));