Files
hackerspace.sh/scripts/schema.sql
T

728 lines
38 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 FRESH DEPLOYMENT SCHEMA
-- =============================================================================
-- This is the single source of truth for a fresh database setup.
-- Run this ONCE on a brand new Supabase project. It is fully idempotent.
--
-- Replaces all previous scripts (000–011). Those are kept as history only.
--
-- Execution order:
-- 1. Extensions
-- 2. Enums
-- 3. Helper functions (used in RLS policies)
-- 4. Tables (dependency order: spaces → space_members → everything else)
-- 5. Indexes
-- 6. Row Level Security (RLS) policies
-- 7. Triggers
-- 8. Auth hook (Supabase auth.users trigger)
-- =============================================================================
-- ─────────────────────────────────────────────────────────────────────────────
-- 1. EXTENSIONS
-- ─────────────────────────────────────────────────────────────────────────────
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- ─────────────────────────────────────────────────────────────────────────────
-- 2. ENUMS
-- ─────────────────────────────────────────────────────────────────────────────
-- Member role within a space
DO $$ BEGIN
CREATE TYPE public.member_role AS ENUM ('admin', 'board', 'treasurer', 'member', 'associate');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Membership tier
DO $$ BEGIN
CREATE TYPE public.member_tier AS ENUM ('plus', 'basic', 'associate');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Membership lifecycle status
DO $$ BEGIN
CREATE TYPE public.member_status AS ENUM ('current', 'late', 'inactive', 'unverified');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Task completion status
-- NOTE: Both 'completed' AND 'done' are valid — 'done' is legacy, 'completed'
-- is canonical. Application code must treat both as finished.
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 $$;
-- Task category
DO $$ BEGIN
CREATE TYPE public.task_type AS ENUM ('chore', 'task');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Task / chore recurrence
DO $$ BEGIN
CREATE TYPE public.recurrence_type AS ENUM ('daily', 'weekly', 'biweekly', 'monthly', 'none');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Project lifecycle status
DO $$ BEGIN
CREATE TYPE public.project_status AS ENUM ('backlog', 'in_progress', 'review', 'done', 'blocked');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Payment platform source
DO $$ BEGIN
CREATE TYPE public.payment_platform AS ENUM ('paypal', 'zeffy', 'venmo', 'cash');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Whether a payment row has been matched to a member
DO $$ BEGIN
CREATE TYPE public.payment_link_status AS ENUM ('linked', 'unlinked');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Knowledge base article visibility
DO $$ BEGIN
CREATE TYPE public.kb_visibility AS ENUM ('all_members', 'board', 'admin_only');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Area lead assignment status
DO $$ BEGIN
CREATE TYPE public.area_lead_status AS ENUM ('active', 'vacant', 'handoff');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Contact / vendor type
DO $$ BEGIN
CREATE TYPE public.contact_type AS ENUM ('vendor', 'supplier', 'partner', 'landlord', 'city');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- Chat channel category
DO $$ BEGIN
CREATE TYPE public.channel_type AS ENUM ('general', 'area', 'ops', 'project');
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
-- ─────────────────────────────────────────────────────────────────────────────
-- 3. HELPER FUNCTIONS
-- Must be created before tables so they can be referenced in RLS policies.
-- Both are SECURITY DEFINER + fixed search_path to prevent privilege escalation.
-- ─────────────────────────────────────────────────────────────────────────────
-- Returns all space_ids a given user belongs to.
-- Used in RLS policies to avoid recursive policy checks on space_members.
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 a user has one of the allowed roles in a specific space.
-- Used in RLS policies that require elevated permissions (admin, board, etc.).
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)
);
$$;
-- ─────────────────────────────────────────────────────────────────────────────
-- 4. TABLES
-- ─────────────────────────────────────────────────────────────────────────────
-- ── spaces ───────────────────────────────────────────────────────────────────
-- Top-level tenant. Each hackerspace is one row.
CREATE TABLE IF NOT EXISTS public.spaces (
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
name text NOT NULL,
slug text NOT NULL UNIQUE,
city text,
require_approval boolean NOT NULL DEFAULT true,
public_member_directory boolean NOT NULL DEFAULT false,
invite_code text NOT NULL UNIQUE,
webhook_secret text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ── space_members ─────────────────────────────────────────────────────────────
-- One row per (user × space). A user may belong to multiple spaces.
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,
display_name text NOT NULL,
handle text,
email text,
phone text,
role member_role NOT NULL DEFAULT 'member',
tier member_tier NOT NULL DEFAULT 'basic',
status member_status NOT NULL DEFAULT 'current',
approved boolean NOT NULL DEFAULT true,
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 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,
type task_type NOT NULL DEFAULT 'task',
task_type text NOT NULL DEFAULT 'chore', -- legacy text col, kept for compat
status task_status NOT NULL DEFAULT 'open',
area text,
recurrence recurrence_type NOT NULL DEFAULT 'none',
due_date timestamptz,
assigned_to uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
assigned_to_name text,
claimed_by uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
claimed_by_name text,
requested_by uuid REFERENCES public.space_members(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()
);
-- ── projects ─────────────────────────────────────────────────────────────────
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, -- legacy alias for title, kept for compat
description text,
status project_status NOT NULL DEFAULT 'backlog',
area text,
category text,
tags text[],
assignees text, -- free-form text list
assignee_names text[], -- structured array
task_count integer NOT NULL DEFAULT 0,
tasks_completed integer NOT NULL DEFAULT 0,
progress integer NOT NULL DEFAULT 0,
due_date timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ── payments ─────────────────────────────────────────────────────────────────
-- One row per transaction. Imported from PayPal/Zeffy/Venmo or logged as cash.
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 payment_platform NOT NULL,
amount numeric(10, 2) NOT NULL,
from_identifier text, -- email, @handle, or name from source platform
from_note text, -- memo / note from sender
member_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
member_name text,
link_status payment_link_status NOT NULL DEFAULT 'unlinked',
transaction_date timestamptz NOT NULL,
created_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,
code text NOT NULL, -- short identifier
contact_type contact_type NOT NULL,
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 ────────────────────────────────────────────────────────────
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 kb_visibility NOT NULL DEFAULT 'all_members',
access_level text NOT NULL DEFAULT 'all_members', -- legacy text col
area text,
tags text[],
is_pinned boolean NOT NULL DEFAULT false,
pinned boolean NOT NULL DEFAULT false, -- legacy alias
updated_by_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL,
updated_by_name text,
updated_by text, -- legacy text col
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ── secrets ──────────────────────────────────────────────────────────────────
-- Shared credentials vault. Only admins can write; board+ can read.
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, -- stored as plain text; encryption is a future enhancement
area text,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ── area_leads ───────────────────────────────────────────────────────────────
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 area_lead_status NOT NULL DEFAULT 'active',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
-- ── integrations ─────────────────────────────────────────────────────────────
-- Payment platform credentials and other third-party configs.
-- config JSONB stores platform-specific keys (e.g. { client_id, client_secret }).
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 ───────────────────────────────────────────────────────────
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 channel_type NOT NULL DEFAULT 'general',
area_reference text, -- links an 'area' channel to an area code
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 ─────────────────────────────────────────────────────────────
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()
);
-- ─────────────────────────────────────────────────────────────────────────────
-- 5. 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_assigned_to ON public.tasks (assigned_to);
CREATE INDEX IF NOT EXISTS idx_tasks_claimed_by ON public.tasks (claimed_by);
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_platform ON public.payments (platform);
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_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_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_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);
-- ─────────────────────────────────────────────────────────────────────────────
-- 6. ROW LEVEL SECURITY
-- ─────────────────────────────────────────────────────────────────────────────
-- Pattern: Every table has RLS enabled.
-- SELECT policies: any space member can read their own space's data.
-- WRITE policies: elevated roles required (admin/board/treasurer as appropriate).
-- The helper functions above prevent recursive policy evaluation.
-- ─────────────────────────────────────────────────────────────────────────────
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 ───────────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS spaces_select_members ON public.spaces;
DROP POLICY IF EXISTS spaces_insert_authenticated ON public.spaces;
DROP POLICY IF EXISTS spaces_update_admins ON public.spaces;
DROP POLICY IF EXISTS spaces_delete_admins ON public.spaces;
CREATE POLICY spaces_select_members ON public.spaces
FOR SELECT USING (id IN (SELECT get_user_space_ids(auth.uid())));
CREATE POLICY spaces_insert_authenticated ON public.spaces
FOR INSERT WITH CHECK (auth.uid() IS NOT NULL);
CREATE POLICY spaces_update_admins ON public.spaces
FOR UPDATE USING (user_has_role_in_space(auth.uid(), id, ARRAY['admin']));
CREATE POLICY spaces_delete_admins ON public.spaces
FOR DELETE USING (user_has_role_in_space(auth.uid(), id, ARRAY['admin']));
-- ── space_members ─────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS space_members_select_own ON public.space_members;
DROP POLICY IF EXISTS space_members_select_same_space ON public.space_members;
DROP POLICY IF EXISTS space_members_insert_authenticated ON public.space_members;
DROP POLICY IF EXISTS space_members_update_admins ON public.space_members;
DROP POLICY IF EXISTS space_members_delete_admins ON public.space_members;
-- Own row always visible (needed for get_user_space_ids bootstrapping)
CREATE POLICY space_members_select_own ON public.space_members
FOR SELECT USING (user_id = auth.uid());
-- All members of the same space can see each other
CREATE POLICY space_members_select_same_space ON public.space_members
FOR SELECT USING (space_id IN (SELECT get_user_space_ids(auth.uid())));
-- A user may insert their own row, or admins/board may insert on behalf of others
CREATE POLICY space_members_insert_authenticated ON public.space_members
FOR INSERT WITH CHECK (
user_id = auth.uid()
OR user_has_role_in_space(auth.uid(), space_id, ARRAY['admin', 'board'])
);
-- Members may update their own profile; admins may update anyone
CREATE POLICY space_members_update_admins ON public.space_members
FOR UPDATE USING (
user_id = auth.uid()
OR user_has_role_in_space(auth.uid(), space_id, ARRAY['admin'])
);
CREATE POLICY space_members_delete_admins ON public.space_members
FOR DELETE USING (user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
-- ── tasks ────────────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS tasks_select_members ON public.tasks;
DROP POLICY IF EXISTS tasks_insert_members ON public.tasks;
DROP POLICY IF EXISTS tasks_update_members ON public.tasks;
DROP POLICY IF EXISTS tasks_delete_members ON public.tasks;
CREATE POLICY tasks_select_members ON public.tasks
FOR SELECT USING (space_id IN (SELECT get_user_space_ids(auth.uid())));
CREATE POLICY tasks_insert_members ON public.tasks
FOR INSERT WITH CHECK (space_id IN (SELECT get_user_space_ids(auth.uid())));
CREATE POLICY tasks_update_members ON public.tasks
FOR UPDATE USING (space_id IN (SELECT get_user_space_ids(auth.uid())));
CREATE POLICY tasks_delete_members ON public.tasks
FOR DELETE USING (space_id IN (SELECT get_user_space_ids(auth.uid())));
-- ── projects ─────────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS projects_select_members ON public.projects;
DROP POLICY IF EXISTS projects_insert_members ON public.projects;
DROP POLICY IF EXISTS projects_update_members ON public.projects;
DROP POLICY IF EXISTS projects_delete_members ON public.projects;
CREATE POLICY projects_select_members ON public.projects
FOR SELECT USING (space_id IN (SELECT get_user_space_ids(auth.uid())));
CREATE POLICY projects_insert_members ON public.projects
FOR INSERT WITH CHECK (space_id IN (SELECT get_user_space_ids(auth.uid())));
CREATE POLICY projects_update_members ON public.projects
FOR UPDATE USING (space_id IN (SELECT get_user_space_ids(auth.uid())));
CREATE POLICY projects_delete_members ON public.projects
FOR DELETE USING (space_id IN (SELECT get_user_space_ids(auth.uid())));
-- ── payments ─────────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS payments_select_treasurer ON public.payments;
DROP POLICY IF EXISTS payments_modify_treasurer ON public.payments;
CREATE POLICY payments_select_treasurer ON public.payments
FOR SELECT USING (
user_has_role_in_space(auth.uid(), space_id, ARRAY['admin', 'board', 'treasurer'])
);
CREATE POLICY payments_modify_treasurer ON public.payments
FOR ALL USING (
user_has_role_in_space(auth.uid(), space_id, ARRAY['admin', 'treasurer'])
);
-- ── contacts ─────────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS contacts_select_members ON public.contacts;
DROP POLICY IF EXISTS contacts_modify_members ON public.contacts;
CREATE POLICY contacts_select_members ON public.contacts
FOR SELECT USING (space_id IN (SELECT get_user_space_ids(auth.uid())));
CREATE POLICY contacts_modify_members ON public.contacts
FOR ALL USING (space_id IN (SELECT get_user_space_ids(auth.uid())));
-- ── knowledge_base ────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS kb_select_members ON public.knowledge_base;
DROP POLICY IF EXISTS kb_insert_members ON public.knowledge_base;
DROP POLICY IF EXISTS kb_update_members ON public.knowledge_base;
DROP POLICY IF EXISTS kb_delete_board ON public.knowledge_base;
CREATE POLICY kb_select_members ON public.knowledge_base
FOR SELECT USING (space_id IN (SELECT get_user_space_ids(auth.uid())));
CREATE POLICY kb_insert_members ON public.knowledge_base
FOR INSERT WITH CHECK (space_id IN (SELECT get_user_space_ids(auth.uid())));
CREATE POLICY kb_update_members ON public.knowledge_base
FOR UPDATE USING (space_id IN (SELECT get_user_space_ids(auth.uid())));
CREATE POLICY kb_delete_board ON public.knowledge_base
FOR DELETE USING (user_has_role_in_space(auth.uid(), space_id, ARRAY['admin', 'board']));
-- ── secrets ──────────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS secrets_select_admins ON public.secrets;
DROP POLICY IF EXISTS secrets_modify_admins ON public.secrets;
-- Board/treasurer can read; only admins can write
CREATE POLICY secrets_select_admins ON public.secrets
FOR SELECT USING (
user_has_role_in_space(auth.uid(), space_id, ARRAY['admin', 'board', 'treasurer'])
);
CREATE POLICY secrets_modify_admins ON public.secrets
FOR ALL USING (user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
-- ── area_leads ───────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS area_leads_select_members ON public.area_leads;
DROP POLICY IF EXISTS area_leads_modify_board ON public.area_leads;
CREATE POLICY area_leads_select_members ON public.area_leads
FOR SELECT USING (space_id IN (SELECT get_user_space_ids(auth.uid())));
CREATE POLICY area_leads_modify_board ON public.area_leads
FOR ALL USING (user_has_role_in_space(auth.uid(), space_id, ARRAY['admin', 'board']));
-- ── integrations ─────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS integrations_select_admins ON public.integrations;
DROP POLICY IF EXISTS integrations_modify_admins ON public.integrations;
CREATE POLICY integrations_select_admins ON public.integrations
FOR SELECT USING (user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
CREATE POLICY integrations_modify_admins ON public.integrations
FOR ALL USING (user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']));
-- ── comms_channels ───────────────────────────────────────────────────────────
DROP POLICY IF EXISTS channels_select_members ON public.comms_channels;
DROP POLICY IF EXISTS channels_insert_board ON public.comms_channels;
DROP POLICY IF EXISTS channels_update_board ON public.comms_channels;
DROP POLICY IF EXISTS channels_delete_board ON public.comms_channels;
CREATE POLICY channels_select_members ON public.comms_channels
FOR SELECT USING (space_id IN (SELECT get_user_space_ids(auth.uid())));
CREATE POLICY channels_insert_board ON public.comms_channels
FOR INSERT WITH CHECK (user_has_role_in_space(auth.uid(), space_id, ARRAY['admin', 'board']));
CREATE POLICY channels_update_board ON public.comms_channels
FOR UPDATE USING (user_has_role_in_space(auth.uid(), space_id, ARRAY['admin', 'board']));
CREATE POLICY channels_delete_board ON public.comms_channels
FOR DELETE USING (user_has_role_in_space(auth.uid(), space_id, ARRAY['admin', 'board']));
-- ── comms_messages ───────────────────────────────────────────────────────────
DROP POLICY IF EXISTS messages_select_members ON public.comms_messages;
DROP POLICY IF EXISTS messages_insert_members ON public.comms_messages;
CREATE POLICY messages_select_members ON public.comms_messages
FOR SELECT USING (space_id IN (SELECT get_user_space_ids(auth.uid())));
-- Users may only insert messages attributed to themselves
CREATE POLICY messages_insert_members ON public.comms_messages
FOR INSERT WITH CHECK (
space_id IN (SELECT get_user_space_ids(auth.uid()))
AND user_id = auth.uid()
);
-- ── activity_log ─────────────────────────────────────────────────────────────
DROP POLICY IF EXISTS activity_select_members ON public.activity_log;
DROP POLICY IF EXISTS activity_insert_members ON public.activity_log;
CREATE POLICY activity_select_members ON public.activity_log
FOR SELECT USING (space_id IN (SELECT get_user_space_ids(auth.uid())));
CREATE POLICY activity_insert_members ON public.activity_log
FOR INSERT WITH CHECK (space_id IN (SELECT get_user_space_ids(auth.uid())));
-- ─────────────────────────────────────────────────────────────────────────────
-- 7. TRIGGERS
-- ─────────────────────────────────────────────────────────────────────────────
-- Auto-creates 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, channel_type) VALUES
(NEW.id, 'general', 'general'),
(NEW.id, 'announcements', 'general'),
(NEW.id, 'random', 'general'),
(NEW.id, 'facilities', 'ops')
ON CONFLICT (space_id, name) DO NOTHING;
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();
-- ─────────────────────────────────────────────────────────────────────────────
-- 8. AUTH HOOK — fires on every new auth.users signup
-- ─────────────────────────────────────────────────────────────────────────────
-- Reads metadata passed at signup time to either:
-- • Create a new space and add the user as admin (space_action = 'create')
-- • Join an existing space via invite code (space_action = 'join')
--
-- Required signup metadata:
-- create: { space_action, space_name, space_slug, space_city, invite_code, full_name }
-- join: { space_action:'join', join_invite_code, full_name }
-- ─────────────────────────────────────────────────────────────────────────────
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_action text;
v_name text;
v_slug text;
v_city text;
v_invite_code text;
BEGIN
v_action := COALESCE(NEW.raw_user_meta_data ->> 'space_action', '');
IF v_action = 'create' THEN
v_name := NEW.raw_user_meta_data ->> 'space_name';
v_slug := NEW.raw_user_meta_data ->> 'space_slug';
v_city := NEW.raw_user_meta_data ->> 'space_city';
v_invite_code := NEW.raw_user_meta_data ->> 'invite_code';
INSERT INTO public.spaces (name, slug, city, invite_code)
VALUES (v_name, v_slug, v_city, v_invite_code)
RETURNING id INTO v_space_id;
INSERT INTO public.space_members
(space_id, user_id, display_name, email, role, tier, status, approved)
VALUES (
v_space_id, NEW.id,
COALESCE(NEW.raw_user_meta_data ->> 'full_name', 'Admin'),
NEW.email,
'admin', 'plus', 'current', true
);
ELSIF v_action = 'join' THEN
v_invite_code := NEW.raw_user_meta_data ->> 'join_invite_code';
SELECT id INTO v_space_id FROM public.spaces WHERE invite_code = v_invite_code;
IF v_space_id IS NOT NULL THEN
INSERT INTO public.space_members
(space_id, user_id, display_name, email, role, tier, status, approved)
SELECT
v_space_id, NEW.id,
COALESCE(NEW.raw_user_meta_data ->> 'full_name', 'Member'),
NEW.email,
'member', 'basic',
CASE WHEN s.require_approval THEN 'unverified' ELSE 'current' END,
CASE WHEN s.require_approval THEN false ELSE true END
FROM public.spaces s WHERE s.id = v_space_id;
END IF;
END IF;
RETURN NEW;
END;
$$;
-- Wire the auth hook (must exist in auth schema, not public)
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();
-- ─────────────────────────────────────────────────────────────────────────────
-- DONE.
-- For a fresh deployment, this is the only script you need to run.
-- ─────────────────────────────────────────────────────────────────────────────