Files
hackerspace.sh/scripts/schema.sql
T
v0andMoheeb Zara 4ef8438630 fix: resolve session refresh issue post space creation
Ensure session cookies are updated before server navigation for both new and returning users.

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

629 lines
31 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 ---------------------------------------------------------------
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 NOT NULL 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
-- -----------------------------------------------------------------------------
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();
-- -----------------------------------------------------------------------------
-- 8. REALTIME
-- -----------------------------------------------------------------------------
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_messages;
ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_channels;