Files
hackerspace.sh/scripts/schema.sql
T
v0andMoheeb Zara 1d08719a04 feat: resolve schema discrepancies and update schema.sql
Fix column and constraint mismatches, and update schema.sql to match live DB.

Co-authored-by: Moheeb Zara <[email protected]>
2026-03-11 06:33:50 +00:00

633 lines
37 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
-- =============================================================================
-- HACKERSPACE.SH — CANONICAL SCHEMA (single source of truth)
-- =============================================================================
-- Run this file ONCE on a brand-new Supabase project.
-- It is fully idempotent: safe to run again if interrupted.
--
-- Execution order:
-- 1. Extensions
-- 2. Enums
-- 3. Tables (spaces → space_members → everything else)
-- 4. Unique constraints & indexes
-- 5. Helper functions (reference space_members → 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 (dependency order)
-- ─────────────────────────────────────────────────────────────────────────────
-- ── 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 NOT NULL DEFAULT 'America/Phoenix',
invite_code text,
require_approval boolean NOT NULL DEFAULT true,
public_member_directory boolean NOT NULL DEFAULT false,
webhook_secret text,
settings jsonb NOT NULL DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ── space_members ─────────────────────────────────────────────────────────────
-- One row per (user × space).
-- display_name — the member's chosen name within this space
-- handle — optional @handle (unique within a space is enforced by app, not DB)
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 SET NULL,
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_paid_at timestamptz,
joined_at timestamptz NOT NULL 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)
);
-- ── projects ─────────────────────────────────────────────────────────────────
-- Must be created before tasks (tasks.project_id FK).
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,
description text,
status public.project_status NOT NULL DEFAULT 'backlog',
area text,
tags text[],
due_date timestamptz,
task_count integer NOT NULL DEFAULT 0,
tasks_completed integer NOT NULL DEFAULT 0,
progress integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ── tasks ────────────────────────────────────────────────────────────────────
-- task_type — the enum column (canonical)
-- status — task lifecycle; both 'completed' and 'done' mean finished
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 NOT NULL DEFAULT 'none',
priority text NOT NULL DEFAULT 'medium',
area text,
project_id uuid REFERENCES public.projects(id) ON DELETE SET NULL,
tags text[],
due_date timestamptz,
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,
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_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ── payments ─────────────────────────────────────────────────────────────────
-- from_identifier — email / @handle / name from source platform
-- from_note — memo / note from sender
-- link_status — whether this payment has been matched to a member
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,
platform public.payment_platform NOT NULL,
amount numeric(10,2) NOT NULL,
from_identifier text,
from_note text,
member_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
member_name text,
link_status public.payment_link_status NOT NULL DEFAULT 'unlinked',
transaction_date timestamptz NOT NULL DEFAULT now(),
created_at timestamptz NOT NULL DEFAULT now()
);
-- ── contacts ─────────────────────────────────────────────────────────────────
-- code — short auto-generated identifier (e.g. "VEN234")
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,
code text NOT NULL,
contact_type public.contact_type NOT NULL DEFAULT 'vendor',
email text,
phone text,
details text,
note text,
group_label text,
tags text[],
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ── knowledge_base ────────────────────────────────────────────────────────────
-- is_pinned — canonical pinned flag
-- updated_by_id — FK to space_members (for display in UI)
-- updated_by_name — denormalized display name for performance
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,
description text,
content text,
icon text,
visibility public.kb_visibility NOT NULL DEFAULT 'all_members',
area text,
tags text[],
is_pinned boolean NOT NULL DEFAULT false,
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 ──────────────────────────────────────────────────────────────────
-- Shared credentials vault. Admin write, board+ read.
-- value stored as plain text — encryption is a future enhancement.
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,
title text NOT NULL,
icon text,
description text,
value text NOT NULL,
area text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ── area_leads ───────────────────────────────────────────────────────────────
-- area_code must be unique within a space (used as upsert conflict target).
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 NOT NULL,
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(),
UNIQUE (space_id, area_code)
);
-- ── integrations ─────────────────────────────────────────────────────────────
-- config JSONB holds platform-specific credentials (e.g. client_id, token).
-- platform must be unique within a space (used as upsert conflict target).
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,
name text NOT NULL,
platform text NOT NULL,
description text,
is_connected boolean NOT NULL DEFAULT false,
config jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, platform)
);
-- ── comms_channels ───────────────────────────────────────────────────────────
-- name must be unique within a space.
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,
icon text,
channel_type public.channel_type NOT NULL DEFAULT 'general',
area_reference text,
project_id uuid REFERENCES public.projects(id) ON DELETE SET NULL,
member_count integer NOT NULL DEFAULT 0,
unread_count integer NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (space_id, name)
);
-- ── 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 REFERENCES auth.users(id) ON DELETE SET NULL,
display_name text NOT NULL,
handle text,
content text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- ── activity_log ─────────────────────────────────────────────────────────────
-- Append-only audit log. No UPDATE/DELETE policies.
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,
created_at timestamptz NOT NULL DEFAULT now()
);
-- ─────────────────────────────────────────────────────────────────────────────
-- 4. UNIQUE CONSTRAINTS & INDEXES
-- ─────────────────────────────────────────────────────────────────────────────
-- Partial unique index: (space_id, email) only where email IS NOT NULL
-- Required by importMembers upsert in actions.ts
CREATE UNIQUE INDEX IF NOT EXISTS idx_space_members_space_email
ON public.space_members (space_id, email)
WHERE email IS NOT NULL;
-- Performance 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_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_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_kb_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_contacts_space_id ON public.contacts (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_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_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 because they reference space_members.
-- Both are SECURITY DEFINER with fixed search_path.
-- ─────────────────────────────────────────────────────────────────────────────
-- Returns all space_ids a given user belongs to.
-- Used in RLS SELECT policies to avoid recursive policy 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 has one of the allowed roles in the given space.
-- Used in RLS policies that require elevated permissions.
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
-- ─────────────────────────────────────────────────────────────────────────────
-- Pattern:
-- SELECT → any space member
-- INSERT → any space member, or elevated role depending on sensitivity
-- UPDATE → space member (own row) or elevated role
-- DELETE → admin/board only
-- ─────────────────────────────────────────────────────────────────────────────
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); -- auth hook creates the space; server-side admin client used
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 ─────────────────────────────────────────────────────────────────
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 ──────────────────────────────────────────────────────────────────
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 ─────────────────────────────────────────────────────────────
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 ─────────────────────────────────────────────────────────────
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
-- ─────────────────────────────────────────────────────────────────────────────
-- Create default comms 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, icon, channel_type)
VALUES
(NEW.id, 'general', '💬', 'general'),
(NEW.id, 'announcements', '📢', 'general'),
(NEW.id, 'ops', '⚙️', 'ops');
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 hook: create a space_member row when a new user signs up.
-- Reads metadata fields: space_id, role, full_name / display_name.
-- NOTE: The app's signup flow calls createSpace/joinSpace server actions
-- directly after signUp() returns a session (email confirm disabled).
-- This trigger is a safety net for when email confirmation IS enabled.
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;
v_role := COALESCE(
NULLIF((NEW.raw_user_meta_data->>'role'), '')::public.member_role,
'member'
);
v_display_name := COALESCE(
NULLIF(NEW.raw_user_meta_data->>'display_name', ''),
NULLIF(NEW.raw_user_meta_data->>'full_name', ''),
NEW.email
);
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();
-- ─────────────────────────────────────────────────────────────────────────────
-- 8. REALTIME
-- ─────────────────────────────────────────────────────────────────────────────
-- Enable realtime for live chat.
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_messages;
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_channels;