Files
hackerspace.sh/scripts/schema.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

1343 lines
66 KiB
PL/PgSQL

-- =============================================================================
-- HACKERSPACE MANAGEMENT — CANONICAL SCHEMA
-- =============================================================================
-- Single file. Run top-to-bottom in Supabase SQL Editor on a fresh project.
-- Idempotent: uses IF NOT EXISTS / OR REPLACE throughout.
-- No supplemental scripts required.
--
-- Sections:
-- 1. Extensions
-- 2. Enums
-- 3. Tables (spaces → space_members → rest)
-- 4. Indexes
-- 5. Helper functions (must come after tables)
-- 6. Row Level Security
-- 7. Triggers & auth hook
-- 8. Realtime
-- =============================================================================
-- -----------------------------------------------------------------------------
-- 1. EXTENSIONS
-- -----------------------------------------------------------------------------
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- -----------------------------------------------------------------------------
-- 2. ENUMS
-- -----------------------------------------------------------------------------
DO $$ BEGIN CREATE TYPE public.member_role AS ENUM ('admin','board','treasurer','member','associate'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE public.member_tier AS ENUM ('plus','basic','associate'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE public.member_status AS ENUM ('current','late','inactive','unverified'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE public.task_status AS ENUM ('open','claimed','in_progress','overdue','due_today','completed','done','blocked'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE public.task_type AS ENUM ('chore','task'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE public.recurrence_type AS ENUM ('daily','weekly','biweekly','monthly','none'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE public.project_status AS ENUM ('backlog','in_progress','review','done','blocked'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE public.payment_platform AS ENUM ('paypal','zeffy','venmo','cash'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE public.payment_link_status AS ENUM ('linked','unlinked'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE public.kb_visibility AS ENUM ('all_members','board','admin_only'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE public.area_lead_status AS ENUM ('active','vacant','handoff'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE public.contact_type AS ENUM ('vendor','supplier','partner','landlord','city'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN CREATE TYPE public.channel_type AS ENUM ('general','area','ops','project'); EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- -----------------------------------------------------------------------------
-- 3. TABLES
-- -----------------------------------------------------------------------------
-- spaces ----------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.spaces (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
name text NOT NULL,
slug text NOT NULL UNIQUE,
description text,
logo_url text,
city text,
address text,
timezone text DEFAULT 'America/Phoenix',
invite_code text,
require_approval boolean DEFAULT true,
public_member_directory boolean DEFAULT false,
webhook_secret text,
settings jsonb DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- space_members ---------------------------------------------------------------
-- user_id is NULLABLE on purpose. The application supports "offline" members
-- (added by an admin without an auth account). addMember() and importMembers()
-- both insert rows with user_id = NULL.
CREATE TABLE IF NOT EXISTS public.space_members (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
role public.member_role NOT NULL DEFAULT 'member',
tier public.member_tier NOT NULL DEFAULT 'basic',
status public.member_status NOT NULL DEFAULT 'unverified',
approved boolean NOT NULL DEFAULT true,
display_name text,
handle text,
email text,
phone text,
bio text,
avatar_url text,
has_card_access boolean NOT NULL DEFAULT false,
payment_status text,
payment_note text,
last_payment_at timestamptz,
last_paid_at timestamptz,
joined_at timestamptz DEFAULT now(),
dues_paid_until timestamptz,
stripe_customer_id text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, user_id)
);
-- Partial unique index: enforce uniqueness on email only when present
-- Required by importMembers upsert (onConflict: 'space_id,email')
CREATE UNIQUE INDEX IF NOT EXISTS idx_space_members_space_email
ON public.space_members (space_id, email)
WHERE email IS NOT NULL;
-- projects --------------------------------------------------------------------
-- Created before tasks because tasks.project_id references it.
CREATE TABLE IF NOT EXISTS public.projects (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
title text NOT NULL,
name text,
description text,
area text,
category text,
status public.project_status NOT NULL DEFAULT 'backlog',
lead_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
assignees text,
assignee_names text[],
due_date timestamptz,
tags text[],
task_count integer NOT NULL DEFAULT 0,
tasks_completed integer NOT NULL DEFAULT 0,
progress integer NOT NULL DEFAULT 0,
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- tasks -----------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.tasks (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
title text NOT NULL,
description text,
task_type public.task_type NOT NULL DEFAULT 'task',
status public.task_status NOT NULL DEFAULT 'open',
recurrence public.recurrence_type DEFAULT 'none',
priority text DEFAULT 'medium',
area text,
tags text[],
due_date timestamptz,
project_id uuid REFERENCES public.projects(id) ON DELETE SET NULL,
claimed_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
claimed_by_name text,
assigned_to uuid REFERENCES auth.users(id) ON DELETE SET NULL,
assigned_to_name text,
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
requested_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
requested_by_name text,
subtask_total integer NOT NULL DEFAULT 0,
subtask_completed integer NOT NULL DEFAULT 0,
progress integer NOT NULL DEFAULT 0,
last_done_at timestamptz,
completed_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- payments --------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.payments (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
member_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
platform public.payment_platform NOT NULL,
amount numeric(10,2) NOT NULL,
currency text NOT NULL DEFAULT 'USD',
description text,
-- status kept for backwards compatibility; link_status is canonical
status public.payment_link_status NOT NULL DEFAULT 'unlinked',
link_status public.payment_link_status NOT NULL DEFAULT 'unlinked',
external_id text,
from_identifier text,
from_note text,
member_name text,
payer_name text,
payer_email text,
payment_date timestamptz,
transaction_date timestamptz DEFAULT now(),
raw_data jsonb DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- contacts --------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.contacts (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
name text NOT NULL,
contact_type public.contact_type NOT NULL DEFAULT 'vendor',
code text,
email text,
phone text,
address text,
notes text,
details text,
note text,
group_label text,
tags text[],
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- knowledge_base --------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.knowledge_base (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
title text NOT NULL,
content text,
category text,
area text,
icon text,
tags text[],
visibility public.kb_visibility NOT NULL DEFAULT 'all_members',
access_level text DEFAULT 'all_members',
-- pinned = legacy column, is_pinned = canonical
pinned boolean NOT NULL DEFAULT false,
is_pinned boolean NOT NULL DEFAULT false,
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
-- updated_by = auth.users FK (legacy), updated_by_id = space_members FK (canonical)
updated_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
updated_by_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
updated_by_name text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- secrets ---------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.secrets (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
-- label = legacy column, title = canonical
label text NOT NULL,
title text,
value text NOT NULL,
description text,
category text DEFAULT 'general',
area text,
icon text,
notes text,
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- area_leads ------------------------------------------------------------------
-- area_code is the upsert conflict target (unique within a space)
CREATE TABLE IF NOT EXISTS public.area_leads (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
area_name text NOT NULL,
area_code text,
description text,
notes text,
lead_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
lead_handle text,
status public.area_lead_status NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT area_leads_space_area_code_key UNIQUE (space_id, area_code) DEFERRABLE INITIALLY DEFERRED
);
-- integrations ----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.integrations (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
platform text NOT NULL,
name text,
description text,
is_connected boolean NOT NULL DEFAULT false,
-- credentials/settings kept for legacy; config is canonical
credentials jsonb DEFAULT '{}',
settings jsonb DEFAULT '{}',
config jsonb,
last_sync_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, platform)
);
-- comms_channels --------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.comms_channels (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
name text NOT NULL,
description text,
channel_type public.channel_type NOT NULL DEFAULT 'general',
icon text,
area_reference text,
is_default boolean NOT NULL DEFAULT false,
is_archived boolean NOT NULL DEFAULT false,
member_count integer NOT NULL DEFAULT 0,
unread_count integer NOT NULL DEFAULT 0,
created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- comms_messages --------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.comms_messages (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
channel_id uuid NOT NULL REFERENCES public.comms_channels(id) ON DELETE CASCADE,
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
display_name text,
handle text,
content text NOT NULL,
edited boolean NOT NULL DEFAULT false,
deleted boolean NOT NULL DEFAULT false,
reply_to uuid REFERENCES public.comms_messages(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- activity_log ----------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.activity_log (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
user_id uuid REFERENCES auth.users(id) ON DELETE SET NULL,
display_name text,
action text NOT NULL,
entity_type text,
entity_id uuid,
details text,
metadata jsonb DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now()
);
-- -----------------------------------------------------------------------------
-- 4. INDEXES
-- -----------------------------------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_space_members_user_id ON public.space_members (user_id);
CREATE INDEX IF NOT EXISTS idx_space_members_space_id ON public.space_members (space_id);
CREATE INDEX IF NOT EXISTS idx_tasks_space_id ON public.tasks (space_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON public.tasks (status);
CREATE INDEX IF NOT EXISTS idx_tasks_claimed_by ON public.tasks (claimed_by);
CREATE INDEX IF NOT EXISTS idx_tasks_assigned_to ON public.tasks (assigned_to);
CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON public.tasks (project_id);
CREATE INDEX IF NOT EXISTS idx_projects_space_id ON public.projects (space_id);
CREATE INDEX IF NOT EXISTS idx_payments_space_id ON public.payments (space_id);
CREATE INDEX IF NOT EXISTS idx_payments_member_id ON public.payments (member_id);
CREATE INDEX IF NOT EXISTS idx_payments_external_id ON public.payments (external_id);
CREATE INDEX IF NOT EXISTS idx_payments_link_status ON public.payments (link_status);
CREATE INDEX IF NOT EXISTS idx_payments_transaction_date ON public.payments (transaction_date DESC);
CREATE INDEX IF NOT EXISTS idx_contacts_space_id ON public.contacts (space_id);
CREATE INDEX IF NOT EXISTS idx_knowledge_base_space_id ON public.knowledge_base (space_id);
CREATE INDEX IF NOT EXISTS idx_secrets_space_id ON public.secrets (space_id);
CREATE INDEX IF NOT EXISTS idx_area_leads_space_id ON public.area_leads (space_id);
CREATE INDEX IF NOT EXISTS idx_integrations_space_id ON public.integrations (space_id);
CREATE INDEX IF NOT EXISTS idx_comms_channels_space_id ON public.comms_channels (space_id);
CREATE INDEX IF NOT EXISTS idx_comms_messages_channel ON public.comms_messages (channel_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_comms_messages_space ON public.comms_messages (space_id);
CREATE INDEX IF NOT EXISTS idx_comms_messages_user ON public.comms_messages (user_id);
CREATE INDEX IF NOT EXISTS idx_activity_log_space_id ON public.activity_log (space_id);
CREATE INDEX IF NOT EXISTS idx_activity_log_created_at ON public.activity_log (created_at DESC);
-- -----------------------------------------------------------------------------
-- 5. HELPER FUNCTIONS
-- Must come after tables — they reference public.space_members.
-- SECURITY DEFINER with fixed search_path prevents privilege escalation.
-- -----------------------------------------------------------------------------
-- Returns all space_ids the given user belongs to.
-- Used in RLS SELECT policies to avoid recursive evaluation.
CREATE OR REPLACE FUNCTION public.get_user_space_ids(uid uuid)
RETURNS SETOF uuid
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public
AS $$
SELECT space_id FROM public.space_members WHERE user_id = uid;
$$;
-- Returns true if the user holds one of the given roles in the given space.
CREATE OR REPLACE FUNCTION public.user_has_role_in_space(uid uuid, sid uuid, allowed_roles text[])
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path = public
AS $$
SELECT EXISTS (
SELECT 1 FROM public.space_members
WHERE user_id = uid
AND space_id = sid
AND role::text = ANY(allowed_roles)
);
$$;
-- -----------------------------------------------------------------------------
-- 6. ROW LEVEL SECURITY
-- Drops first so re-runs do not fail with "policy already exists".
-- -----------------------------------------------------------------------------
DROP POLICY IF EXISTS spaces_select ON public.spaces;
DROP POLICY IF EXISTS spaces_insert ON public.spaces;
DROP POLICY IF EXISTS spaces_update ON public.spaces;
DROP POLICY IF EXISTS members_select ON public.space_members;
DROP POLICY IF EXISTS members_insert ON public.space_members;
DROP POLICY IF EXISTS members_update ON public.space_members;
DROP POLICY IF EXISTS members_delete ON public.space_members;
DROP POLICY IF EXISTS tasks_select ON public.tasks;
DROP POLICY IF EXISTS tasks_insert ON public.tasks;
DROP POLICY IF EXISTS tasks_update ON public.tasks;
DROP POLICY IF EXISTS tasks_delete ON public.tasks;
DROP POLICY IF EXISTS projects_select ON public.projects;
DROP POLICY IF EXISTS projects_insert ON public.projects;
DROP POLICY IF EXISTS projects_update ON public.projects;
DROP POLICY IF EXISTS projects_delete ON public.projects;
DROP POLICY IF EXISTS payments_select ON public.payments;
DROP POLICY IF EXISTS payments_insert ON public.payments;
DROP POLICY IF EXISTS payments_update ON public.payments;
DROP POLICY IF EXISTS payments_delete ON public.payments;
DROP POLICY IF EXISTS contacts_select ON public.contacts;
DROP POLICY IF EXISTS contacts_insert ON public.contacts;
DROP POLICY IF EXISTS contacts_update ON public.contacts;
DROP POLICY IF EXISTS contacts_delete ON public.contacts;
DROP POLICY IF EXISTS kb_select ON public.knowledge_base;
DROP POLICY IF EXISTS kb_insert ON public.knowledge_base;
DROP POLICY IF EXISTS kb_update ON public.knowledge_base;
DROP POLICY IF EXISTS kb_delete ON public.knowledge_base;
DROP POLICY IF EXISTS secrets_select ON public.secrets;
DROP POLICY IF EXISTS secrets_insert ON public.secrets;
DROP POLICY IF EXISTS secrets_update ON public.secrets;
DROP POLICY IF EXISTS secrets_delete ON public.secrets;
DROP POLICY IF EXISTS area_leads_select ON public.area_leads;
DROP POLICY IF EXISTS area_leads_insert ON public.area_leads;
DROP POLICY IF EXISTS area_leads_update ON public.area_leads;
DROP POLICY IF EXISTS area_leads_delete ON public.area_leads;
DROP POLICY IF EXISTS integrations_select ON public.integrations;
DROP POLICY IF EXISTS integrations_insert ON public.integrations;
DROP POLICY IF EXISTS integrations_update ON public.integrations;
DROP POLICY IF EXISTS integrations_delete ON public.integrations;
DROP POLICY IF EXISTS channels_select ON public.comms_channels;
DROP POLICY IF EXISTS channels_insert ON public.comms_channels;
DROP POLICY IF EXISTS channels_update ON public.comms_channels;
DROP POLICY IF EXISTS channels_delete ON public.comms_channels;
DROP POLICY IF EXISTS messages_select ON public.comms_messages;
DROP POLICY IF EXISTS messages_insert ON public.comms_messages;
DROP POLICY IF EXISTS messages_update ON public.comms_messages;
DROP POLICY IF EXISTS messages_delete ON public.comms_messages;
DROP POLICY IF EXISTS activity_select ON public.activity_log;
DROP POLICY IF EXISTS activity_insert ON public.activity_log;
ALTER TABLE public.spaces ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.space_members ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.tasks ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.payments ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.contacts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.knowledge_base ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.secrets ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.area_leads ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.integrations ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.comms_channels ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.comms_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.activity_log ENABLE ROW LEVEL SECURITY;
-- spaces
CREATE POLICY spaces_select ON public.spaces FOR SELECT
USING (id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY spaces_insert ON public.spaces FOR INSERT
WITH CHECK (true);
CREATE POLICY spaces_update ON public.spaces FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), id, ARRAY['admin','board']));
-- space_members
CREATE POLICY members_select ON public.space_members FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY members_insert ON public.space_members FOR INSERT
WITH CHECK (
user_id = auth.uid()
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])
);
CREATE POLICY members_update ON public.space_members FOR UPDATE
USING (
user_id = auth.uid()
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])
);
CREATE POLICY members_delete ON public.space_members FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
-- tasks
CREATE POLICY tasks_select ON public.tasks FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY tasks_insert ON public.tasks FOR INSERT
WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY tasks_update ON public.tasks FOR UPDATE
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY tasks_delete ON public.tasks FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
-- projects
CREATE POLICY projects_select ON public.projects FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY projects_insert ON public.projects FOR INSERT
WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY projects_update ON public.projects FOR UPDATE
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY projects_delete ON public.projects FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
-- payments (treasurer / board / admin only)
CREATE POLICY payments_select ON public.payments FOR SELECT
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
CREATE POLICY payments_insert ON public.payments FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
CREATE POLICY payments_update ON public.payments FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
CREATE POLICY payments_delete ON public.payments FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','treasurer']));
-- contacts
CREATE POLICY contacts_select ON public.contacts FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY contacts_insert ON public.contacts FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
CREATE POLICY contacts_update ON public.contacts FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
CREATE POLICY contacts_delete ON public.contacts FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
-- knowledge_base
CREATE POLICY kb_select ON public.knowledge_base FOR SELECT
USING (
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
AND (
visibility = 'all_members'
OR (visibility = 'board' AND public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']))
OR (visibility = 'admin_only' AND public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']))
)
);
CREATE POLICY kb_insert ON public.knowledge_base FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
CREATE POLICY kb_update ON public.knowledge_base FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
CREATE POLICY kb_delete ON public.knowledge_base FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
-- secrets (board / admin only)
CREATE POLICY secrets_select ON public.secrets FOR SELECT
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY secrets_insert ON public.secrets FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY secrets_update ON public.secrets FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY secrets_delete ON public.secrets FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
-- area_leads
CREATE POLICY area_leads_select ON public.area_leads FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY area_leads_insert ON public.area_leads FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY area_leads_update ON public.area_leads FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY area_leads_delete ON public.area_leads FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
-- integrations (admin only)
CREATE POLICY integrations_select ON public.integrations FOR SELECT
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']));
CREATE POLICY integrations_insert ON public.integrations FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
CREATE POLICY integrations_update ON public.integrations FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
CREATE POLICY integrations_delete ON public.integrations FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
-- comms_channels
CREATE POLICY channels_select ON public.comms_channels FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY channels_insert ON public.comms_channels FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY channels_update ON public.comms_channels FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY channels_delete ON public.comms_channels FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
-- comms_messages
CREATE POLICY messages_select ON public.comms_messages FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY messages_insert ON public.comms_messages FOR INSERT
WITH CHECK (
space_id IN (SELECT public.get_user_space_ids(auth.uid()))
AND user_id = auth.uid()
);
CREATE POLICY messages_update ON public.comms_messages FOR UPDATE
USING (
user_id = auth.uid()
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
);
CREATE POLICY messages_delete ON public.comms_messages FOR DELETE
USING (
user_id = auth.uid()
OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])
);
-- activity_log (append-only for members, readable by admin/board)
CREATE POLICY activity_select ON public.activity_log FOR SELECT
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY activity_insert ON public.activity_log FOR INSERT
WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
-- -----------------------------------------------------------------------------
-- 7. TRIGGERS & AUTH HOOK
-- -----------------------------------------------------------------------------
-- Auto-create default channels when a new space is created
CREATE OR REPLACE FUNCTION public.create_default_channels()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
INSERT INTO public.comms_channels (space_id, name, description, channel_type, is_default)
VALUES
(NEW.id, 'general', 'General discussion', 'general', true),
(NEW.id, 'announcements', 'Important announcements', 'general', false),
(NEW.id, 'ops', 'Operations & logistics', 'ops', false);
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS on_space_created ON public.spaces;
CREATE TRIGGER on_space_created
AFTER INSERT ON public.spaces
FOR EACH ROW EXECUTE FUNCTION public.create_default_channels();
-- Auth signup hook
-- Called when a new user is created in auth.users.
-- Reads metadata from supabase.auth.signUp({ data: { space_id, role, display_name } })
-- and inserts the corresponding space_members row.
CREATE OR REPLACE FUNCTION public.handle_space_signup()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_space_id uuid;
v_role public.member_role;
v_display_name text;
BEGIN
v_space_id := (NEW.raw_user_meta_data->>'space_id')::uuid;
-- Default to 'member' if role is missing or invalid
v_role := COALESCE(
NULLIF(NEW.raw_user_meta_data->>'role', '')::public.member_role,
'member'
);
-- Prefer display_name, fall back to full_name, then email
v_display_name := COALESCE(
NULLIF(NEW.raw_user_meta_data->>'display_name', ''),
NULLIF(NEW.raw_user_meta_data->>'full_name', ''),
NEW.email
);
-- No space_id means this is not a hackerspace signup flow
IF v_space_id IS NULL THEN
RETURN NEW;
END IF;
INSERT INTO public.space_members
(space_id, user_id, role, status, display_name, email, approved)
VALUES
(v_space_id, NEW.id, v_role, 'unverified', v_display_name, NEW.email, true)
ON CONFLICT (space_id, user_id) DO NOTHING;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_space_signup();
-- Privilege-escalation guard.
-- RLS lets a member update their own space_members row (so they can fix their
-- own display_name, handle, phone, etc.). Without this trigger, a member
-- could PATCH role = 'admin' on themselves via a direct PostgREST call.
-- The trigger fires on every UPDATE: if the user updating their own row is
-- not already an admin/board/treasurer, any change to role/tier/status/
-- approved is rejected. SECURITY DEFINER so it can read space_members
-- regardless of RLS during the check.
CREATE OR REPLACE FUNCTION public.prevent_member_self_role_change()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_is_privileged boolean;
BEGIN
-- Service role (no auth.uid) and inserts handled elsewhere: skip.
IF auth.uid() IS NULL THEN
RETURN NEW;
END IF;
-- If the row being updated does not belong to the current user, the
-- existing RLS already requires admin/board/treasurer. Nothing to add.
IF NEW.user_id IS DISTINCT FROM auth.uid() THEN
RETURN NEW;
END IF;
-- Self-update. If the user already holds a privileged role IN THIS SPACE
-- they may update anything (an admin editing their own row is fine).
SELECT public.user_has_role_in_space(auth.uid(), NEW.space_id, ARRAY['admin','board','treasurer'])
INTO v_is_privileged;
IF v_is_privileged THEN
RETURN NEW;
END IF;
-- Non-privileged self-update: protected columns must not change.
IF NEW.role IS DISTINCT FROM OLD.role
OR NEW.tier IS DISTINCT FROM OLD.tier
OR NEW.status IS DISTINCT FROM OLD.status
OR NEW.approved IS DISTINCT FROM OLD.approved
OR NEW.has_card_access IS DISTINCT FROM OLD.has_card_access
OR NEW.space_id IS DISTINCT FROM OLD.space_id THEN
RAISE EXCEPTION 'Members cannot change their own role, tier, status, approval, card access, or space.'
USING ERRCODE = '42501';
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_prevent_member_self_role_change ON public.space_members;
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();
-- -----------------------------------------------------------------------------
-- 8. REALTIME
-- Wrapped in DO blocks so re-running this file is safe: ADD TABLE errors when
-- the table is already a member of the publication.
-- -----------------------------------------------------------------------------
DO $$ BEGIN
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_messages;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_channels;
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- =============================================================================
-- 9. GOVERNANCE KERNEL
-- Source of truth: scripts/016_governance_kernel.sql.
-- Tier 1 from docs/GOVERNANCE_FEATURES.md.
-- Adds proposals + proposal_votes, incidents + incident_updates, policies.
-- =============================================================================
-- 9.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 $$;
-- 9.2 Spaces extensions ------------------------------------------------------
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;
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;
ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS mission_statement text;
-- 9.3 Tables -----------------------------------------------------------------
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
);
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))
);
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
);
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()
);
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)
);
-- 9.4 Circular FKs -----------------------------------------------------------
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 $$;
-- 9.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);
-- 9.6 Triggers ---------------------------------------------------------------
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();
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;
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();
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();
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();
-- 9.7 RLS --------------------------------------------------------------------
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 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;
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']));
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 (
EXISTS (
SELECT 1
FROM public.proposals p
JOIN public.space_members m ON m.id = proposal_votes.member_id
WHERE p.id = proposal_votes.proposal_id
AND m.user_id = auth.uid()
AND m.space_id = p.space_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 (
EXISTS (
SELECT 1
FROM public.proposals p
JOIN public.space_members m ON m.id = proposal_votes.member_id
WHERE p.id = proposal_votes.proposal_id
AND m.user_id = auth.uid()
AND m.space_id = p.space_id
AND p.status = 'open'
AND p.voting_closes_at > now()
)
);
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()))
AND (
reporter_id IS NULL
OR EXISTS (
SELECT 1 FROM public.space_members m
WHERE m.id = incidents.reporter_id
AND m.user_id = auth.uid()
AND m.space_id = incidents.space_id
)
)
);
CREATE POLICY incidents_update ON public.incidents FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
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 m.id FROM public.space_members m
WHERE m.user_id = auth.uid() AND m.space_id = i.space_id
)
)
)
AND (
author_id IS NULL
OR EXISTS (
SELECT 1 FROM public.space_members m, public.incidents i
WHERE i.id = incident_updates.incident_id
AND m.id = incident_updates.author_id
AND m.user_id = auth.uid()
AND m.space_id = i.space_id
)
)
);
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']));
-- 9.8 Active-policy uniqueness ------------------------------------------------
-- A race between two admins concurrently activating two different drafts of
-- the same slug could leave two `active` rows. Partial unique index forbids
-- it at the storage layer.
DROP INDEX IF EXISTS public.policies_one_active_per_slug;
CREATE UNIQUE INDEX policies_one_active_per_slug
ON public.policies (space_id, slug)
WHERE status = 'active';
-- =============================================================================
-- 10. MEMBER STATE + VISIBILITY (Tier 2 + Tier 3 of GOVERNANCE_FEATURES.md)
-- Source of truth: scripts/018_member_state_and_visibility.sql.
-- =============================================================================
-- 10.1 Enums
DO $$ BEGIN
CREATE TYPE public.financial_visibility AS ENUM ('treasurer_only','board_visible','all_members_visible');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
DO $$ BEGIN
CREATE TYPE public.directory_visibility AS ENUM ('board_only','member_count_visible','members_visible','public_members_visible');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- 10.2 Spaces visibility settings
ALTER TABLE public.spaces
ADD COLUMN IF NOT EXISTS financial_visibility public.financial_visibility NOT NULL DEFAULT 'board_visible';
ALTER TABLE public.spaces
ADD COLUMN IF NOT EXISTS member_directory_visibility public.directory_visibility NOT NULL DEFAULT 'members_visible';
-- 10.3 Space-members opt-in profile and COI
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS skills text[] NOT NULL DEFAULT '{}'::text[];
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS interests text[] NOT NULL DEFAULT '{}'::text[];
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS willing_to text[] NOT NULL DEFAULT '{}'::text[];
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS affiliations text[] NOT NULL DEFAULT '{}'::text[];
ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS coi_last_disclosed_at timestamptz;
CREATE INDEX IF NOT EXISTS idx_space_members_willing_to ON public.space_members USING GIN (willing_to);
CREATE INDEX IF NOT EXISTS idx_space_members_skills ON public.space_members USING GIN (skills);
-- 10.4 Knowledge-base meeting minutes flag
ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS is_meeting_minutes boolean NOT NULL DEFAULT false;
ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS meeting_date timestamptz;
CREATE INDEX IF NOT EXISTS idx_knowledge_base_meeting_date
ON public.knowledge_base (meeting_date DESC)
WHERE is_meeting_minutes = true;
-- 10.5 Card-access mid-tenure review trigger
CREATE OR REPLACE FUNCTION public.schedule_card_review()
RETURNS trigger
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_review_date timestamptz;
v_old_card boolean;
BEGIN
v_old_card := COALESCE(OLD.has_card_access, false);
IF NEW.has_card_access = true AND (TG_OP = 'INSERT' OR v_old_card = false) THEN
v_review_date := now() + interval '180 days';
INSERT INTO public.tasks (
space_id, title, description, task_type, status, area, due_date, requested_by_name
) VALUES (
NEW.space_id,
'Card-access review: ' || COALESCE(NULLIF(NEW.display_name, ''), 'member'),
'Auto-generated 6-month review. Confirm card-access should continue. Any concerns from area leads, station champions, or hosts?',
'task',
'open',
'Admin',
v_review_date,
'System'
);
END IF;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_schedule_card_review ON public.space_members;
CREATE TRIGGER trg_schedule_card_review
AFTER INSERT OR UPDATE OF has_card_access ON public.space_members
FOR EACH ROW EXECUTE FUNCTION public.schedule_card_review();
-- 10.6 Inactive members view
CREATE OR REPLACE VIEW public.inactive_members AS
SELECT m.*,
a.last_activity_at
FROM public.space_members m
LEFT JOIN LATERAL (
SELECT max(created_at) AS last_activity_at
FROM public.activity_log al
WHERE al.user_id = m.user_id AND al.space_id = m.space_id
) a ON true
WHERE m.status IN ('current', 'late')
AND m.approved = true
AND (a.last_activity_at IS NULL OR a.last_activity_at < now() - interval '180 days');
-- =============================================================================
-- 11. PROPOSAL EXPIRY
-- Source: scripts/019_proposal_expiry.sql.
-- Call public.expire_proposals() on a schedule to auto-close open proposals
-- whose voting_closes_at has passed.
-- =============================================================================
CREATE OR REPLACE FUNCTION public.expire_proposals()
RETURNS integer
LANGUAGE plpgsql SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_decided integer;
v_expired integer;
BEGIN
WITH updated AS (
UPDATE public.proposals
SET status = 'decided', decided_at = now()
WHERE status = 'open'
AND voting_closes_at IS NOT NULL
AND voting_closes_at < now()
AND quorum_met = true
RETURNING 1
)
SELECT count(*) INTO v_decided FROM updated;
WITH updated AS (
UPDATE public.proposals
SET status = 'expired'
WHERE status = 'open'
AND voting_closes_at IS NOT NULL
AND voting_closes_at < now()
AND COALESCE(quorum_met, false) = false
RETURNING 1
)
SELECT count(*) INTO v_expired FROM updated;
RETURN v_decided + v_expired;
END;
$$;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_cron') THEN
PERFORM cron.unschedule(jobid) FROM cron.job WHERE jobname = 'expire-proposals-hourly';
PERFORM cron.schedule('expire-proposals-hourly', '0 * * * *', $cron$SELECT public.expire_proposals();$cron$);
END IF;
EXCEPTION WHEN OTHERS THEN
NULL;
END $$;
-- =============================================================================
-- 12. SPACE AREAS
-- Source: scripts/020_areas.sql.
-- Each space gets its own list of physical areas / shops / categories.
-- A trigger seeds ten sensible defaults when a space is created. Admin and
-- board members manage the list afterwards.
-- =============================================================================
CREATE TABLE IF NOT EXISTS public.space_areas (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
space_id uuid NOT NULL REFERENCES public.spaces(id) ON DELETE CASCADE,
code text NOT NULL,
name text NOT NULL,
icon text,
sort_order integer NOT NULL DEFAULT 100,
is_archived boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, code),
UNIQUE (space_id, name)
);
CREATE INDEX IF NOT EXISTS idx_space_areas_space_sort
ON public.space_areas (space_id, sort_order, name);
ALTER TABLE public.space_areas ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS space_areas_select ON public.space_areas;
DROP POLICY IF EXISTS space_areas_insert ON public.space_areas;
DROP POLICY IF EXISTS space_areas_update ON public.space_areas;
DROP POLICY IF EXISTS space_areas_delete ON public.space_areas;
CREATE POLICY space_areas_select ON public.space_areas FOR SELECT
USING (space_id IN (SELECT public.get_user_space_ids(auth.uid())));
CREATE POLICY space_areas_insert ON public.space_areas FOR INSERT
WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY space_areas_update ON public.space_areas FOR UPDATE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']));
CREATE POLICY space_areas_delete ON public.space_areas FOR DELETE
USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
DROP TRIGGER IF EXISTS trg_space_areas_touch ON public.space_areas;
CREATE TRIGGER trg_space_areas_touch
BEFORE UPDATE ON public.space_areas
FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
CREATE OR REPLACE FUNCTION public.seed_default_areas()
RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
BEGIN
INSERT INTO public.space_areas (space_id, code, name, sort_order) VALUES
(NEW.id, '3d-printing', '3D Printing', 10),
(NEW.id, 'electronics', 'Electronics', 20),
(NEW.id, 'woodshop', 'Woodshop', 30),
(NEW.id, 'laser', 'Laser', 40),
(NEW.id, 'metal-shop', 'Metal Shop', 50),
(NEW.id, 'facilities', 'Facilities', 60),
(NEW.id, 'software', 'Software', 70),
(NEW.id, 'kitchen', 'Kitchen', 80),
(NEW.id, 'admin', 'Admin', 90),
(NEW.id, 'general', 'General', 100)
ON CONFLICT (space_id, code) DO NOTHING;
RETURN NEW;
END;
$$;
DROP TRIGGER IF EXISTS trg_seed_default_areas ON public.spaces;
CREATE TRIGGER trg_seed_default_areas
AFTER INSERT ON public.spaces
FOR EACH ROW EXECUTE FUNCTION public.seed_default_areas();