fix: resolve session refresh issue post space creation

Ensure session cookies are updated before server navigation for both new and returning users.

Co-authored-by: Moheeb Zara <[email protected]>
This commit is contained in:
v0
2026-03-11 06:48:38 +00:00
co-authored by Moheeb Zara
parent e36dee2601
commit 4ef8438630
19 changed files with 371 additions and 2219 deletions
-14
View File
@@ -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 |
+9 -5
View File
@@ -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 |
+14 -6
View File
@@ -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
}
+1 -1
View File
@@ -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
-37
View File
@@ -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;
-635
View File
@@ -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);
-82
View File
@@ -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();
-9
View File
@@ -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';
-85
View File
@@ -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();
-39
View File
@@ -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()
);
-20
View File
@@ -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()
);
-516
View File
@@ -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;
-62
View File
@@ -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;
-12
View File
@@ -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;
-32
View File
@@ -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 $$;
-27
View File
@@ -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();
-93
View File
@@ -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';
-193
View File
@@ -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;
$$;
+347 -351
View File
@@ -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;