From 1d08719a04b6804e11c78d6684e449108dc3d3b1 Mon Sep 17 00:00:00 2001 From: v0 Date: Wed, 11 Mar 2026 06:33:50 +0000 Subject: [PATCH] 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 <1184415+virgilvox@users.noreply.github.com> --- lib/actions.ts | 2 +- scripts/schema.sql | 843 ++++++++++++++++++++------------------------- 2 files changed, 372 insertions(+), 473 deletions(-) diff --git a/lib/actions.ts b/lib/actions.ts index 3883b92..1e59580 100644 --- a/lib/actions.ts +++ b/lib/actions.ts @@ -62,7 +62,7 @@ export async function createTask(formData: { space_id: member.space_id, title: formData.title, description: formData.description, - type: formData.type || 'task', + task_type: formData.type || 'task', area: formData.area, recurrence: formData.recurrence || 'none', due_date: formData.due_date || null, diff --git a/scripts/schema.sql b/scripts/schema.sql index 0cfff86..897ea5c 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -1,20 +1,18 @@ -- ============================================================================= --- HACKERSPACE.SH — CANONICAL FRESH DEPLOYMENT SCHEMA +-- HACKERSPACE.SH — CANONICAL SCHEMA (single source of truth) -- ============================================================================= --- 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. +-- 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. 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) +-- 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 -- ============================================================================= -- ───────────────────────────────────────────────────────────────────────────── @@ -26,124 +24,26 @@ 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 $$; +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. 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 +-- 3. TABLES (dependency order) -- ───────────────────────────────────────────────────────────────────────────── -- ── 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, @@ -152,171 +52,186 @@ CREATE TABLE IF NOT EXISTS public.spaces ( logo_url text, city text, address text, - timezone text DEFAULT 'America/Phoenix', + 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 DEFAULT '{}', + 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). A user may belong to multiple spaces. +-- 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 NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + 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, - full_name text, + approved boolean NOT NULL DEFAULT true, + display_name text, + handle text, email text, phone text, bio text, avatar_url text, - joined_at timestamptz DEFAULT now(), + 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(), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), UNIQUE (space_id, user_id) ); --- ── 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 ───────────────────────────────────────────────────────────────── +-- 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() ); --- ── 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() +-- ── 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 ───────────────────────────────────────────────────────────────── --- One row per transaction. Imported from PayPal/Zeffy/Venmo or logged as cash. +-- 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 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, + 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 payment_link_status NOT NULL DEFAULT 'unlinked', - transaction_date timestamptz NOT NULL, - created_at timestamptz NOT NULL DEFAULT now() + 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, -- short identifier - contact_type contact_type NOT NULL, + 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() + 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 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() + 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. Only admins can write; board+ can read. +-- 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, -- stored as plain text; encryption is a future enhancement + 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, + 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() + 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 ───────────────────────────────────────────────────────────── --- Payment platform credentials and other third-party configs. --- config JSONB stores platform-specific keys (e.g. { client_id, client_secret }). +-- 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, @@ -331,17 +246,18 @@ CREATE TABLE IF NOT EXISTS public.integrations ( ); -- ── 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, + 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(), + 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) ); @@ -358,6 +274,7 @@ CREATE TABLE IF NOT EXISTS public.comms_messages ( ); -- ── 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, @@ -372,37 +289,75 @@ CREATE TABLE IF NOT EXISTS public.activity_log ( -- ───────────────────────────────────────────────────────────────────────────── --- 5. INDEXES +-- 4. UNIQUE CONSTRAINTS & 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); + +-- 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_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); +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: 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. +-- 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; @@ -420,227 +375,203 @@ 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" ON public.spaces + FOR SELECT USING (id IN (SELECT public.get_user_space_ids(auth.uid()))); -CREATE POLICY spaces_select_members ON public.spaces - FOR SELECT USING (id IN (SELECT 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_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'])); +CREATE POLICY "spaces_update" ON public.spaces + FOR UPDATE USING (public.user_has_role_in_space(auth.uid(), id, ARRAY['admin','board'])); -- ── 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; +CREATE POLICY "members_select" ON public.space_members + FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); --- 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 +CREATE POLICY "members_insert" 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']) + OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']) ); --- Members may update their own profile; admins may update anyone -CREATE POLICY space_members_update_admins ON public.space_members +CREATE POLICY "members_update" ON public.space_members FOR UPDATE USING ( user_id = auth.uid() - OR user_has_role_in_space(auth.uid(), space_id, ARRAY['admin']) + OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer']) ); -CREATE POLICY space_members_delete_admins ON public.space_members - FOR DELETE USING (user_has_role_in_space(auth.uid(), space_id, ARRAY['admin'])); +CREATE POLICY "members_delete" ON public.space_members + FOR DELETE USING (public.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" ON public.tasks + FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); -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" ON public.tasks + FOR INSERT WITH CHECK (space_id IN (SELECT public.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" ON public.tasks + FOR UPDATE USING (space_id IN (SELECT public.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()))); +CREATE POLICY "tasks_delete" ON public.tasks + FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])); -- ── 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" ON public.projects + FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); -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" ON public.projects + FOR INSERT WITH CHECK (space_id IN (SELECT public.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" ON public.projects + FOR UPDATE USING (space_id IN (SELECT public.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()))); +CREATE POLICY "projects_delete" ON public.projects + FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])); -- ── 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" ON public.payments + FOR SELECT USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])); -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_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_modify_treasurer ON public.payments - FOR ALL USING ( - user_has_role_in_space(auth.uid(), space_id, ARRAY['admin', '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 ───────────────────────────────────────────────────────────────── -DROP POLICY IF EXISTS contacts_select_members ON public.contacts; -DROP POLICY IF EXISTS contacts_modify_members ON public.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_select_members ON public.contacts - FOR SELECT USING (space_id IN (SELECT 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_modify_members ON public.contacts - FOR ALL USING (space_id IN (SELECT get_user_space_ids(auth.uid()))); +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 ──────────────────────────────────────────────────────────── -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 +CREATE POLICY "kb_select" ON public.knowledge_base FOR SELECT USING ( - user_has_role_in_space(auth.uid(), space_id, ARRAY['admin', 'board', 'treasurer']) + 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 secrets_modify_admins ON public.secrets - FOR ALL USING (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 ─────────────────────────────────────────────────────────────── -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" ON public.area_leads + FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); -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_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_modify_board ON public.area_leads - FOR ALL USING (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 ───────────────────────────────────────────────────────────── -DROP POLICY IF EXISTS integrations_select_admins ON public.integrations; -DROP POLICY IF EXISTS integrations_modify_admins ON public.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_select_admins ON public.integrations - FOR SELECT USING (user_has_role_in_space(auth.uid(), space_id, ARRAY['admin'])); +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_modify_admins ON public.integrations - FOR ALL USING (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 ─────────────────────────────────────────────────────────── -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" ON public.comms_channels + FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); -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" ON public.comms_channels + FOR INSERT WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])); -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" ON public.comms_channels + FOR UPDATE USING (public.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'])); +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 ─────────────────────────────────────────────────────────── -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" ON public.comms_messages + FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); -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 +CREATE POLICY "messages_insert" ON public.comms_messages FOR INSERT WITH CHECK ( - space_id IN (SELECT get_user_space_ids(auth.uid())) + 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 ───────────────────────────────────────────────────────────── -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" ON public.activity_log + FOR SELECT USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])); -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()))); +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 +-- 7. TRIGGERS & AUTH HOOK -- ───────────────────────────────────────────────────────────────────────────── --- Auto-creates default channels when a new space is created +-- Create default comms channels when a new space is created. CREATE OR REPLACE FUNCTION public.create_default_channels() - RETURNS trigger - LANGUAGE plpgsql - SECURITY DEFINER + 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; + 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; $$; @@ -650,77 +581,43 @@ 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 } --- ───────────────────────────────────────────────────────────────────────────── - +-- 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 + 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; + v_role public.member_role; + v_display_name text; BEGIN - v_action := COALESCE(NEW.raw_user_meta_data ->> 'space_action', ''); + 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_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; + 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; $$; --- 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 @@ -728,6 +625,8 @@ CREATE TRIGGER on_auth_user_created -- ───────────────────────────────────────────────────────────────────────────── --- DONE. --- For a fresh deployment, this is the only script you need to run. +-- 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;