From c0d01b4f3a6b13178eb6ca879a331945f9b1042c Mon Sep 17 00:00:00 2001 From: v0 Date: Wed, 11 Mar 2026 05:43:40 +0000 Subject: [PATCH] feat: create canonical schema and README\n\nGenerate single schema migration and add deployment guide. --- DATABASE.md | 30 ++ scripts/schema.sql | 727 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 757 insertions(+) create mode 100644 DATABASE.md create mode 100644 scripts/schema.sql diff --git a/DATABASE.md b/DATABASE.md new file mode 100644 index 0000000..cf7cc76 --- /dev/null +++ b/DATABASE.md @@ -0,0 +1,30 @@ +## Database Setup + +### Fresh deployment + +Run **one file**: + +``` +scripts/schema.sql +``` + +Paste it in Supabase → **SQL Editor → New query** and hit Run. +That is the complete, idempotent schema — all tables, enums, indexes, RLS policies, functions, and triggers. + +### Existing deployment + +The numbered scripts (`001_*` → `011_*`) are the **migration history** of the live database. +Do not re-run them. They are kept for reference only. + +### What `schema.sql` contains + +| Section | Contents | +|---------|----------| +| Extensions | `uuid-ossp` | +| Enums | 14 custom types (`member_role`, `task_status`, etc.) | +| Helper functions | `get_user_space_ids`, `user_has_role_in_space` | +| Tables | `spaces`, `space_members`, `tasks`, `projects`, `payments`, `contacts`, `knowledge_base`, `secrets`, `area_leads`, `integrations`, `comms_channels`, `comms_messages`, `activity_log` | +| Indexes | 22 performance indexes | +| RLS policies | Full row-level security on every table | +| Triggers | `on_space_created` (auto-creates 4 default channels) | +| Auth hook | `on_auth_user_created` (handles create-space and join-space signup flows) | diff --git a/scripts/schema.sql b/scripts/schema.sql new file mode 100644 index 0000000..820fc18 --- /dev/null +++ b/scripts/schema.sql @@ -0,0 +1,727 @@ +-- ============================================================================= +-- 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. +-- ─────────────────────────────────────────────────────────────────────────────