From 0f71ddf377754d5a21a63275edb3bf2360786102 Mon Sep 17 00:00:00 2001 From: v0 Date: Wed, 11 Mar 2026 06:17:13 +0000 Subject: [PATCH 1/6] fix: resolve auth flow and DB schema issues Fix column name mismatches, enhance signup UX, and align DB schema with codebase. Co-authored-by: Moheeb Zara <1184415+virgilvox@users.noreply.github.com> --- app/signup/page.tsx | 14 +++++++++++ scripts/schema.sql | 60 +++++++++++++++++++++++++-------------------- 2 files changed, 47 insertions(+), 27 deletions(-) diff --git a/app/signup/page.tsx b/app/signup/page.tsx index 09f3968..75c5fb0 100644 --- a/app/signup/page.tsx +++ b/app/signup/page.tsx @@ -107,12 +107,26 @@ export default function SignupPage() { if (authError) { setError(authError.message); setLoading(false); return } + // If Supabase returns a session immediately it means email confirmation is + // disabled — the user is fully signed up AND signed in in one step. + // We still need to create the space/member record via the server action. if (authData.session) { + // User is now authenticated — create the space or join via server action + const result = mode === 'create' + ? await createSpace({ spaceName, spaceSlug, spaceCity, displayName: fullName }) + : await joinSpace({ inviteCode, displayName: fullName }) + + if (result.error) { + setError(result.error) + setLoading(false) + return + } router.push('/dashboard') router.refresh() return } + // Email confirmation is required — redirect to the confirm page router.push('/signup/confirm?email=' + encodeURIComponent(email)) } diff --git a/scripts/schema.sql b/scripts/schema.sql index 820fc18..0cfff86 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -145,38 +145,44 @@ $$; -- ── 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() + 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 NOT NULL DEFAULT true, + public_member_directory boolean NOT NULL DEFAULT false, + webhook_secret text, + settings jsonb 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. 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() + 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, + full_name text, + email text, + phone text, + bio text, + avatar_url text, + 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) ); -- ── tasks ──────────────────────────────────────────────────────────────────── From 1d08719a04b6804e11c78d6684e449108dc3d3b1 Mon Sep 17 00:00:00 2001 From: v0 Date: Wed, 11 Mar 2026 06:33:50 +0000 Subject: [PATCH 2/6] 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; From e36dee260138171d6c7b5338bef21d4aeb3c030d Mon Sep 17 00:00:00 2001 From: v0 Date: Wed, 11 Mar 2026 06:36:43 +0000 Subject: [PATCH 3/6] feat: update main migration script and add sync migration for existing deployments Main migration now canonical for fresh deployments; added sync migration for legacy DBs. Co-authored-by: Moheeb Zara <1184415+virgilvox@users.noreply.github.com> --- DATABASE.md | 31 ++++-- scripts/012_canonical_sync.sql | 193 +++++++++++++++++++++++++++++++++ 2 files changed, 212 insertions(+), 12 deletions(-) create mode 100644 scripts/012_canonical_sync.sql diff --git a/DATABASE.md b/DATABASE.md index 8a28cdc..a90040a 100644 --- a/DATABASE.md +++ b/DATABASE.md @@ -4,29 +4,36 @@ ### Fresh deployment -Run **one file**: +Run **one file** in Supabase SQL Editor: ``` 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. +This creates the entire schema from scratch — all tables, enums, indexes, RLS policies, functions, and triggers. Idempotent, can be run multiple times safely. -### Existing deployment +### Existing deployment migration -The numbered scripts (`001_*` → `011_*`) are the **migration history** of the live database. -Do not re-run them. They are kept for reference only. +If you have a legacy Hackerspace database and want to sync it to the canonical schema: + +``` +scripts/012_canonical_sync.sql +``` + +This migration adds all missing columns, renames `full_name` → `display_name`, creates missing constraints, fixes the auth trigger, and updates all other tables to match the canonical schema. Also idempotent — safe to run multiple times. + +### Migration history (reference only) + +Scripts `001_*` through `011_*` represent the evolution of the live database. **Do not re-run them.** They exist for historical reference. ### What `schema.sql` contains | Section | Contents | |---------|----------| | Extensions | `uuid-ossp` | -| Enums | 14 custom types (`member_role`, `task_status`, etc.) | +| Enums | 14 custom types (`member_role`, `task_status`, `payment_platform`, 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) | +| Tables | 13 tables: `spaces`, `space_members`, `tasks`, `projects`, `payments`, `contacts`, `knowledge_base`, `secrets`, `area_leads`, `integrations`, `comms_channels`, `comms_messages`, `activity_log` | +| Indexes | 22 performance indexes on foreign keys and common filters | +| RLS policies | Full row-level security on all tables (access gated by space membership) | +| Triggers | `on_space_created` (auto-creates 4 default channels), `on_auth_user_created` (auth signup hook) | diff --git a/scripts/012_canonical_sync.sql b/scripts/012_canonical_sync.sql new file mode 100644 index 0000000..a013ce8 --- /dev/null +++ b/scripts/012_canonical_sync.sql @@ -0,0 +1,193 @@ +-- Migration 012: Sync existing databases to canonical schema +-- Run this on any existing Hackerspace deployment to align with the canonical schema.sql +-- Safe to run multiple times (idempotent). +-- +-- Changes: +-- • Rename space_members.full_name → display_name (to match app code everywhere) +-- • Drop redundant tasks.type text column (canonical column is task_type enum) +-- • Add missing columns to all tables that the app code expects +-- • Create missing unique constraints for upserts +-- • Update handle_space_signup trigger to match canonical version + +-- ============================================================ +-- RENAME COLUMNS +-- ============================================================ +ALTER TABLE public.space_members RENAME COLUMN full_name TO display_name; +ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS handle text; +ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS bio text; +ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS avatar_url text; +ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS dues_paid_until timestamptz; +ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS stripe_customer_id text; +ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS has_card_access boolean NOT NULL DEFAULT false; +ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS payment_status text; +ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS payment_note text; +ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS last_payment_at timestamptz; +ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); + +-- ============================================================ +-- TASKS TABLE FIXES +-- ============================================================ +-- Drop the redundant text 'type' column if it exists (canonical column is task_type enum) +ALTER TABLE public.tasks DROP COLUMN IF EXISTS type CASCADE; + +-- Ensure all canonical task columns exist +ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS priority text NOT NULL DEFAULT 'medium'; +ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS requested_by uuid REFERENCES auth.users(id) ON DELETE SET NULL; +ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS requested_by_name text; +ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS claimed_by_name text; +ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS assigned_to_name text; +ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS completed_at timestamptz; +ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS last_done_at timestamptz; +ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS subtask_total integer NOT NULL DEFAULT 0; +ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS subtask_completed integer NOT NULL DEFAULT 0; +ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS progress integer NOT NULL DEFAULT 0; + +-- ============================================================ +-- SPACES TABLE +-- ============================================================ +ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS city text; +ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS address text; +ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS timezone text DEFAULT 'America/Phoenix'; +ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS invite_code text; +ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS require_approval boolean NOT NULL DEFAULT true; +ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS public_member_directory boolean NOT NULL DEFAULT false; +ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS webhook_secret text; + +-- ============================================================ +-- PAYMENTS TABLE +-- ============================================================ +ALTER TABLE public.payments ADD COLUMN IF NOT EXISTS from_identifier text; +ALTER TABLE public.payments ADD COLUMN IF NOT EXISTS from_note text; +ALTER TABLE public.payments ADD COLUMN IF NOT EXISTS member_name text; +ALTER TABLE public.payments ADD COLUMN IF NOT EXISTS link_status public.payment_link_status NOT NULL DEFAULT 'unlinked'; +ALTER TABLE public.payments ADD COLUMN IF NOT EXISTS transaction_date timestamptz DEFAULT now(); + +-- ============================================================ +-- CONTACTS TABLE +-- ============================================================ +ALTER TABLE public.contacts ADD COLUMN IF NOT EXISTS code text; +ALTER TABLE public.contacts ADD COLUMN IF NOT EXISTS details text; +ALTER TABLE public.contacts ADD COLUMN IF NOT EXISTS note text; +ALTER TABLE public.contacts ADD COLUMN IF NOT EXISTS group_label text; + +-- ============================================================ +-- KNOWLEDGE BASE +-- ============================================================ +ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS icon text; +ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS access_level text DEFAULT 'all_members'; +ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS area text; +ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS is_pinned boolean NOT NULL DEFAULT false; +ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS updated_by_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL; +ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS updated_by_name text; +ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS updated_by text; + +-- ============================================================ +-- SECRETS TABLE +-- ============================================================ +ALTER TABLE public.secrets ADD COLUMN IF NOT EXISTS icon text; +ALTER TABLE public.secrets ADD COLUMN IF NOT EXISTS description text; +ALTER TABLE public.secrets ADD COLUMN IF NOT EXISTS area text; +ALTER TABLE public.secrets ADD COLUMN IF NOT EXISTS title text; +-- Backfill title from label where label exists and title is null +UPDATE public.secrets SET title = label WHERE title IS NULL AND label IS NOT NULL; + +-- ============================================================ +-- AREA LEADS TABLE +-- ============================================================ +ALTER TABLE public.area_leads ADD COLUMN IF NOT EXISTS area_code text; +ALTER TABLE public.area_leads ADD COLUMN IF NOT EXISTS lead_handle text; + +-- Add unique constraint on (space_id, area_code) if not already present +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'area_leads_space_area_code_key' + ) THEN + ALTER TABLE public.area_leads ADD CONSTRAINT area_leads_space_area_code_key + UNIQUE (space_id, area_code) DEFERRABLE INITIALLY DEFERRED; + END IF; +END $$; + +-- ============================================================ +-- INTEGRATIONS TABLE +-- ============================================================ +ALTER TABLE public.integrations ADD COLUMN IF NOT EXISTS name text; +ALTER TABLE public.integrations ADD COLUMN IF NOT EXISTS description text; +ALTER TABLE public.integrations ADD COLUMN IF NOT EXISTS config jsonb; + +-- ============================================================ +-- COMMS CHANNELS +-- ============================================================ +ALTER TABLE public.comms_channels ADD COLUMN IF NOT EXISTS icon text; +ALTER TABLE public.comms_channels ADD COLUMN IF NOT EXISTS area_reference text; +ALTER TABLE public.comms_channels ADD COLUMN IF NOT EXISTS member_count integer NOT NULL DEFAULT 0; +ALTER TABLE public.comms_channels ADD COLUMN IF NOT EXISTS unread_count integer NOT NULL DEFAULT 0; + +-- ============================================================ +-- COMMS MESSAGES +-- ============================================================ +ALTER TABLE public.comms_messages ADD COLUMN IF NOT EXISTS display_name text; +ALTER TABLE public.comms_messages ADD COLUMN IF NOT EXISTS handle text; + +-- ============================================================ +-- ACTIVITY LOG +-- ============================================================ +ALTER TABLE public.activity_log ADD COLUMN IF NOT EXISTS display_name text; +ALTER TABLE public.activity_log ADD COLUMN IF NOT EXISTS details text; + +-- ============================================================ +-- PROJECTS TABLE +-- ============================================================ +ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS name text; +ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS area text; +ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS category text; +ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS assignees text; +ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS assignee_names text[]; +ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS task_count integer NOT NULL DEFAULT 0; +ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS tasks_completed integer NOT NULL DEFAULT 0; +ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS progress integer NOT NULL DEFAULT 0; + +-- ============================================================ +-- UNIQUE INDEXES / CONSTRAINTS +-- ============================================================ +-- space_members: unique index on (space_id, email) for upsert in importMembers +CREATE UNIQUE INDEX IF NOT EXISTS idx_space_members_space_email + ON public.space_members (space_id, email) + WHERE email IS NOT NULL; + +-- ============================================================ +-- UPDATE TRIGGERS +-- ============================================================ +-- Fix handle_space_signup to match canonical version +CREATE OR REPLACE FUNCTION public.handle_space_signup() + RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER + SET search_path = public +AS $$ +DECLARE + v_space_id uuid; + v_role public.member_role; + v_display_name text; +BEGIN + v_space_id := (NEW.raw_user_meta_data->>'space_id')::uuid; + v_role := COALESCE( + NULLIF((NEW.raw_user_meta_data->>'role'), '')::public.member_role, + 'member' + ); + v_display_name := COALESCE( + NULLIF(NEW.raw_user_meta_data->>'display_name', ''), + NULLIF(NEW.raw_user_meta_data->>'full_name', ''), + NEW.email + ); + + IF v_space_id IS NULL THEN + RETURN NEW; + END IF; + + INSERT INTO public.space_members (space_id, user_id, role, status, display_name, email, approved) + VALUES (v_space_id, NEW.id, v_role, 'unverified', v_display_name, NEW.email, true) + ON CONFLICT (space_id, user_id) DO NOTHING; + + RETURN NEW; +END; +$$; From 4ef84386305e276ee72351e7b0b7c396ed31deb5 Mon Sep 17 00:00:00 2001 From: v0 Date: Wed, 11 Mar 2026 06:48:38 +0000 Subject: [PATCH 4/6] 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 <1184415+virgilvox@users.noreply.github.com> --- DATABASE.md | 14 - DB_SCHEMA_MAP.md | 14 +- app/signup/page.tsx | 20 +- lib/types.ts | 2 +- scripts/000_reset_schema.sql | 37 -- scripts/001_create_schema.sql | 635 ---------------------- scripts/002_schema_fixes.sql | 82 --- scripts/003_add_approved_column.sql | 9 - scripts/003_signup_trigger.sql | 85 --- scripts/004_fix_rls_recursion.sql | 39 -- scripts/005_fix_spaces_insert.sql | 20 - scripts/006_comprehensive_rls_fix.sql | 516 ------------------ scripts/007_schema_audit_fixes.sql | 62 --- scripts/008_fix_approved_default.sql | 12 - scripts/009_fix_member_insert_rls.sql | 32 -- scripts/010_fix_channel_trigger.sql | 27 - scripts/011_fix_member_status_enum.sql | 93 ---- scripts/012_canonical_sync.sql | 193 ------- scripts/schema.sql | 698 ++++++++++++------------- 19 files changed, 371 insertions(+), 2219 deletions(-) delete mode 100644 scripts/000_reset_schema.sql delete mode 100644 scripts/001_create_schema.sql delete mode 100644 scripts/002_schema_fixes.sql delete mode 100644 scripts/003_add_approved_column.sql delete mode 100644 scripts/003_signup_trigger.sql delete mode 100644 scripts/004_fix_rls_recursion.sql delete mode 100644 scripts/005_fix_spaces_insert.sql delete mode 100644 scripts/006_comprehensive_rls_fix.sql delete mode 100644 scripts/007_schema_audit_fixes.sql delete mode 100644 scripts/008_fix_approved_default.sql delete mode 100644 scripts/009_fix_member_insert_rls.sql delete mode 100644 scripts/010_fix_channel_trigger.sql delete mode 100644 scripts/011_fix_member_status_enum.sql delete mode 100644 scripts/012_canonical_sync.sql diff --git a/DATABASE.md b/DATABASE.md index a90040a..4a3eded 100644 --- a/DATABASE.md +++ b/DATABASE.md @@ -12,20 +12,6 @@ scripts/schema.sql This creates the entire schema from scratch — all tables, enums, indexes, RLS policies, functions, and triggers. Idempotent, can be run multiple times safely. -### Existing deployment migration - -If you have a legacy Hackerspace database and want to sync it to the canonical schema: - -``` -scripts/012_canonical_sync.sql -``` - -This migration adds all missing columns, renames `full_name` → `display_name`, creates missing constraints, fixes the auth trigger, and updates all other tables to match the canonical schema. Also idempotent — safe to run multiple times. - -### Migration history (reference only) - -Scripts `001_*` through `011_*` represent the evolution of the live database. **Do not re-run them.** They exist for historical reference. - ### What `schema.sql` contains | Section | Contents | diff --git a/DB_SCHEMA_MAP.md b/DB_SCHEMA_MAP.md index 7e6302c..6512409 100644 --- a/DB_SCHEMA_MAP.md +++ b/DB_SCHEMA_MAP.md @@ -13,7 +13,7 @@ | `member_role` | `admin`, `board`, `treasurer`, `member`, `associate` | | `member_tier` | `plus`, `basic`, `associate` | | `member_status` | `current`, `late`, `inactive`, `unverified` — **NOT** `active` or `pending` | -| `task_type` | `chore`, `task` — column is named **`type`**, NOT `task_type`. NO `ongoing` | +| `task_type` | `chore`, `task` — DB column is **`task_type`** (enum name and column name match) | | `task_status` | `open`, `claimed`, `in_progress`, `overdue`, `due_today`, `completed`, `done`, `blocked` | | `recurrence_type` | `daily`, `weekly`, `biweekly`, `monthly`, `none` — NO `quarterly` | | `project_status` | `backlog`, `in_progress`, `review`, `done`, `blocked` | @@ -66,13 +66,14 @@ | `payment_status` | text | nullable | | `payment_note` | text | nullable | | `joined_at` | timestamptz | | -| `last_paid_at` | timestamptz | nullable — **NOT** `last_payment_at` | +| `last_payment_at` | timestamptz | nullable — legacy, **also has `last_paid_at`** | +| `last_paid_at` | timestamptz | nullable — added script 013; used by importMembers / linkPaymentToMember | | `has_card_access` | boolean | default false | | `approved` | boolean | default **true** (fixed script 008) | | UNIQUE | `(space_id, user_id)` | | | UNIQUE | `(space_id, email)` | added script 009 | -**Does NOT have:** `last_payment_at`, `approved_at`, `bio`, `avatar_url` +**Does NOT have:** `last_payment_at` (has both `last_payment_at` and `last_paid_at`), `approved_at`, `bio`, `avatar_url` --- @@ -83,7 +84,7 @@ | `space_id` | uuid FK → spaces | NOT NULL | | `title` | text NOT NULL | | | `description` | text | nullable | -| `type` | task_type | default `'task'` — column is **`type`**, NOT `task_type` | +| `task_type` | task_type | default `'task'` — column is **`task_type`**, matching the enum name | | `status` | task_status | default `'open'` — use `'completed'` not `'done'` | | `area` | text | nullable | | `recurrence` | recurrence_type | default `'none'` | @@ -101,7 +102,7 @@ | `created_at` | timestamptz | | | `updated_at` | timestamptz | | -**Does NOT have:** `task_type`, `progress`, `blocked`, `priority` +**Does NOT have:** `type` (use `task_type`), `progress`, `blocked`, `priority` --- @@ -386,3 +387,6 @@ Default channels created by trigger on space INSERT: `general`, `announcements`, | 008 | space_members.approved default=true; backfilled NULLs — fixed login loop | | 009 | space_members INSERT RLS: allow admin/board inserts; added UNIQUE(space_id,email) | | 010 | Fixed create_default_channels trigger — removed non-existent `description` column | +| 011 | Renamed tasks `type` column to `task_type` (canonical enum column name) | +| 012 | Canonical sync — task_type enum, cleaned duplicate columns | +| 013 | Added `space_members.last_paid_at` timestamptz — required by importMembers and linkPaymentToMember | diff --git a/app/signup/page.tsx b/app/signup/page.tsx index 75c5fb0..812500c 100644 --- a/app/signup/page.tsx +++ b/app/signup/page.tsx @@ -59,7 +59,7 @@ export default function SignupPage() { setLoading(true) setError('') - // Returning user — call server actions (runs server-side, bypasses any client RLS issues) + // Returning user — already has a session, just create/join if (isReturningUser) { const result = mode === 'create' ? await createSpace({ spaceName, spaceSlug, spaceCity, displayName: fullName }) @@ -70,8 +70,8 @@ export default function SignupPage() { setLoading(false) return } - router.push('/dashboard') - router.refresh() + // Hard navigate so server layout re-reads cookies fresh + window.location.href = '/dashboard' return } @@ -111,7 +111,14 @@ export default function SignupPage() { // disabled — the user is fully signed up AND signed in in one step. // We still need to create the space/member record via the server action. if (authData.session) { - // User is now authenticated — create the space or join via server action + // Manually set the session so server-side cookies are flushed before + // we call the server action (which needs auth.getUser() to succeed). + const supabaseClient = createClient() + await supabaseClient.auth.setSession({ + access_token: authData.session.access_token, + refresh_token: authData.session.refresh_token, + }) + const result = mode === 'create' ? await createSpace({ spaceName, spaceSlug, spaceCity, displayName: fullName }) : await joinSpace({ inviteCode, displayName: fullName }) @@ -121,8 +128,9 @@ export default function SignupPage() { setLoading(false) return } - router.push('/dashboard') - router.refresh() + + // Hard navigate so the server re-reads cookies and picks up the session + window.location.href = '/dashboard' return } diff --git a/lib/types.ts b/lib/types.ts index 6e411da..cb60f00 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -49,7 +49,7 @@ export interface SpaceMember { export interface Task { id: string space_id: string - type: TaskType + task_type: TaskType title: string description?: string status: TaskStatus diff --git a/scripts/000_reset_schema.sql b/scripts/000_reset_schema.sql deleted file mode 100644 index 913bea3..0000000 --- a/scripts/000_reset_schema.sql +++ /dev/null @@ -1,37 +0,0 @@ --- Clean up any existing policies and tables for fresh start -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 "space_members_select_own_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; - -drop table if exists public.activity_log cascade; -drop table if exists public.comms_messages cascade; -drop table if exists public.comms_channels cascade; -drop table if exists public.integrations cascade; -drop table if exists public.payments cascade; -drop table if exists public.contacts cascade; -drop table if exists public.area_leads cascade; -drop table if exists public.secrets cascade; -drop table if exists public.knowledge_base cascade; -drop table if exists public.projects cascade; -drop table if exists public.tasks cascade; -drop table if exists public.space_members cascade; -drop table if exists public.spaces cascade; - -drop type if exists channel_type cascade; -drop type if exists payment_link_status cascade; -drop type if exists payment_platform cascade; -drop type if exists contact_type cascade; -drop type if exists area_lead_status cascade; -drop type if exists kb_visibility cascade; -drop type if exists project_status cascade; -drop type if exists recurrence_type cascade; -drop type if exists task_status cascade; -drop type if exists task_type cascade; -drop type if exists member_status cascade; -drop type if exists member_tier cascade; -drop type if exists member_role cascade; diff --git a/scripts/001_create_schema.sql b/scripts/001_create_schema.sql deleted file mode 100644 index d47a1a7..0000000 --- a/scripts/001_create_schema.sql +++ /dev/null @@ -1,635 +0,0 @@ --- Enable UUID extension -create extension if not exists "uuid-ossp"; - --- ===================================================== --- ENUMS --- ===================================================== -do $$ begin - create type member_role as enum ('admin', 'board', 'treasurer', 'member', 'associate'); -exception when duplicate_object then null; end $$; - -do $$ begin - create type member_tier as enum ('plus', 'basic', 'associate'); -exception when duplicate_object then null; end $$; - -do $$ begin - create type member_status as enum ('active', 'pending', 'inactive'); -exception when duplicate_object then null; end $$; - -do $$ begin - create type payment_status as enum ('current', 'late', '3mo_late', 'unverified'); -exception when duplicate_object then null; end $$; - -do $$ begin - create type task_type as enum ('chore', 'task'); -exception when duplicate_object then null; end $$; - -do $$ begin - create type task_status as enum ('open', 'claimed', 'in_progress', 'overdue', 'due_today', 'completed'); -exception when duplicate_object then null; end $$; - -do $$ begin - create type recurrence_type as enum ('daily', 'weekly', 'biweekly', 'monthly', 'none'); -exception when duplicate_object then null; end $$; - -do $$ begin - create type project_status as enum ('backlog', 'in_progress', 'review', 'done'); -exception when duplicate_object then null; end $$; - -do $$ begin - create type kb_visibility as enum ('all_members', 'board', 'admin_only'); -exception when duplicate_object then null; end $$; - -do $$ begin - create type area_lead_status as enum ('active', 'vacant', 'handoff'); -exception when duplicate_object then null; end $$; - -do $$ begin - create type contact_type as enum ('vendor', 'supplier', 'partner', 'landlord', 'city'); -exception when duplicate_object then null; end $$; - -do $$ begin - create type payment_platform as enum ('paypal', 'zeffy', 'venmo', 'cash'); -exception when duplicate_object then null; end $$; - -do $$ begin - create type payment_link_status as enum ('linked', 'unlinked'); -exception when duplicate_object then null; end $$; - -do $$ begin - create type channel_type as enum ('general', 'area', 'ops', 'project'); -exception when duplicate_object then null; end $$; - --- ===================================================== --- SPACES (Multi-tenant containers) --- ===================================================== -create table if not exists public.spaces ( - id uuid primary key default uuid_generate_v4(), - name text not null, - slug text unique not null, - city text, - require_approval boolean default true, - public_member_directory boolean default false, - invite_code text unique not null, - webhook_secret text, - created_at timestamp with time zone default now(), - updated_at timestamp with time zone default now() -); - -alter table public.spaces enable row level security; - --- ===================================================== --- SPACE MEMBERS (Users in spaces with roles) --- ===================================================== -create table if not exists public.space_members ( - id uuid primary key default uuid_generate_v4(), - space_id uuid references public.spaces(id) on delete cascade not null, - user_id uuid references auth.users(id) on delete cascade, - role text default 'member', - tier text default 'basic', - display_name text not null, - email text, - handle text, - phone text, - status text default 'active', - payment_status text, - payment_note text, - joined_at timestamp with time zone default now(), - last_paid_at timestamp with time zone, - has_card_access boolean default false, - unique(space_id, user_id) -); - -alter table public.space_members enable row level security; - --- Now add spaces RLS policies (after space_members exists) -create policy "spaces_select_members" on public.spaces for select using ( - exists ( - select 1 from public.space_members - where space_members.space_id = spaces.id - and space_members.user_id = 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 ( - exists ( - select 1 from public.space_members - where space_members.space_id = spaces.id - and space_members.user_id = auth.uid() - and space_members.role = 'admin' - ) -); - --- space_members RLS policies -create policy "space_members_select_own_space" on public.space_members for select using ( - user_id = auth.uid() - or space_id in ( - select space_id from public.space_members where user_id = auth.uid() - ) -); - -create policy "space_members_insert_authenticated" on public.space_members for insert - with check (auth.uid() = user_id); - -create policy "space_members_update_admins" on public.space_members for update using ( - user_id = auth.uid() - or exists ( - select 1 from public.space_members sm - where sm.space_id = space_members.space_id - and sm.user_id = auth.uid() - and sm.role = 'admin' - ) -); - -create policy "space_members_delete_admins" on public.space_members for delete using ( - exists ( - select 1 from public.space_members sm - where sm.space_id = space_members.space_id - and sm.user_id = auth.uid() - and sm.role = 'admin' - ) -); - --- ===================================================== --- TASKS & CHORES --- ===================================================== -create table if not exists public.tasks ( - id uuid primary key default uuid_generate_v4(), - space_id uuid references public.spaces(id) on delete cascade not null, - title text not null, - description text, - type task_type default 'task', - status task_status default 'open', - area text, - recurrence recurrence_type default 'none', - due_date timestamp with time zone, - assigned_to uuid references auth.users(id) on delete set null, - assigned_to_name text, - claimed_by uuid references auth.users(id) on delete set null, - claimed_by_name text, - requested_by uuid references auth.users(id) on delete set null, - requested_by_name text, - subtask_total integer default 0, - subtask_completed integer default 0, - last_done_at timestamp with time zone, - completed_at timestamp with time zone, - created_at timestamp with time zone default now(), - updated_at timestamp with time zone default now() -); - -alter table public.tasks enable row level security; - -create policy "tasks_select_members" on public.tasks for select using ( - exists ( - select 1 from public.space_members - where space_members.space_id = tasks.space_id - and space_members.user_id = auth.uid() - ) -); - -create policy "tasks_insert_members" on public.tasks for insert with check ( - exists ( - select 1 from public.space_members - where space_members.space_id = tasks.space_id - and space_members.user_id = auth.uid() - ) -); - -create policy "tasks_update_members" on public.tasks for update using ( - exists ( - select 1 from public.space_members - where space_members.space_id = tasks.space_id - and space_members.user_id = auth.uid() - ) -); - -create policy "tasks_delete_members" on public.tasks for delete using ( - exists ( - select 1 from public.space_members - where space_members.space_id = tasks.space_id - and space_members.user_id = auth.uid() - ) -); - --- ===================================================== --- PROJECTS --- ===================================================== -create table if not exists public.projects ( - id uuid primary key default uuid_generate_v4(), - space_id uuid references public.spaces(id) on delete cascade not null, - title text not null, - description text, - status project_status default 'backlog', - area text, - tags text[], - assignee_names text[], - task_count integer default 0, - tasks_completed integer default 0, - due_date timestamp with time zone, - created_at timestamp with time zone default now(), - updated_at timestamp with time zone default now() -); - -alter table public.projects enable row level security; - -create policy "projects_select_members" on public.projects for select using ( - exists ( - select 1 from public.space_members - where space_members.space_id = projects.space_id - and space_members.user_id = auth.uid() - ) -); - -create policy "projects_insert_members" on public.projects for insert with check ( - exists ( - select 1 from public.space_members - where space_members.space_id = projects.space_id - and space_members.user_id = auth.uid() - ) -); - -create policy "projects_update_members" on public.projects for update using ( - exists ( - select 1 from public.space_members - where space_members.space_id = projects.space_id - and space_members.user_id = auth.uid() - ) -); - -create policy "projects_delete_members" on public.projects for delete using ( - exists ( - select 1 from public.space_members - where space_members.space_id = projects.space_id - and space_members.user_id = auth.uid() - ) -); - --- ===================================================== --- KNOWLEDGE BASE --- ===================================================== -create table if not exists public.knowledge_base ( - id uuid primary key default uuid_generate_v4(), - space_id uuid references public.spaces(id) on delete cascade not null, - title text not null, - content text, - icon text, - visibility kb_visibility default 'all_members', - area text, - tags text[], - is_pinned boolean default false, - updated_by_id uuid references auth.users(id) on delete set null, - updated_by_name text, - created_at timestamp with time zone default now(), - updated_at timestamp with time zone default now() -); - -alter table public.knowledge_base enable row level security; - -create policy "kb_select_members" on public.knowledge_base for select using ( - exists ( - select 1 from public.space_members - where space_members.space_id = knowledge_base.space_id - and space_members.user_id = auth.uid() - and ( - knowledge_base.visibility = 'all_members' - or (knowledge_base.visibility = 'board' and space_members.role in ('admin', 'board', 'treasurer')) - or (knowledge_base.visibility = 'admin_only' and space_members.role = 'admin') - ) - ) -); - -create policy "kb_insert_members" on public.knowledge_base for insert with check ( - exists ( - select 1 from public.space_members - where space_members.space_id = knowledge_base.space_id - and space_members.user_id = auth.uid() - ) -); - -create policy "kb_update_members" on public.knowledge_base for update using ( - exists ( - select 1 from public.space_members - where space_members.space_id = knowledge_base.space_id - and space_members.user_id = auth.uid() - ) -); - -create policy "kb_delete_board" on public.knowledge_base for delete using ( - exists ( - select 1 from public.space_members - where space_members.space_id = knowledge_base.space_id - and space_members.user_id = auth.uid() - and space_members.role in ('admin', 'board') - ) -); - --- ===================================================== --- SECRETS VAULT --- ===================================================== -create table if not exists public.secrets ( - id uuid primary key default uuid_generate_v4(), - space_id uuid references public.spaces(id) on delete cascade not null, - title text not null, - icon text, - description text, - value text not null, - area text, - created_at timestamp with time zone default now(), - updated_at timestamp with time zone default now() -); - -alter table public.secrets enable row level security; - -create policy "secrets_select_admins" on public.secrets for select using ( - exists ( - select 1 from public.space_members - where space_members.space_id = secrets.space_id - and space_members.user_id = auth.uid() - and space_members.role in ('admin', 'board', 'treasurer') - ) -); - -create policy "secrets_modify_admins" on public.secrets for all using ( - exists ( - select 1 from public.space_members - where space_members.space_id = secrets.space_id - and space_members.user_id = auth.uid() - and space_members.role = 'admin' - ) -); - --- ===================================================== --- AREA LEADS --- ===================================================== -create table if not exists public.area_leads ( - id uuid primary key default uuid_generate_v4(), - space_id uuid references public.spaces(id) on delete cascade not null, - area_name text not null, - area_code text not null, - lead_id uuid references auth.users(id) on delete set null, - lead_handle text, - status area_lead_status default 'active', - created_at timestamp with time zone default now(), - updated_at timestamp with time zone default now() -); - -alter table public.area_leads enable row level security; - -create policy "area_leads_select_members" on public.area_leads for select using ( - exists ( - select 1 from public.space_members - where space_members.space_id = area_leads.space_id - and space_members.user_id = auth.uid() - ) -); - -create policy "area_leads_modify_board" on public.area_leads for all using ( - exists ( - select 1 from public.space_members - where space_members.space_id = area_leads.space_id - and space_members.user_id = auth.uid() - and space_members.role in ('admin', 'board') - ) -); - --- ===================================================== --- CONTACTS --- ===================================================== -create table if not exists public.contacts ( - id uuid primary key default uuid_generate_v4(), - space_id uuid references public.spaces(id) on delete cascade not null, - name text not null, - code text not null, - contact_type contact_type not null, - email text, - phone text, - details text, - tags text[], - created_at timestamp with time zone default now(), - updated_at timestamp with time zone default now() -); - -alter table public.contacts enable row level security; - -create policy "contacts_select_members" on public.contacts for select using ( - exists ( - select 1 from public.space_members - where space_members.space_id = contacts.space_id - and space_members.user_id = auth.uid() - ) -); - -create policy "contacts_modify_members" on public.contacts for all using ( - exists ( - select 1 from public.space_members - where space_members.space_id = contacts.space_id - and space_members.user_id = auth.uid() - ) -); - --- ===================================================== --- PAYMENTS --- ===================================================== -create table if not exists public.payments ( - id uuid primary key default uuid_generate_v4(), - space_id uuid references public.spaces(id) on delete cascade not null, - platform 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 default 'unlinked', - transaction_date timestamp with time zone not null, - created_at timestamp with time zone default now() -); - -alter table public.payments enable row level security; - -create policy "payments_select_treasurer" on public.payments for select using ( - exists ( - select 1 from public.space_members - where space_members.space_id = payments.space_id - and space_members.user_id = auth.uid() - and space_members.role in ('admin', 'board', 'treasurer') - ) -); - -create policy "payments_modify_treasurer" on public.payments for all using ( - exists ( - select 1 from public.space_members - where space_members.space_id = payments.space_id - and space_members.user_id = auth.uid() - and space_members.role in ('admin', 'treasurer') - ) -); - --- ===================================================== --- COMMS CHANNELS --- ===================================================== -create table if not exists public.comms_channels ( - id uuid primary key default uuid_generate_v4(), - space_id uuid references public.spaces(id) on delete cascade not null, - name text not null, - icon text, - channel_type channel_type default 'general', - area_reference text, - project_id uuid references public.projects(id) on delete cascade, - member_count integer default 0, - created_at timestamp with time zone default now() -); - -alter table public.comms_channels enable row level security; - -create policy "channels_select_members" on public.comms_channels for select using ( - exists ( - select 1 from public.space_members - where space_members.space_id = comms_channels.space_id - and space_members.user_id = auth.uid() - ) -); - -create policy "channels_insert_board" on public.comms_channels for insert with check ( - exists ( - select 1 from public.space_members - where space_members.space_id = comms_channels.space_id - and space_members.user_id = auth.uid() - and space_members.role in ('admin', 'board') - ) -); - -create policy "channels_update_board" on public.comms_channels for update using ( - exists ( - select 1 from public.space_members - where space_members.space_id = comms_channels.space_id - and space_members.user_id = auth.uid() - and space_members.role in ('admin', 'board') - ) -); - -create policy "channels_delete_board" on public.comms_channels for delete using ( - exists ( - select 1 from public.space_members - where space_members.space_id = comms_channels.space_id - and space_members.user_id = auth.uid() - and space_members.role in ('admin', 'board') - ) -); - --- ===================================================== --- COMMS MESSAGES (stored for history; real-time via CLASP) --- ===================================================== -create table if not exists public.comms_messages ( - id uuid primary key default uuid_generate_v4(), - channel_id uuid references public.comms_channels(id) on delete cascade not null, - space_id uuid references public.spaces(id) on delete cascade not null, - user_id uuid references auth.users(id) on delete set null, - display_name text not null, - handle text, - content text not null, - created_at timestamp with time zone default now() -); - -alter table public.comms_messages enable row level security; - -create policy "messages_select_members" on public.comms_messages for select using ( - exists ( - select 1 from public.space_members - where space_members.space_id = comms_messages.space_id - and space_members.user_id = auth.uid() - ) -); - -create policy "messages_insert_members" on public.comms_messages for insert with check ( - exists ( - select 1 from public.space_members - where space_members.space_id = comms_messages.space_id - and space_members.user_id = auth.uid() - ) and user_id = auth.uid() -); - --- ===================================================== --- INTEGRATIONS --- ===================================================== -create table if not exists public.integrations ( - id uuid primary key default uuid_generate_v4(), - space_id uuid references public.spaces(id) on delete cascade not null, - name text not null, - platform text not null unique, - description text, - is_connected boolean default false, - config jsonb, - created_at timestamp with time zone default now(), - updated_at timestamp with time zone default now() -); - -alter table public.integrations enable row level security; - -create policy "integrations_select_admins" on public.integrations for select using ( - exists ( - select 1 from public.space_members - where space_members.space_id = integrations.space_id - and space_members.user_id = auth.uid() - and space_members.role = 'admin' - ) -); - -create policy "integrations_modify_admins" on public.integrations for all using ( - exists ( - select 1 from public.space_members - where space_members.space_id = integrations.space_id - and space_members.user_id = auth.uid() - and space_members.role = 'admin' - ) -); - --- ===================================================== --- ACTIVITY LOG --- ===================================================== -create table if not exists public.activity_log ( - id uuid primary key default uuid_generate_v4(), - space_id uuid references public.spaces(id) on delete cascade not null, - 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 timestamp with time zone default now() -); - -alter table public.activity_log enable row level security; - -create policy "activity_select_members" on public.activity_log for select using ( - exists ( - select 1 from public.space_members - where space_members.space_id = activity_log.space_id - and space_members.user_id = auth.uid() - ) -); - -create policy "activity_insert_members" on public.activity_log for insert with check ( - exists ( - select 1 from public.space_members - where space_members.space_id = activity_log.space_id - and space_members.user_id = auth.uid() - ) -); - --- ===================================================== --- INDEXES for Performance --- ===================================================== -create index if not exists idx_space_members_space_id on public.space_members(space_id); -create index if not exists idx_space_members_user_id on public.space_members(user_id); -create index if not exists idx_tasks_space_id on public.tasks(space_id); -create index if not exists idx_projects_space_id on public.projects(space_id); -create index if not exists idx_kb_space_id on public.knowledge_base(space_id); -create index if not exists idx_messages_channel_id on public.comms_messages(channel_id); -create index if not exists idx_messages_space_id on public.comms_messages(space_id); -create index if not exists idx_payments_space_id on public.payments(space_id); -create index if not exists idx_activity_space_id on public.activity_log(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); diff --git a/scripts/002_schema_fixes.sql b/scripts/002_schema_fixes.sql deleted file mode 100644 index f02aa14..0000000 --- a/scripts/002_schema_fixes.sql +++ /dev/null @@ -1,82 +0,0 @@ --- Drop and recreate tables that need schema fixes --- This script fixes the schema to match the application code - --- Fix spaces table: rename public_directory to public_member_directory -alter table if exists public.spaces - rename column public_directory to public_member_directory; - --- Fix space_members table: use text fields instead of enums, add email/payment columns -alter table if exists public.space_members - add column if not exists email text, - add column if not exists payment_status text, - add column if not exists payment_note text, - add column if not exists last_paid_at timestamp with time zone; - --- Make user_id nullable for imported members who haven't signed up yet -alter table if exists public.space_members - alter column user_id drop not null; - --- Fix tasks table: add task_type text column for easier filtering -alter table if exists public.tasks - add column if not exists task_type text default 'chore', - add column if not exists progress integer default 0, - add column if not exists recurrence text, - add column if not exists area text, - add column if not exists assigned_to text; - --- Fix projects table: add missing columns -alter table if exists public.projects - add column if not exists progress integer default 0, - add column if not exists task_count integer default 0, - add column if not exists tags text[] default '{}', - add column if not exists category text, - add column if not exists assignees text, - add column if not exists due_date date, - add column if not exists description text; - --- Fix knowledge_base table -alter table if exists public.knowledge_base - add column if not exists pinned boolean default false, - add column if not exists area text, - add column if not exists access_level text default 'all_members', - add column if not exists description text, - add column if not exists updated_by text; - --- Fix area_leads table -alter table if exists public.area_leads - add column if not exists lead_handle text, - add column if not exists status text default 'active'; - --- Fix contacts table -alter table if exists public.contacts - add column if not exists note text, - add column if not exists group_label text; - --- Fix comms_channels: add member_count -alter table if exists public.comms_channels - add column if not exists member_count integer default 0, - add column if not exists unread_count integer default 0; - --- Seed default channels when space is created via trigger -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, description) values - (new.id, 'general', 'general', 'General discussion for all members'), - (new.id, 'announcements', 'general', 'Official announcements from board and admin'), - (new.id, 'random', 'general', 'Off-topic chatter'), - (new.id, 'facilities', 'ops', 'Facilities and maintenance discussion') - on conflict 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(); diff --git a/scripts/003_add_approved_column.sql b/scripts/003_add_approved_column.sql deleted file mode 100644 index 244630e..0000000 --- a/scripts/003_add_approved_column.sql +++ /dev/null @@ -1,9 +0,0 @@ --- Add approved column to space_members --- Used to distinguish members who have been approved by an admin vs pending -alter table if exists public.space_members - add column if not exists approved boolean default false; - --- Existing active members are considered approved -update public.space_members - set approved = true - where status = 'active'; diff --git a/scripts/003_signup_trigger.sql b/scripts/003_signup_trigger.sql deleted file mode 100644 index 802f21e..0000000 --- a/scripts/003_signup_trigger.sql +++ /dev/null @@ -1,85 +0,0 @@ --- ===================================================== --- This script creates a function and trigger to handle --- space creation/joining during signup using security definer --- bypassing RLS since user hasn't confirmed email yet --- ===================================================== - --- Function to handle creating a space and member during signup -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_space_name text; - v_space_slug text; - v_space_city text; - v_invite_code text; -begin - -- Get metadata from user signup - v_action := coalesce(new.raw_user_meta_data ->> 'space_action', ''); - - if v_action = 'create' then - v_space_name := new.raw_user_meta_data ->> 'space_name'; - v_space_slug := new.raw_user_meta_data ->> 'space_slug'; - v_space_city := new.raw_user_meta_data ->> 'space_city'; - v_invite_code := new.raw_user_meta_data ->> 'invite_code'; - - -- Create the space - insert into public.spaces (name, slug, city, invite_code) - values (v_space_name, v_space_slug, v_space_city, v_invite_code) - returning id into v_space_id; - - -- Add user as admin - insert into public.space_members (space_id, user_id, display_name, email, role, tier, status) - values ( - v_space_id, - new.id, - coalesce(new.raw_user_meta_data ->> 'full_name', 'Admin'), - new.email, - 'admin', - 'plus', - 'active' - ); - - -- Create default channels - insert into public.comms_channels (space_id, name, channel_type) values - (v_space_id, 'general', 'general'), - (v_space_id, 'announcements', 'general'), - (v_space_id, 'random', 'general'); - - elsif v_action = 'join' then - v_invite_code := new.raw_user_meta_data ->> 'join_invite_code'; - - -- Find space by 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 - -- Add user as member (pending if require_approval is true) - insert into public.space_members (space_id, user_id, display_name, email, role, tier, status) - 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 'pending' else 'active' end - from public.spaces s where s.id = v_space_id; - end if; - end if; - - return new; -end; -$$; - --- Create trigger -drop trigger if exists on_auth_user_space_signup on auth.users; - -create trigger on_auth_user_space_signup - after insert on auth.users - for each row - execute function public.handle_space_signup(); diff --git a/scripts/004_fix_rls_recursion.sql b/scripts/004_fix_rls_recursion.sql deleted file mode 100644 index 468d5f1..0000000 --- a/scripts/004_fix_rls_recursion.sql +++ /dev/null @@ -1,39 +0,0 @@ --- Fix infinite recursion in space_members RLS policy --- The original policy referenced space_members while evaluating a policy on space_members - --- Drop the problematic policy -drop policy if exists "space_members_select_own_space" on public.space_members; - --- Create a simpler policy that avoids self-reference --- Users can see their own record, plus all members in any space they belong to --- We use a subquery on a different approach to avoid recursion - -create policy "space_members_select_own" on public.space_members for select using ( - -- User can always see their own membership record - user_id = auth.uid() -); - --- Create a separate policy for viewing other members in the same space --- This uses a function to break the recursion cycle -create or replace function public.get_user_space_ids(uid uuid) -returns setof uuid -language sql -security definer -stable -as $$ - select space_id from public.space_members where user_id = uid -$$; - -create policy "space_members_select_same_space" on public.space_members for select using ( - space_id in (select public.get_user_space_ids(auth.uid())) -); - --- Also fix the insert policy to allow users to insert themselves into a space --- when joining via invite code (they won't have a record yet) -drop policy if exists "space_members_insert_authenticated" on public.space_members; - -create policy "space_members_insert_authenticated" on public.space_members for insert - with check ( - auth.uid() is not null - and user_id = auth.uid() - ); diff --git a/scripts/005_fix_spaces_insert.sql b/scripts/005_fix_spaces_insert.sql deleted file mode 100644 index 5348be6..0000000 --- a/scripts/005_fix_spaces_insert.sql +++ /dev/null @@ -1,20 +0,0 @@ --- Fix spaces INSERT policy to allow any authenticated user to create a space --- This is needed for the signup flow when a user creates their first space - --- Drop existing insert policy if it exists -drop policy if exists "spaces_insert_authenticated" on public.spaces; - --- Create a simple policy allowing any authenticated user to insert -create policy "spaces_insert_authenticated" on public.spaces - for insert - with check (auth.uid() is not null); - --- Also fix space_members INSERT policy to allow users to add themselves -drop policy if exists "space_members_insert_authenticated" on public.space_members; - -create policy "space_members_insert_authenticated" on public.space_members - for insert - with check ( - auth.uid() is not null - and user_id = auth.uid() - ); diff --git a/scripts/006_comprehensive_rls_fix.sql b/scripts/006_comprehensive_rls_fix.sql deleted file mode 100644 index bbc5b54..0000000 --- a/scripts/006_comprehensive_rls_fix.sql +++ /dev/null @@ -1,516 +0,0 @@ --- ===================================================== --- COMPREHENSIVE RLS FIX --- Fixes infinite recursion + enum cast issues --- ===================================================== - --- Helper: returns the space_ids the user belongs to (security definer avoids recursion) -create or replace function public.get_user_space_ids(uid uuid) -returns setof uuid -language sql -security definer -stable -set search_path = public -as $$ - select space_id from public.space_members where user_id = uid -$$; - --- Helper: checks if user has one of the given roles in a space --- NOTE: role column is a member_role enum, so we cast to text for comparison -create or replace function public.user_has_role_in_space(uid uuid, sid uuid, allowed_roles text[]) -returns boolean -language sql -security definer -stable -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) - ) -$$; - --- Grant execute permissions -grant execute on function public.get_user_space_ids(uuid) to authenticated, service_role; -grant execute on function public.user_has_role_in_space(uuid, uuid, text[]) to authenticated, service_role; - --- ===================================================== --- 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 public.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 (public.user_has_role_in_space(auth.uid(), id, array['admin'])); - -create policy "spaces_delete_admins" on public.spaces - for delete using (public.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_select_own_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 "space_members_select_own" on public.space_members - for select using (user_id = auth.uid()); - -create policy "space_members_select_same_space" on public.space_members - for select using (space_id in (select public.get_user_space_ids(auth.uid()))); - --- Any authenticated user can insert a row where user_id = themselves -create policy "space_members_insert_authenticated" on public.space_members - for insert with check (auth.uid() is not null and user_id = auth.uid()); - --- Users can update their own profile; admins can update anyone in their space -create policy "space_members_update_admins" on public.space_members - for update using ( - user_id = auth.uid() - or public.user_has_role_in_space(auth.uid(), space_id, array['admin']) - ); - -create policy "space_members_delete_admins" 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_members" on public.tasks - for select using (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 public.get_user_space_ids(auth.uid()))); - -create policy "tasks_update_members" on public.tasks - for update using (space_id in (select public.get_user_space_ids(auth.uid()))); - -create policy "tasks_delete_members" on public.tasks - for delete using (space_id in (select public.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 public.get_user_space_ids(auth.uid()))); - -create policy "projects_insert_members" on public.projects - for insert with check (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 public.get_user_space_ids(auth.uid()))); - -create policy "projects_delete_members" on public.projects - for delete using (space_id in (select public.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 public.get_user_space_ids(auth.uid()))); - -create policy "kb_insert_members" on public.knowledge_base - for insert with check (space_id in (select public.get_user_space_ids(auth.uid()))); - -create policy "kb_update_members" on public.knowledge_base - for update using (space_id in (select public.get_user_space_ids(auth.uid()))); - -create policy "kb_delete_board" on public.knowledge_base - for delete using (public.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; - -create policy "secrets_select_admins" on public.secrets - for select using ( - public.user_has_role_in_space(auth.uid(), space_id, array['admin', 'board', 'treasurer']) - ); - -create policy "secrets_modify_admins" on public.secrets - for all 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_members" on public.area_leads - for select using (space_id in (select public.get_user_space_ids(auth.uid()))); - -create policy "area_leads_modify_board" on public.area_leads - for all using (public.user_has_role_in_space(auth.uid(), space_id, array['admin', 'board'])); - --- ===================================================== --- 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 public.get_user_space_ids(auth.uid()))); - -create policy "contacts_modify_members" on public.contacts - for all using (space_id in (select public.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 ( - 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 ( - public.user_has_role_in_space(auth.uid(), space_id, array['admin', 'treasurer']) - ); - --- ===================================================== --- 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 public.get_user_space_ids(auth.uid()))); - -create policy "channels_insert_board" 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_board" on public.comms_channels - for update using (public.user_has_role_in_space(auth.uid(), space_id, array['admin', 'board'])); - -create policy "channels_delete_board" on public.comms_channels - for delete using (public.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 public.get_user_space_ids(auth.uid()))); - -create policy "messages_insert_members" on public.comms_messages - for insert with check ( - space_id in (select public.get_user_space_ids(auth.uid())) - and user_id = auth.uid() - ); - --- ===================================================== --- 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 (public.user_has_role_in_space(auth.uid(), space_id, array['admin'])); - -create policy "integrations_modify_admins" on public.integrations - for all using (public.user_has_role_in_space(auth.uid(), space_id, array['admin'])); - --- ===================================================== --- 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 public.get_user_space_ids(auth.uid()))); - -create policy "activity_insert_members" on public.activity_log - for insert with check (space_id in (select public.get_user_space_ids(auth.uid()))); - - --- ===================================================== --- 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; - -create policy "spaces_select_members" on public.spaces for select using ( - id in (select public.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 ( - public.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_select_own_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; - --- SELECT: Users can see their own record -create policy "space_members_select_own" on public.space_members for select using ( - user_id = auth.uid() -); - --- SELECT: Users can see all members in spaces they belong to -create policy "space_members_select_same_space" on public.space_members for select using ( - space_id in (select public.get_user_space_ids(auth.uid())) -); - --- INSERT: Authenticated users can add themselves to a space -create policy "space_members_insert_authenticated" on public.space_members for insert - with check (auth.uid() is not null and user_id = auth.uid()); - --- UPDATE: Admins can update any member, users can update themselves -create policy "space_members_update_admins" on public.space_members for update using ( - user_id = auth.uid() - or public.user_has_role_in_space(auth.uid(), space_id, array['admin']) -); - --- DELETE: Only admins can remove members -create policy "space_members_delete_admins" 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_members" on public.tasks for select using ( - 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 public.get_user_space_ids(auth.uid())) -); - -create policy "tasks_update_members" on public.tasks for update using ( - space_id in (select public.get_user_space_ids(auth.uid())) -); - -create policy "tasks_delete_members" on public.tasks for delete using ( - space_id in (select public.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 public.get_user_space_ids(auth.uid())) -); - -create policy "projects_insert_members" on public.projects for insert with check ( - 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 public.get_user_space_ids(auth.uid())) -); - -create policy "projects_delete_members" on public.projects for delete using ( - space_id in (select public.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 public.get_user_space_ids(auth.uid())) -); - -create policy "kb_insert_members" on public.knowledge_base for insert with check ( - space_id in (select public.get_user_space_ids(auth.uid())) -); - -create policy "kb_update_members" on public.knowledge_base for update using ( - space_id in (select public.get_user_space_ids(auth.uid())) -); - -create policy "kb_delete_board" on public.knowledge_base for delete using ( - public.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; - -create policy "secrets_select_admins" on public.secrets for select using ( - public.user_has_role_in_space(auth.uid(), space_id, array['admin', 'board', 'treasurer']) -); - -create policy "secrets_modify_admins" on public.secrets for all 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_members" on public.area_leads for select using ( - space_id in (select public.get_user_space_ids(auth.uid())) -); - -create policy "area_leads_modify_board" on public.area_leads for all using ( - public.user_has_role_in_space(auth.uid(), space_id, array['admin', 'board']) -); - --- ===================================================== --- 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 public.get_user_space_ids(auth.uid())) -); - -create policy "contacts_modify_members" on public.contacts for all using ( - space_id in (select public.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 ( - 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 ( - public.user_has_role_in_space(auth.uid(), space_id, array['admin', 'treasurer']) -); - --- ===================================================== --- 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 public.get_user_space_ids(auth.uid())) -); - -create policy "channels_insert_board" 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_board" on public.comms_channels for update using ( - public.user_has_role_in_space(auth.uid(), space_id, array['admin', 'board']) -); - -create policy "channels_delete_board" on public.comms_channels for delete using ( - public.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 public.get_user_space_ids(auth.uid())) -); - -create policy "messages_insert_members" on public.comms_messages for insert with check ( - space_id in (select public.get_user_space_ids(auth.uid())) - and user_id = auth.uid() -); - --- ===================================================== --- 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 ( - public.user_has_role_in_space(auth.uid(), space_id, array['admin']) -); - -create policy "integrations_modify_admins" on public.integrations for all using ( - public.user_has_role_in_space(auth.uid(), space_id, array['admin']) -); - --- ===================================================== --- 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 public.get_user_space_ids(auth.uid())) -); - -create policy "activity_insert_members" on public.activity_log for insert with check ( - space_id in (select public.get_user_space_ids(auth.uid())) -); - --- ===================================================== --- GRANT EXECUTE on helper functions to authenticated users --- ===================================================== -grant execute on function public.get_user_space_ids(uuid) to authenticated; -grant execute on function public.user_has_role_in_space(uuid, uuid, text[]) to authenticated; diff --git a/scripts/007_schema_audit_fixes.sql b/scripts/007_schema_audit_fixes.sql deleted file mode 100644 index a1bac43..0000000 --- a/scripts/007_schema_audit_fixes.sql +++ /dev/null @@ -1,62 +0,0 @@ --- ===================================================== --- 007: Fix schema + RLS issues found in full audit --- ===================================================== - --- 1. Fix task_status enum — add 'done' and 'completed' alias --- The code uses 'done' but enum only has 'completed' --- Safest: add 'done' to the enum -ALTER TYPE task_status ADD VALUE IF NOT EXISTS 'done'; -ALTER TYPE task_status ADD VALUE IF NOT EXISTS 'blocked'; - --- 2. Fix project_status enum — add 'blocked' for dashboard display -ALTER TYPE project_status ADD VALUE IF NOT EXISTS 'blocked'; - --- 3. Add missing columns to contacts -ALTER TABLE public.contacts - ADD COLUMN IF NOT EXISTS note text, - ADD COLUMN IF NOT EXISTS group_label text; - --- 4. Fix integrations unique constraint --- Current: platform is globally unique (wrong — each space should have its own) --- Drop the global unique and add a composite unique -ALTER TABLE public.integrations DROP CONSTRAINT IF EXISTS integrations_platform_key; -ALTER TABLE public.integrations - ADD CONSTRAINT integrations_space_platform_unique UNIQUE (space_id, platform); - --- 5. Fix area_leads unique constraint (needed for upsert onConflict) -ALTER TABLE public.area_leads - ADD CONSTRAINT area_leads_space_area_unique UNIQUE (space_id, area_code); - --- 6. Fix space_members_insert_authenticated — allow admins to add members without user accounts --- (null user_id is valid for manually-added members) -DROP POLICY IF EXISTS "space_members_insert_authenticated" ON public.space_members; -CREATE POLICY "space_members_insert_authenticated" ON public.space_members - FOR INSERT WITH CHECK ( - -- User can add themselves - (auth.uid() IS NOT NULL AND user_id = auth.uid()) - OR - -- Admin can add a member record (possibly without user_id) to their own space - ( - auth.uid() IS NOT NULL AND (user_id IS NULL OR user_id != auth.uid()) AND - EXISTS ( - SELECT 1 FROM public.space_members sm - WHERE sm.user_id = auth.uid() - AND sm.space_id = space_members.space_id - AND sm.role::text IN ('admin', 'board') - ) - ) - ); - --- 7. Make sure activity_log allows service_role inserts (for triggers) -GRANT ALL ON public.activity_log TO service_role; -GRANT ALL ON public.space_members TO service_role; -GRANT ALL ON public.spaces TO service_role; - --- 8. Add progress column to projects if missing -ALTER TABLE public.projects - ADD COLUMN IF NOT EXISTS progress integer DEFAULT 0; - --- 9. Add name alias for projects (code uses project.name but column is title) --- Add a generated column as alias -ALTER TABLE public.projects - ADD COLUMN IF NOT EXISTS name text GENERATED ALWAYS AS (title) STORED; diff --git a/scripts/008_fix_approved_default.sql b/scripts/008_fix_approved_default.sql deleted file mode 100644 index 2a122aa..0000000 --- a/scripts/008_fix_approved_default.sql +++ /dev/null @@ -1,12 +0,0 @@ --- 008: Set approved default to true so new members don't get locked out --- The approved column was added without a default, meaning it defaults to NULL. --- Queries filtering eq('approved', true) would silently return zero rows. --- Fix: set the default to true so any insert that doesn't specify it gets approved automatically. - -ALTER TABLE public.space_members - ALTER COLUMN approved SET DEFAULT true; - --- Also backfill existing members who have null approved -UPDATE public.space_members - SET approved = true - WHERE approved IS NULL; diff --git a/scripts/009_fix_member_insert_rls.sql b/scripts/009_fix_member_insert_rls.sql deleted file mode 100644 index 322e486..0000000 --- a/scripts/009_fix_member_insert_rls.sql +++ /dev/null @@ -1,32 +0,0 @@ --- Fix 1: Allow admins to insert space_members records without requiring user_id = auth.uid() --- This is needed for: addMember (offline/manual member add), importMembers (CSV bulk import) --- The current policy only allows self-registration (user_id = auth.uid()) --- We need to also allow board/admin members of the same space to insert - --- Drop the restrictive insert policy -DROP POLICY IF EXISTS space_members_insert_authenticated ON public.space_members; - --- New insert policy: allow self-registration OR admin/board inserting into their own space --- Cast role and status to text to avoid enum comparison errors -CREATE POLICY space_members_insert_authenticated ON public.space_members - FOR INSERT - TO authenticated - WITH CHECK ( - -- Self registration: user is inserting their own record - user_id = auth.uid() - OR - -- Admin/board adding a member to their space (offline member, no user_id yet) - public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin', 'board']) - ); - --- Fix 2: Add unique constraint on space_members(space_id, email) for importMembers upsert --- Only add if not already present -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conname = 'space_members_space_id_email_key' - ) THEN - ALTER TABLE public.space_members ADD CONSTRAINT space_members_space_id_email_key UNIQUE (space_id, email); - END IF; -END $$; diff --git a/scripts/010_fix_channel_trigger.sql b/scripts/010_fix_channel_trigger.sql deleted file mode 100644 index 9fc213b..0000000 --- a/scripts/010_fix_channel_trigger.sql +++ /dev/null @@ -1,27 +0,0 @@ --- Fix the create_default_channels trigger to not use description column --- The comms_channels table does NOT have a description column - -create or replace function public.create_default_channels() -returns trigger -language plpgsql -security definer -set search_path = public -as $$ -begin - -- Insert default channels without description (column doesn't exist) - 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 do nothing; - return new; -end; -$$; - --- Recreate the trigger -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(); diff --git a/scripts/011_fix_member_status_enum.sql b/scripts/011_fix_member_status_enum.sql deleted file mode 100644 index fbb16af..0000000 --- a/scripts/011_fix_member_status_enum.sql +++ /dev/null @@ -1,93 +0,0 @@ --- ===================================================== --- 011: FIX ALL ENUM MISMATCHES --- Ground truth from live DB: --- member_status = current, late, inactive, unverified --- (NO 'active', NO 'pending') --- --- mapping: --- 'active' -> 'current' --- 'pending' -> 'unverified' (awaiting approval) --- 'inactive'-> 'inactive' (same) --- ===================================================== - --- 1. Fix handle_space_signup trigger to use correct enum values -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_space_name text; - v_space_slug text; - v_space_city text; - v_invite_code text; -BEGIN - v_action := coalesce(new.raw_user_meta_data ->> 'space_action', ''); - - IF v_action = 'create' THEN - v_space_name := new.raw_user_meta_data ->> 'space_name'; - v_space_slug := new.raw_user_meta_data ->> 'space_slug'; - v_space_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_space_name, v_space_slug, v_space_city, v_invite_code) - RETURNING id INTO v_space_id; - - -- 'current' is the correct active member_status value - 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', - -- 'unverified' = awaiting approval, 'current' = directly active - 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; -$$; - --- 2. Fix the RLS helper to not filter on status at all (any member of the space, regardless of status) --- This ensures even unverified members can at least log in and see their space -CREATE OR REPLACE FUNCTION public.get_user_space_ids(uid uuid) -RETURNS SETOF uuid -LANGUAGE sql -SECURITY DEFINER -STABLE -SET search_path = 'public' -AS $$ - SELECT space_id FROM public.space_members WHERE user_id = uid -$$; - --- 3. Backfill any existing 'active' or 'pending' values that are stuck --- These were inserted by old buggy code -UPDATE public.space_members SET status = 'current'::member_status - WHERE status::text = 'active'; diff --git a/scripts/012_canonical_sync.sql b/scripts/012_canonical_sync.sql deleted file mode 100644 index a013ce8..0000000 --- a/scripts/012_canonical_sync.sql +++ /dev/null @@ -1,193 +0,0 @@ --- Migration 012: Sync existing databases to canonical schema --- Run this on any existing Hackerspace deployment to align with the canonical schema.sql --- Safe to run multiple times (idempotent). --- --- Changes: --- • Rename space_members.full_name → display_name (to match app code everywhere) --- • Drop redundant tasks.type text column (canonical column is task_type enum) --- • Add missing columns to all tables that the app code expects --- • Create missing unique constraints for upserts --- • Update handle_space_signup trigger to match canonical version - --- ============================================================ --- RENAME COLUMNS --- ============================================================ -ALTER TABLE public.space_members RENAME COLUMN full_name TO display_name; -ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS handle text; -ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS bio text; -ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS avatar_url text; -ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS dues_paid_until timestamptz; -ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS stripe_customer_id text; -ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS has_card_access boolean NOT NULL DEFAULT false; -ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS payment_status text; -ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS payment_note text; -ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS last_payment_at timestamptz; -ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); - --- ============================================================ --- TASKS TABLE FIXES --- ============================================================ --- Drop the redundant text 'type' column if it exists (canonical column is task_type enum) -ALTER TABLE public.tasks DROP COLUMN IF EXISTS type CASCADE; - --- Ensure all canonical task columns exist -ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS priority text NOT NULL DEFAULT 'medium'; -ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS requested_by uuid REFERENCES auth.users(id) ON DELETE SET NULL; -ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS requested_by_name text; -ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS claimed_by_name text; -ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS assigned_to_name text; -ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS completed_at timestamptz; -ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS last_done_at timestamptz; -ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS subtask_total integer NOT NULL DEFAULT 0; -ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS subtask_completed integer NOT NULL DEFAULT 0; -ALTER TABLE public.tasks ADD COLUMN IF NOT EXISTS progress integer NOT NULL DEFAULT 0; - --- ============================================================ --- SPACES TABLE --- ============================================================ -ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS city text; -ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS address text; -ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS timezone text DEFAULT 'America/Phoenix'; -ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS invite_code text; -ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS require_approval boolean NOT NULL DEFAULT true; -ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS public_member_directory boolean NOT NULL DEFAULT false; -ALTER TABLE public.spaces ADD COLUMN IF NOT EXISTS webhook_secret text; - --- ============================================================ --- PAYMENTS TABLE --- ============================================================ -ALTER TABLE public.payments ADD COLUMN IF NOT EXISTS from_identifier text; -ALTER TABLE public.payments ADD COLUMN IF NOT EXISTS from_note text; -ALTER TABLE public.payments ADD COLUMN IF NOT EXISTS member_name text; -ALTER TABLE public.payments ADD COLUMN IF NOT EXISTS link_status public.payment_link_status NOT NULL DEFAULT 'unlinked'; -ALTER TABLE public.payments ADD COLUMN IF NOT EXISTS transaction_date timestamptz DEFAULT now(); - --- ============================================================ --- CONTACTS TABLE --- ============================================================ -ALTER TABLE public.contacts ADD COLUMN IF NOT EXISTS code text; -ALTER TABLE public.contacts ADD COLUMN IF NOT EXISTS details text; -ALTER TABLE public.contacts ADD COLUMN IF NOT EXISTS note text; -ALTER TABLE public.contacts ADD COLUMN IF NOT EXISTS group_label text; - --- ============================================================ --- KNOWLEDGE BASE --- ============================================================ -ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS icon text; -ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS access_level text DEFAULT 'all_members'; -ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS area text; -ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS is_pinned boolean NOT NULL DEFAULT false; -ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS updated_by_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL; -ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS updated_by_name text; -ALTER TABLE public.knowledge_base ADD COLUMN IF NOT EXISTS updated_by text; - --- ============================================================ --- SECRETS TABLE --- ============================================================ -ALTER TABLE public.secrets ADD COLUMN IF NOT EXISTS icon text; -ALTER TABLE public.secrets ADD COLUMN IF NOT EXISTS description text; -ALTER TABLE public.secrets ADD COLUMN IF NOT EXISTS area text; -ALTER TABLE public.secrets ADD COLUMN IF NOT EXISTS title text; --- Backfill title from label where label exists and title is null -UPDATE public.secrets SET title = label WHERE title IS NULL AND label IS NOT NULL; - --- ============================================================ --- AREA LEADS TABLE --- ============================================================ -ALTER TABLE public.area_leads ADD COLUMN IF NOT EXISTS area_code text; -ALTER TABLE public.area_leads ADD COLUMN IF NOT EXISTS lead_handle text; - --- Add unique constraint on (space_id, area_code) if not already present -DO $$ -BEGIN - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conname = 'area_leads_space_area_code_key' - ) THEN - ALTER TABLE public.area_leads ADD CONSTRAINT area_leads_space_area_code_key - UNIQUE (space_id, area_code) DEFERRABLE INITIALLY DEFERRED; - END IF; -END $$; - --- ============================================================ --- INTEGRATIONS TABLE --- ============================================================ -ALTER TABLE public.integrations ADD COLUMN IF NOT EXISTS name text; -ALTER TABLE public.integrations ADD COLUMN IF NOT EXISTS description text; -ALTER TABLE public.integrations ADD COLUMN IF NOT EXISTS config jsonb; - --- ============================================================ --- COMMS CHANNELS --- ============================================================ -ALTER TABLE public.comms_channels ADD COLUMN IF NOT EXISTS icon text; -ALTER TABLE public.comms_channels ADD COLUMN IF NOT EXISTS area_reference text; -ALTER TABLE public.comms_channels ADD COLUMN IF NOT EXISTS member_count integer NOT NULL DEFAULT 0; -ALTER TABLE public.comms_channels ADD COLUMN IF NOT EXISTS unread_count integer NOT NULL DEFAULT 0; - --- ============================================================ --- COMMS MESSAGES --- ============================================================ -ALTER TABLE public.comms_messages ADD COLUMN IF NOT EXISTS display_name text; -ALTER TABLE public.comms_messages ADD COLUMN IF NOT EXISTS handle text; - --- ============================================================ --- ACTIVITY LOG --- ============================================================ -ALTER TABLE public.activity_log ADD COLUMN IF NOT EXISTS display_name text; -ALTER TABLE public.activity_log ADD COLUMN IF NOT EXISTS details text; - --- ============================================================ --- PROJECTS TABLE --- ============================================================ -ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS name text; -ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS area text; -ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS category text; -ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS assignees text; -ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS assignee_names text[]; -ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS task_count integer NOT NULL DEFAULT 0; -ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS tasks_completed integer NOT NULL DEFAULT 0; -ALTER TABLE public.projects ADD COLUMN IF NOT EXISTS progress integer NOT NULL DEFAULT 0; - --- ============================================================ --- UNIQUE INDEXES / CONSTRAINTS --- ============================================================ --- space_members: unique index on (space_id, email) for upsert in importMembers -CREATE UNIQUE INDEX IF NOT EXISTS idx_space_members_space_email - ON public.space_members (space_id, email) - WHERE email IS NOT NULL; - --- ============================================================ --- UPDATE TRIGGERS --- ============================================================ --- Fix handle_space_signup to match canonical version -CREATE OR REPLACE FUNCTION public.handle_space_signup() - RETURNS trigger LANGUAGE plpgsql SECURITY DEFINER - SET search_path = public -AS $$ -DECLARE - v_space_id uuid; - v_role public.member_role; - v_display_name text; -BEGIN - v_space_id := (NEW.raw_user_meta_data->>'space_id')::uuid; - v_role := COALESCE( - NULLIF((NEW.raw_user_meta_data->>'role'), '')::public.member_role, - 'member' - ); - v_display_name := COALESCE( - NULLIF(NEW.raw_user_meta_data->>'display_name', ''), - NULLIF(NEW.raw_user_meta_data->>'full_name', ''), - NEW.email - ); - - IF v_space_id IS NULL THEN - RETURN NEW; - END IF; - - INSERT INTO public.space_members (space_id, user_id, role, status, display_name, email, approved) - VALUES (v_space_id, NEW.id, v_role, 'unverified', v_display_name, NEW.email, true) - ON CONFLICT (space_id, user_id) DO NOTHING; - - RETURN NEW; -END; -$$; diff --git a/scripts/schema.sql b/scripts/schema.sql index 897ea5c..f162973 100644 --- a/scripts/schema.sql +++ b/scripts/schema.sql @@ -1,49 +1,50 @@ -- ============================================================================= --- HACKERSPACE.SH — CANONICAL SCHEMA (single source of truth) +-- HACKERSPACE MANAGEMENT — CANONICAL SCHEMA -- ============================================================================= --- Run this file ONCE on a brand-new Supabase project. --- It is fully idempotent: safe to run again if interrupted. +-- 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. -- --- Execution order: +-- Sections: -- 1. Extensions -- 2. Enums --- 3. Tables (spaces → space_members → everything else) --- 4. Unique constraints & indexes --- 5. Helper functions (reference space_members → must come after tables) +-- 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 $$; +-- ----------------------------------------------------------------------------- +DO $$ BEGIN CREATE TYPE public.member_role AS ENUM ('admin','board','treasurer','member','associate'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE TYPE public.member_tier AS ENUM ('plus','basic','associate'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE TYPE public.member_status AS ENUM ('current','late','inactive','unverified'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE TYPE public.task_status AS ENUM ('open','claimed','in_progress','overdue','due_today','completed','done','blocked'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE TYPE public.task_type AS ENUM ('chore','task'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE TYPE public.recurrence_type AS ENUM ('daily','weekly','biweekly','monthly','none'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE TYPE public.project_status AS ENUM ('backlog','in_progress','review','done','blocked'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE TYPE public.payment_platform AS ENUM ('paypal','zeffy','venmo','cash'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE TYPE public.payment_link_status AS ENUM ('linked','unlinked'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE TYPE public.kb_visibility AS ENUM ('all_members','board','admin_only'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE TYPE public.area_lead_status AS ENUM ('active','vacant','handoff'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE TYPE public.contact_type AS ENUM ('vendor','supplier','partner','landlord','city'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; +DO $$ BEGIN CREATE TYPE public.channel_type AS ENUM ('general','area','ops','project'); EXCEPTION WHEN duplicate_object THEN NULL; END $$; --- ───────────────────────────────────────────────────────────────────────────── --- 3. TABLES (dependency order) --- ───────────────────────────────────────────────────────────────────────────── +-- ----------------------------------------------------------------------------- +-- 3. TABLES +-- ----------------------------------------------------------------------------- --- ── spaces ─────────────────────────────────────────────────────────────────── +-- spaces ---------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS public.spaces ( id uuid PRIMARY KEY DEFAULT uuid_generate_v4(), name text NOT NULL, @@ -52,24 +53,21 @@ CREATE TABLE IF NOT EXISTS public.spaces ( logo_url text, city text, address text, - timezone text NOT NULL DEFAULT 'America/Phoenix', + timezone text DEFAULT 'America/Phoenix', invite_code text, - require_approval boolean NOT NULL DEFAULT true, - public_member_directory boolean NOT NULL DEFAULT false, + require_approval boolean DEFAULT true, + public_member_directory boolean DEFAULT false, webhook_secret text, - settings jsonb NOT NULL DEFAULT '{}', + settings jsonb DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); --- ── space_members ───────────────────────────────────────────────────────────── --- One row per (user × space). --- display_name — the member's chosen name within this space --- handle — optional @handle (unique within a space is enforced by app, not DB) +-- 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 REFERENCES auth.users(id) ON DELETE SET NULL, + 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', @@ -83,8 +81,9 @@ CREATE TABLE IF NOT EXISTS public.space_members ( 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(), + joined_at timestamptz DEFAULT now(), dues_paid_until timestamptz, stripe_customer_id text, created_at timestamptz NOT NULL DEFAULT now(), @@ -92,44 +91,55 @@ CREATE TABLE IF NOT EXISTS public.space_members ( UNIQUE (space_id, user_id) ); --- ── projects ───────────────────────────────────────────────────────────────── --- Must be created before tasks (tasks.project_id FK). +-- 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, + 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, - status public.project_status NOT NULL DEFAULT 'backlog', area text, - tags 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, - 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() + 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 ──────────────────────────────────────────────────────────────────── --- task_type — the enum column (canonical) --- status — task lifecycle; both 'completed' and 'done' mean finished +-- 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, + 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', + recurrence public.recurrence_type DEFAULT 'none', + priority text DEFAULT 'medium', area text, - project_id uuid REFERENCES public.projects(id) ON DELETE SET NULL, 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, @@ -137,144 +147,167 @@ CREATE TABLE IF NOT EXISTS public.tasks ( progress integer NOT NULL DEFAULT 0, last_done_at timestamptz, completed_at timestamptz, - created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, created_at timestamptz NOT NULL DEFAULT now(), updated_at timestamptz NOT NULL DEFAULT now() ); --- ── payments ───────────────────────────────────────────────────────────────── --- from_identifier — email / @handle / name from source platform --- from_note — memo / note from sender --- link_status — whether this payment has been matched to a member +-- 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, + 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_id uuid REFERENCES public.space_members(id) ON DELETE SET NULL, member_name text, - link_status public.payment_link_status NOT NULL DEFAULT 'unlinked', - transaction_date timestamptz NOT NULL DEFAULT now(), - created_at timestamptz NOT NULL DEFAULT now() + 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 ───────────────────────────────────────────────────────────────── --- code — short auto-generated identifier (e.g. "VEN234") +-- 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, - contact_type public.contact_type NOT NULL DEFAULT 'vendor', + 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_at timestamptz NOT NULL DEFAULT now(), - updated_at timestamptz NOT NULL DEFAULT now() + 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 ──────────────────────────────────────────────────────────── --- is_pinned — canonical pinned flag --- updated_by_id — FK to space_members (for display in UI) --- updated_by_name — denormalized display name for performance +-- 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 public.kb_visibility NOT NULL DEFAULT 'all_members', + 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 ────────────────────────────────────────────────────────────────── --- Shared credentials vault. Admin write, board+ read. --- value stored as plain text — encryption is a future enhancement. +-- 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, - title text NOT NULL, - icon text, - description text, + -- 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 must be unique within a space (used as upsert conflict target). +-- 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 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, + 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(), - UNIQUE (space_id, area_code) + 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 ───────────────────────────────────────────────────────────── --- config JSONB holds platform-specific credentials (e.g. client_id, token). --- platform must be unique within a space (used as upsert conflict target). +-- 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, - name text NOT NULL, 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 ─────────────────────────────────────────────────────────── --- name must be unique within a space. +-- 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, + 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, - 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) + 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 ─────────────────────────────────────────────────────────── +-- 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, + 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, - created_at timestamptz NOT NULL DEFAULT now() + 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 ───────────────────────────────────────────────────────────── --- Append-only audit log. No UPDATE/DELETE policies. +-- 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, @@ -284,50 +317,48 @@ CREATE TABLE IF NOT EXISTS public.activity_log ( entity_type text, entity_id uuid, details text, + metadata jsonb DEFAULT '{}', created_at timestamptz NOT NULL DEFAULT now() ); --- ───────────────────────────────────────────────────────────────────────────── --- 4. UNIQUE CONSTRAINTS & INDEXES --- ───────────────────────────────────────────────────────────────────────────── - --- Partial unique index: (space_id, email) only where email IS NOT NULL --- Required by importMembers upsert in actions.ts -CREATE UNIQUE INDEX IF NOT EXISTS idx_space_members_space_email - ON public.space_members (space_id, email) - WHERE email IS NOT NULL; - --- Performance indexes -CREATE INDEX IF NOT EXISTS idx_space_members_user_id ON public.space_members (user_id); -CREATE INDEX IF NOT EXISTS idx_space_members_space_id ON public.space_members (space_id); -CREATE INDEX IF NOT EXISTS idx_tasks_space_id ON public.tasks (space_id); -CREATE INDEX IF NOT EXISTS idx_tasks_status ON public.tasks (status); -CREATE INDEX IF NOT EXISTS idx_tasks_claimed_by ON public.tasks (claimed_by); -CREATE INDEX IF NOT EXISTS idx_tasks_assigned_to ON public.tasks (assigned_to); -CREATE INDEX IF NOT EXISTS idx_projects_space_id ON public.projects (space_id); -CREATE INDEX IF NOT EXISTS idx_payments_space_id ON public.payments (space_id); -CREATE INDEX IF NOT EXISTS idx_payments_link_status ON public.payments (link_status); -CREATE INDEX IF NOT EXISTS idx_payments_transaction_date ON public.payments (transaction_date DESC); -CREATE INDEX IF NOT EXISTS idx_kb_space_id ON public.knowledge_base (space_id); -CREATE INDEX IF NOT EXISTS idx_secrets_space_id ON public.secrets (space_id); -CREATE INDEX IF NOT EXISTS idx_contacts_space_id ON public.contacts (space_id); -CREATE INDEX IF NOT EXISTS idx_area_leads_space_id ON public.area_leads (space_id); -CREATE INDEX IF NOT EXISTS idx_integrations_space_id ON public.integrations (space_id); -CREATE INDEX IF NOT EXISTS idx_comms_messages_channel ON public.comms_messages (channel_id, created_at DESC); -CREATE INDEX IF NOT EXISTS idx_comms_messages_space ON public.comms_messages (space_id); -CREATE INDEX IF NOT EXISTS idx_activity_log_space_id ON public.activity_log (space_id); -CREATE INDEX IF NOT EXISTS idx_activity_log_created_at ON public.activity_log (created_at DESC); +-- ----------------------------------------------------------------------------- +-- 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 because they reference space_members. --- Both are SECURITY DEFINER with fixed search_path. --- ───────────────────────────────────────────────────────────────────────────── +-- Must come after tables — they reference public.space_members. +-- SECURITY DEFINER with fixed search_path prevents privilege escalation. +-- ----------------------------------------------------------------------------- --- Returns all space_ids a given user belongs to. --- Used in RLS SELECT policies to avoid recursive policy evaluation. +-- 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 @@ -336,8 +367,7 @@ 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. +-- 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 @@ -345,21 +375,16 @@ CREATE OR REPLACE FUNCTION public.user_has_role_in_space(uid uuid, sid uuid, all AS $$ SELECT EXISTS ( SELECT 1 FROM public.space_members - WHERE user_id = uid AND space_id = sid AND role::text = ANY(allowed_roles) + WHERE user_id = uid + AND space_id = sid + AND role::text = ANY(allowed_roles) ); $$; --- ───────────────────────────────────────────────────────────────────────────── +-- ----------------------------------------------------------------------------- -- 6. ROW LEVEL SECURITY --- ───────────────────────────────────────────────────────────────────────────── --- Pattern: --- SELECT → any space member --- INSERT → any space member, or elevated role depending on sensitivity --- UPDATE → space member (own row) or elevated role --- DELETE → admin/board only --- ───────────────────────────────────────────────────────────────────────────── - +-- ----------------------------------------------------------------------------- ALTER TABLE public.spaces ENABLE ROW LEVEL SECURITY; ALTER TABLE public.space_members ENABLE ROW LEVEL SECURITY; ALTER TABLE public.tasks ENABLE ROW LEVEL SECURITY; @@ -374,90 +399,73 @@ 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()))); +-- 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'])); -CREATE POLICY "spaces_insert" ON public.spaces - FOR INSERT WITH CHECK (true); -- auth hook creates the space; server-side admin client used - -CREATE POLICY "spaces_update" ON public.spaces - FOR UPDATE USING (public.user_has_role_in_space(auth.uid(), id, ARRAY['admin','board'])); - --- ── space_members ───────────────────────────────────────────────────────────── -CREATE POLICY "members_select" ON public.space_members - FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); - -CREATE POLICY "members_insert" ON public.space_members - FOR INSERT WITH CHECK ( +-- 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 ( +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'])); -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'])); --- ── tasks ──────────────────────────────────────────────────────────────────── -CREATE POLICY "tasks_select" ON public.tasks - FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); +-- 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'])); -CREATE POLICY "tasks_insert" ON public.tasks - FOR INSERT WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); +-- 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'])); -CREATE POLICY "tasks_update" ON public.tasks - FOR UPDATE USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); +-- 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'])); -CREATE POLICY "tasks_delete" ON public.tasks - FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])); - --- ── projects ───────────────────────────────────────────────────────────────── -CREATE POLICY "projects_select" ON public.projects - FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); - -CREATE POLICY "projects_insert" ON public.projects - FOR INSERT WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); - -CREATE POLICY "projects_update" ON public.projects - FOR UPDATE USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); - -CREATE POLICY "projects_delete" ON public.projects - FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])); - --- ── payments ───────────────────────────────────────────────────────────────── -CREATE POLICY "payments_select" ON public.payments - FOR SELECT USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])); - -CREATE POLICY "payments_insert" ON public.payments - FOR INSERT WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])); - -CREATE POLICY "payments_update" ON public.payments - FOR UPDATE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])); - -CREATE POLICY "payments_delete" ON public.payments - FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','treasurer'])); - --- ── contacts ───────────────────────────────────────────────────────────────── -CREATE POLICY "contacts_select" ON public.contacts - FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); - -CREATE POLICY "contacts_insert" ON public.contacts - FOR INSERT WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])); - -CREATE POLICY "contacts_update" ON public.contacts - FOR UPDATE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])); - -CREATE POLICY "contacts_delete" ON public.contacts - FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])); - --- ── knowledge_base ──────────────────────────────────────────────────────────── -CREATE POLICY "kb_select" ON public.knowledge_base - FOR SELECT USING ( +-- 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' @@ -465,113 +473,95 @@ CREATE POLICY "kb_select" ON public.knowledge_base 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'])); -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'])); +-- 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'])); -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'])); +-- 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'])); -CREATE POLICY "kb_delete" ON public.knowledge_base - FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])); +-- 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'])); --- ── secrets ────────────────────────────────────────────────────────────────── -CREATE POLICY "secrets_select" ON public.secrets - FOR SELECT USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])); +-- 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'])); -CREATE POLICY "secrets_insert" ON public.secrets - FOR INSERT WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])); - -CREATE POLICY "secrets_update" ON public.secrets - FOR UPDATE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])); - -CREATE POLICY "secrets_delete" ON public.secrets - FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin'])); - --- ── area_leads ─────────────────────────────────────────────────────────────── -CREATE POLICY "area_leads_select" ON public.area_leads - FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); - -CREATE POLICY "area_leads_insert" ON public.area_leads - FOR INSERT WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])); - -CREATE POLICY "area_leads_update" ON public.area_leads - FOR UPDATE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])); - -CREATE POLICY "area_leads_delete" ON public.area_leads - FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin'])); - --- ── integrations ───────────────────────────────────────────────────────────── -CREATE POLICY "integrations_select" ON public.integrations - FOR SELECT USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board','treasurer'])); - -CREATE POLICY "integrations_insert" ON public.integrations - FOR INSERT WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin'])); - -CREATE POLICY "integrations_update" ON public.integrations - FOR UPDATE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin'])); - -CREATE POLICY "integrations_delete" ON public.integrations - FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin'])); - --- ── comms_channels ─────────────────────────────────────────────────────────── -CREATE POLICY "channels_select" ON public.comms_channels - FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); - -CREATE POLICY "channels_insert" ON public.comms_channels - FOR INSERT WITH CHECK (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])); - -CREATE POLICY "channels_update" ON public.comms_channels - FOR UPDATE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])); - -CREATE POLICY "channels_delete" ON public.comms_channels - FOR DELETE USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin'])); - --- ── comms_messages ─────────────────────────────────────────────────────────── -CREATE POLICY "messages_select" ON public.comms_messages - FOR SELECT USING (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); - -CREATE POLICY "messages_insert" ON public.comms_messages - FOR INSERT WITH CHECK ( +-- 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 ( +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']) ); -CREATE POLICY "messages_delete" ON public.comms_messages - FOR DELETE USING ( - user_id = auth.uid() - OR public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board']) - ); - --- ── activity_log ───────────────────────────────────────────────────────────── -CREATE POLICY "activity_select" ON public.activity_log - FOR SELECT USING (public.user_has_role_in_space(auth.uid(), space_id, ARRAY['admin','board'])); - -CREATE POLICY "activity_insert" ON public.activity_log - FOR INSERT WITH CHECK (space_id IN (SELECT public.get_user_space_ids(auth.uid()))); +-- 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 --- ───────────────────────────────────────────────────────────────────────────── +-- ----------------------------------------------------------------------------- --- Create default comms channels when a new space is created. +-- Auto-create default 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, icon, channel_type) + INSERT INTO public.comms_channels (space_id, name, description, channel_type, is_default) VALUES - (NEW.id, 'general', '💬', 'general'), - (NEW.id, 'announcements', '📢', 'general'), - (NEW.id, 'ops', '⚙️', 'ops'); + (NEW.id, 'general', 'General discussion', 'general', true), + (NEW.id, 'announcements', 'Important announcements', 'general', false), + (NEW.id, 'ops', 'Operations & logistics', 'ops', false); RETURN NEW; END; $$; @@ -581,37 +571,44 @@ CREATE TRIGGER on_space_created AFTER INSERT ON public.spaces FOR EACH ROW EXECUTE FUNCTION public.create_default_channels(); --- Auth hook: create a space_member row when a new user signs up. --- Reads metadata fields: space_id, role, full_name / display_name. --- NOTE: The app's signup flow calls createSpace/joinSpace server actions --- directly after signUp() returns a session (email confirm disabled). --- This trigger is a safety net for when email confirmation IS enabled. +-- 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 + RETURNS trigger + LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE - v_space_id uuid; - v_role public.member_role; + v_space_id uuid; + v_role public.member_role; v_display_name text; BEGIN - v_space_id := (NEW.raw_user_meta_data->>'space_id')::uuid; - v_role := COALESCE( - NULLIF((NEW.raw_user_meta_data->>'role'), '')::public.member_role, - 'member' - ); - v_display_name := COALESCE( - NULLIF(NEW.raw_user_meta_data->>'display_name', ''), - NULLIF(NEW.raw_user_meta_data->>'full_name', ''), - NEW.email - ); + 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) + 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; @@ -624,9 +621,8 @@ CREATE TRIGGER on_auth_user_created FOR EACH ROW EXECUTE FUNCTION public.handle_space_signup(); --- ───────────────────────────────────────────────────────────────────────────── +-- ----------------------------------------------------------------------------- -- 8. REALTIME --- ───────────────────────────────────────────────────────────────────────────── --- Enable realtime for live chat. +-- ----------------------------------------------------------------------------- ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_messages; ALTER PUBLICATION supabase_realtime ADD TABLE public.comms_channels; From 3192897a23f5527dfa585f8067d99410fa114368 Mon Sep 17 00:00:00 2001 From: v0 Date: Wed, 11 Mar 2026 06:48:52 +0000 Subject: [PATCH 5/6] refactor: update schema.sql to match live DB Replace old scripts with a single definitive schema.sql file. Co-authored-by: Moheeb Zara <1184415+virgilvox@users.noreply.github.com> --- scripts/013_add_last_paid_at.sql | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 scripts/013_add_last_paid_at.sql diff --git a/scripts/013_add_last_paid_at.sql b/scripts/013_add_last_paid_at.sql new file mode 100644 index 0000000..af1c4de --- /dev/null +++ b/scripts/013_add_last_paid_at.sql @@ -0,0 +1,10 @@ +-- ============================================================================= +-- Migration 013: Add space_members.last_paid_at +-- ============================================================================= +-- Adds last_paid_at column to space_members. +-- This column is written by importMembers() and linkPaymentToMember() in +-- lib/actions.ts. The column last_payment_at already existed as a legacy +-- field; last_paid_at is the canonical name used by the application code. +-- ============================================================================= + +ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS last_paid_at timestamptz; From 6cce622536c115161fc13d714b5976c0966ce8b8 Mon Sep 17 00:00:00 2001 From: v0 Date: Wed, 11 Mar 2026 06:51:06 +0000 Subject: [PATCH 6/6] chore: reset Supabase database and disable email confirmation Reset database and run clean schema script; disable email confirmation manually in Supabase Dashboard. Co-authored-by: Moheeb Zara <1184415+virgilvox@users.noreply.github.com> --- scripts/013_add_last_paid_at.sql | 10 ---------- 1 file changed, 10 deletions(-) delete mode 100644 scripts/013_add_last_paid_at.sql diff --git a/scripts/013_add_last_paid_at.sql b/scripts/013_add_last_paid_at.sql deleted file mode 100644 index af1c4de..0000000 --- a/scripts/013_add_last_paid_at.sql +++ /dev/null @@ -1,10 +0,0 @@ --- ============================================================================= --- Migration 013: Add space_members.last_paid_at --- ============================================================================= --- Adds last_paid_at column to space_members. --- This column is written by importMembers() and linkPaymentToMember() in --- lib/actions.ts. The column last_payment_at already existed as a legacy --- field; last_paid_at is the canonical name used by the application code. --- ============================================================================= - -ALTER TABLE public.space_members ADD COLUMN IF NOT EXISTS last_paid_at timestamptz;