first commit
This commit is contained in:
596
supabase/migrations/20260506170000_riotz_production_schema.sql
Normal file
596
supabase/migrations/20260506170000_riotz_production_schema.sql
Normal file
@@ -0,0 +1,596 @@
|
||||
-- RIOTZ production schema + RLS policies for Supabase
|
||||
-- Safe to run as a single migration.
|
||||
|
||||
begin;
|
||||
|
||||
create extension if not exists "pgcrypto";
|
||||
create extension if not exists "citext";
|
||||
|
||||
-- =========================
|
||||
-- Utility functions
|
||||
-- =========================
|
||||
|
||||
create or replace function public.set_updated_at()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
new.updated_at = timezone('utc', now());
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
create or replace function public.is_admin(_uid uuid)
|
||||
returns boolean
|
||||
language sql
|
||||
stable
|
||||
as $$
|
||||
select exists (
|
||||
select 1
|
||||
from public.admin_users au
|
||||
where au.user_id = _uid
|
||||
);
|
||||
$$;
|
||||
|
||||
-- =========================
|
||||
-- Tables
|
||||
-- =========================
|
||||
|
||||
create table if not exists public.admin_users (
|
||||
user_id uuid primary key references auth.users(id) on delete cascade,
|
||||
created_at timestamptz not null default timezone('utc', now())
|
||||
);
|
||||
|
||||
create table if not exists public.profiles (
|
||||
user_id uuid primary key references auth.users(id) on delete cascade,
|
||||
username citext not null unique,
|
||||
bio text not null default '',
|
||||
avatar_url text not null default '',
|
||||
banned boolean not null default false,
|
||||
created_at timestamptz not null default timezone('utc', now()),
|
||||
updated_at timestamptz not null default timezone('utc', now()),
|
||||
constraint profiles_username_len check (char_length(username::text) between 3 and 32),
|
||||
constraint profiles_bio_len check (char_length(bio) <= 280)
|
||||
);
|
||||
|
||||
create table if not exists public.posts (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
caption text not null default '',
|
||||
image_url text not null,
|
||||
likes_count integer not null default 0 check (likes_count >= 0),
|
||||
featured boolean not null default false,
|
||||
created_at timestamptz not null default timezone('utc', now()),
|
||||
updated_at timestamptz not null default timezone('utc', now()),
|
||||
constraint posts_caption_len check (char_length(caption) <= 2800)
|
||||
);
|
||||
|
||||
create table if not exists public.post_likes (
|
||||
post_id uuid not null references public.posts(id) on delete cascade,
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
created_at timestamptz not null default timezone('utc', now()),
|
||||
primary key (post_id, user_id)
|
||||
);
|
||||
|
||||
create table if not exists public.comments (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
post_id uuid not null references public.posts(id) on delete cascade,
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
content text not null,
|
||||
created_at timestamptz not null default timezone('utc', now()),
|
||||
updated_at timestamptz not null default timezone('utc', now()),
|
||||
constraint comments_content_len check (char_length(content) between 1 and 1200)
|
||||
);
|
||||
|
||||
create table if not exists public.tracks (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
title text not null,
|
||||
audio_url text not null,
|
||||
tags text not null default '',
|
||||
featured boolean not null default false,
|
||||
created_at timestamptz not null default timezone('utc', now()),
|
||||
updated_at timestamptz not null default timezone('utc', now()),
|
||||
constraint tracks_title_len check (char_length(title) between 1 and 120),
|
||||
constraint tracks_tags_len check (char_length(tags) <= 256)
|
||||
);
|
||||
|
||||
create table if not exists public.chats (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
name text not null,
|
||||
is_group boolean not null default true,
|
||||
created_by uuid not null references auth.users(id) on delete cascade,
|
||||
created_at timestamptz not null default timezone('utc', now()),
|
||||
updated_at timestamptz not null default timezone('utc', now()),
|
||||
constraint chats_name_len check (char_length(name) between 1 and 120)
|
||||
);
|
||||
|
||||
create table if not exists public.chat_members (
|
||||
chat_id uuid not null references public.chats(id) on delete cascade,
|
||||
user_id uuid not null references auth.users(id) on delete cascade,
|
||||
role text not null default 'member',
|
||||
joined_at timestamptz not null default timezone('utc', now()),
|
||||
primary key (chat_id, user_id),
|
||||
constraint chat_members_role_valid check (role in ('owner', 'admin', 'member'))
|
||||
);
|
||||
|
||||
create table if not exists public.messages (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
chat_id uuid not null references public.chats(id) on delete cascade,
|
||||
sender_id uuid not null references auth.users(id) on delete cascade,
|
||||
content text not null,
|
||||
created_at timestamptz not null default timezone('utc', now()),
|
||||
updated_at timestamptz not null default timezone('utc', now()),
|
||||
constraint messages_content_len check (char_length(content) between 1 and 1200)
|
||||
);
|
||||
|
||||
-- =========================
|
||||
-- Indexes
|
||||
-- =========================
|
||||
|
||||
create index if not exists idx_profiles_username on public.profiles (username);
|
||||
create index if not exists idx_profiles_banned on public.profiles (banned);
|
||||
|
||||
create index if not exists idx_posts_user_id_created_at on public.posts (user_id, created_at desc);
|
||||
create index if not exists idx_posts_created_at on public.posts (created_at desc);
|
||||
create index if not exists idx_posts_likes_count on public.posts (likes_count desc);
|
||||
create index if not exists idx_posts_featured on public.posts (featured) where featured = true;
|
||||
|
||||
create index if not exists idx_post_likes_user_id on public.post_likes (user_id);
|
||||
create index if not exists idx_post_likes_post_id on public.post_likes (post_id);
|
||||
|
||||
create index if not exists idx_comments_post_id_created_at on public.comments (post_id, created_at asc);
|
||||
create index if not exists idx_comments_user_id_created_at on public.comments (user_id, created_at desc);
|
||||
|
||||
create index if not exists idx_tracks_user_id_created_at on public.tracks (user_id, created_at desc);
|
||||
create index if not exists idx_tracks_created_at on public.tracks (created_at desc);
|
||||
create index if not exists idx_tracks_featured on public.tracks (featured) where featured = true;
|
||||
|
||||
create index if not exists idx_chat_members_user_id on public.chat_members (user_id);
|
||||
create index if not exists idx_messages_chat_id_created_at on public.messages (chat_id, created_at asc);
|
||||
create index if not exists idx_messages_sender_id_created_at on public.messages (sender_id, created_at desc);
|
||||
|
||||
-- =========================
|
||||
-- Triggers
|
||||
-- =========================
|
||||
|
||||
drop trigger if exists trg_profiles_updated_at on public.profiles;
|
||||
create trigger trg_profiles_updated_at
|
||||
before update on public.profiles
|
||||
for each row execute procedure public.set_updated_at();
|
||||
|
||||
drop trigger if exists trg_posts_updated_at on public.posts;
|
||||
create trigger trg_posts_updated_at
|
||||
before update on public.posts
|
||||
for each row execute procedure public.set_updated_at();
|
||||
|
||||
drop trigger if exists trg_comments_updated_at on public.comments;
|
||||
create trigger trg_comments_updated_at
|
||||
before update on public.comments
|
||||
for each row execute procedure public.set_updated_at();
|
||||
|
||||
drop trigger if exists trg_tracks_updated_at on public.tracks;
|
||||
create trigger trg_tracks_updated_at
|
||||
before update on public.tracks
|
||||
for each row execute procedure public.set_updated_at();
|
||||
|
||||
drop trigger if exists trg_chats_updated_at on public.chats;
|
||||
create trigger trg_chats_updated_at
|
||||
before update on public.chats
|
||||
for each row execute procedure public.set_updated_at();
|
||||
|
||||
drop trigger if exists trg_messages_updated_at on public.messages;
|
||||
create trigger trg_messages_updated_at
|
||||
before update on public.messages
|
||||
for each row execute procedure public.set_updated_at();
|
||||
|
||||
-- Keep posts.likes_count in sync from post_likes
|
||||
create or replace function public.handle_post_like_delta()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
as $$
|
||||
begin
|
||||
if tg_op = 'INSERT' then
|
||||
update public.posts
|
||||
set likes_count = likes_count + 1
|
||||
where id = new.post_id;
|
||||
return new;
|
||||
elsif tg_op = 'DELETE' then
|
||||
update public.posts
|
||||
set likes_count = greatest(0, likes_count - 1)
|
||||
where id = old.post_id;
|
||||
return old;
|
||||
end if;
|
||||
return null;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trg_post_like_delta on public.post_likes;
|
||||
create trigger trg_post_like_delta
|
||||
after insert or delete on public.post_likes
|
||||
for each row execute procedure public.handle_post_like_delta();
|
||||
|
||||
-- Auto-create profile on new auth user
|
||||
create or replace function public.handle_new_user_profile()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
declare
|
||||
suggested_username text;
|
||||
begin
|
||||
suggested_username :=
|
||||
coalesce(new.raw_user_meta_data ->> 'username', split_part(new.email, '@', 1), 'riot_user');
|
||||
suggested_username := left(regexp_replace(lower(suggested_username), '[^a-z0-9_]', '', 'g'), 32);
|
||||
if char_length(suggested_username) < 3 then
|
||||
suggested_username := 'riot_' || substring(new.id::text from 1 for 8);
|
||||
end if;
|
||||
|
||||
insert into public.profiles (user_id, username)
|
||||
values (new.id, suggested_username)
|
||||
on conflict (user_id) do nothing;
|
||||
return new;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists on_auth_user_created_profile on auth.users;
|
||||
create trigger on_auth_user_created_profile
|
||||
after insert on auth.users
|
||||
for each row execute procedure public.handle_new_user_profile();
|
||||
|
||||
-- =========================
|
||||
-- RLS
|
||||
-- =========================
|
||||
|
||||
alter table public.admin_users enable row level security;
|
||||
alter table public.profiles enable row level security;
|
||||
alter table public.posts enable row level security;
|
||||
alter table public.post_likes enable row level security;
|
||||
alter table public.comments enable row level security;
|
||||
alter table public.tracks enable row level security;
|
||||
alter table public.chats enable row level security;
|
||||
alter table public.chat_members enable row level security;
|
||||
alter table public.messages enable row level security;
|
||||
|
||||
-- admin_users (self can read own admin row; admins can read all)
|
||||
drop policy if exists admin_users_select_self_or_admin on public.admin_users;
|
||||
create policy admin_users_select_self_or_admin
|
||||
on public.admin_users
|
||||
for select
|
||||
using (auth.uid() = user_id or public.is_admin(auth.uid()));
|
||||
|
||||
-- profiles
|
||||
drop policy if exists profiles_public_read on public.profiles;
|
||||
create policy profiles_public_read
|
||||
on public.profiles
|
||||
for select
|
||||
using (true);
|
||||
|
||||
drop policy if exists profiles_insert_self on public.profiles;
|
||||
create policy profiles_insert_self
|
||||
on public.profiles
|
||||
for insert
|
||||
with check (auth.uid() = user_id);
|
||||
|
||||
drop policy if exists profiles_update_self_or_admin on public.profiles;
|
||||
create policy profiles_update_self_or_admin
|
||||
on public.profiles
|
||||
for update
|
||||
using (auth.uid() = user_id or public.is_admin(auth.uid()))
|
||||
with check (auth.uid() = user_id or public.is_admin(auth.uid()));
|
||||
|
||||
drop policy if exists profiles_delete_admin_only on public.profiles;
|
||||
create policy profiles_delete_admin_only
|
||||
on public.profiles
|
||||
for delete
|
||||
using (public.is_admin(auth.uid()));
|
||||
|
||||
-- posts
|
||||
drop policy if exists posts_public_read on public.posts;
|
||||
create policy posts_public_read
|
||||
on public.posts
|
||||
for select
|
||||
using (true);
|
||||
|
||||
drop policy if exists posts_insert_self_not_banned on public.posts;
|
||||
create policy posts_insert_self_not_banned
|
||||
on public.posts
|
||||
for insert
|
||||
with check (
|
||||
auth.uid() = user_id
|
||||
and not exists (
|
||||
select 1 from public.profiles p
|
||||
where p.user_id = auth.uid() and p.banned = true
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists posts_update_owner_or_admin on public.posts;
|
||||
create policy posts_update_owner_or_admin
|
||||
on public.posts
|
||||
for update
|
||||
using (auth.uid() = user_id or public.is_admin(auth.uid()))
|
||||
with check (auth.uid() = user_id or public.is_admin(auth.uid()));
|
||||
|
||||
drop policy if exists posts_delete_owner_or_admin on public.posts;
|
||||
create policy posts_delete_owner_or_admin
|
||||
on public.posts
|
||||
for delete
|
||||
using (auth.uid() = user_id or public.is_admin(auth.uid()));
|
||||
|
||||
-- post_likes
|
||||
drop policy if exists post_likes_public_read on public.post_likes;
|
||||
create policy post_likes_public_read
|
||||
on public.post_likes
|
||||
for select
|
||||
using (true);
|
||||
|
||||
drop policy if exists post_likes_insert_self on public.post_likes;
|
||||
create policy post_likes_insert_self
|
||||
on public.post_likes
|
||||
for insert
|
||||
with check (auth.uid() = user_id);
|
||||
|
||||
drop policy if exists post_likes_delete_self_or_admin on public.post_likes;
|
||||
create policy post_likes_delete_self_or_admin
|
||||
on public.post_likes
|
||||
for delete
|
||||
using (auth.uid() = user_id or public.is_admin(auth.uid()));
|
||||
|
||||
-- comments
|
||||
drop policy if exists comments_public_read on public.comments;
|
||||
create policy comments_public_read
|
||||
on public.comments
|
||||
for select
|
||||
using (true);
|
||||
|
||||
drop policy if exists comments_insert_self_not_banned on public.comments;
|
||||
create policy comments_insert_self_not_banned
|
||||
on public.comments
|
||||
for insert
|
||||
with check (
|
||||
auth.uid() = user_id
|
||||
and not exists (
|
||||
select 1 from public.profiles p
|
||||
where p.user_id = auth.uid() and p.banned = true
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists comments_update_owner_or_admin on public.comments;
|
||||
create policy comments_update_owner_or_admin
|
||||
on public.comments
|
||||
for update
|
||||
using (auth.uid() = user_id or public.is_admin(auth.uid()))
|
||||
with check (auth.uid() = user_id or public.is_admin(auth.uid()));
|
||||
|
||||
drop policy if exists comments_delete_owner_or_admin on public.comments;
|
||||
create policy comments_delete_owner_or_admin
|
||||
on public.comments
|
||||
for delete
|
||||
using (auth.uid() = user_id or public.is_admin(auth.uid()));
|
||||
|
||||
-- tracks
|
||||
drop policy if exists tracks_public_read on public.tracks;
|
||||
create policy tracks_public_read
|
||||
on public.tracks
|
||||
for select
|
||||
using (true);
|
||||
|
||||
drop policy if exists tracks_insert_self_not_banned on public.tracks;
|
||||
create policy tracks_insert_self_not_banned
|
||||
on public.tracks
|
||||
for insert
|
||||
with check (
|
||||
auth.uid() = user_id
|
||||
and not exists (
|
||||
select 1 from public.profiles p
|
||||
where p.user_id = auth.uid() and p.banned = true
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists tracks_update_owner_or_admin on public.tracks;
|
||||
create policy tracks_update_owner_or_admin
|
||||
on public.tracks
|
||||
for update
|
||||
using (auth.uid() = user_id or public.is_admin(auth.uid()))
|
||||
with check (auth.uid() = user_id or public.is_admin(auth.uid()));
|
||||
|
||||
drop policy if exists tracks_delete_owner_or_admin on public.tracks;
|
||||
create policy tracks_delete_owner_or_admin
|
||||
on public.tracks
|
||||
for delete
|
||||
using (auth.uid() = user_id or public.is_admin(auth.uid()));
|
||||
|
||||
-- chats
|
||||
drop policy if exists chats_member_or_admin_read on public.chats;
|
||||
create policy chats_member_or_admin_read
|
||||
on public.chats
|
||||
for select
|
||||
using (
|
||||
public.is_admin(auth.uid())
|
||||
or exists (
|
||||
select 1 from public.chat_members cm
|
||||
where cm.chat_id = id and cm.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists chats_create_self on public.chats;
|
||||
create policy chats_create_self
|
||||
on public.chats
|
||||
for insert
|
||||
with check (auth.uid() = created_by);
|
||||
|
||||
drop policy if exists chats_update_owner_or_admin on public.chats;
|
||||
create policy chats_update_owner_or_admin
|
||||
on public.chats
|
||||
for update
|
||||
using (auth.uid() = created_by or public.is_admin(auth.uid()))
|
||||
with check (auth.uid() = created_by or public.is_admin(auth.uid()));
|
||||
|
||||
drop policy if exists chats_delete_owner_or_admin on public.chats;
|
||||
create policy chats_delete_owner_or_admin
|
||||
on public.chats
|
||||
for delete
|
||||
using (auth.uid() = created_by or public.is_admin(auth.uid()));
|
||||
|
||||
-- chat_members
|
||||
drop policy if exists chat_members_member_or_admin_read on public.chat_members;
|
||||
create policy chat_members_member_or_admin_read
|
||||
on public.chat_members
|
||||
for select
|
||||
using (
|
||||
public.is_admin(auth.uid())
|
||||
or user_id = auth.uid()
|
||||
or exists (
|
||||
select 1 from public.chat_members cm
|
||||
where cm.chat_id = chat_id and cm.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists chat_members_owner_or_admin_manage on public.chat_members;
|
||||
create policy chat_members_owner_or_admin_manage
|
||||
on public.chat_members
|
||||
for all
|
||||
using (
|
||||
public.is_admin(auth.uid())
|
||||
or exists (
|
||||
select 1 from public.chat_members cm
|
||||
where cm.chat_id = chat_id and cm.user_id = auth.uid() and cm.role in ('owner', 'admin')
|
||||
)
|
||||
)
|
||||
with check (
|
||||
public.is_admin(auth.uid())
|
||||
or exists (
|
||||
select 1 from public.chat_members cm
|
||||
where cm.chat_id = chat_id and cm.user_id = auth.uid() and cm.role in ('owner', 'admin')
|
||||
)
|
||||
);
|
||||
|
||||
-- messages
|
||||
drop policy if exists messages_chat_member_read on public.messages;
|
||||
create policy messages_chat_member_read
|
||||
on public.messages
|
||||
for select
|
||||
using (
|
||||
public.is_admin(auth.uid())
|
||||
or exists (
|
||||
select 1 from public.chat_members cm
|
||||
where cm.chat_id = chat_id and cm.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists messages_chat_member_insert on public.messages;
|
||||
create policy messages_chat_member_insert
|
||||
on public.messages
|
||||
for insert
|
||||
with check (
|
||||
auth.uid() = sender_id
|
||||
and exists (
|
||||
select 1 from public.chat_members cm
|
||||
where cm.chat_id = chat_id and cm.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists messages_sender_or_admin_delete on public.messages;
|
||||
create policy messages_sender_or_admin_delete
|
||||
on public.messages
|
||||
for delete
|
||||
using (auth.uid() = sender_id or public.is_admin(auth.uid()));
|
||||
|
||||
-- =========================
|
||||
-- Storage buckets + policies
|
||||
-- =========================
|
||||
|
||||
insert into storage.buckets (id, name, public)
|
||||
values
|
||||
('avatars', 'avatars', true),
|
||||
('post-images', 'post-images', true),
|
||||
('tracks', 'tracks', true)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
-- Read policies (public)
|
||||
drop policy if exists avatars_public_read on storage.objects;
|
||||
create policy avatars_public_read
|
||||
on storage.objects
|
||||
for select
|
||||
using (bucket_id = 'avatars');
|
||||
|
||||
drop policy if exists post_images_public_read on storage.objects;
|
||||
create policy post_images_public_read
|
||||
on storage.objects
|
||||
for select
|
||||
using (bucket_id = 'post-images');
|
||||
|
||||
drop policy if exists tracks_public_read on storage.objects;
|
||||
create policy tracks_public_read
|
||||
on storage.objects
|
||||
for select
|
||||
using (bucket_id = 'tracks');
|
||||
|
||||
-- Upload policies (owner only)
|
||||
drop policy if exists avatars_owner_upload on storage.objects;
|
||||
create policy avatars_owner_upload
|
||||
on storage.objects
|
||||
for insert
|
||||
with check (
|
||||
bucket_id = 'avatars'
|
||||
and auth.uid() is not null
|
||||
and (storage.foldername(name))[1] = 'avatars'
|
||||
and (storage.foldername(name))[2] = auth.uid()::text
|
||||
);
|
||||
|
||||
drop policy if exists post_images_owner_upload on storage.objects;
|
||||
create policy post_images_owner_upload
|
||||
on storage.objects
|
||||
for insert
|
||||
with check (
|
||||
bucket_id = 'post-images'
|
||||
and auth.uid() is not null
|
||||
and (storage.foldername(name))[1] = 'posts'
|
||||
and (storage.foldername(name))[2] = auth.uid()::text
|
||||
);
|
||||
|
||||
drop policy if exists tracks_owner_upload on storage.objects;
|
||||
create policy tracks_owner_upload
|
||||
on storage.objects
|
||||
for insert
|
||||
with check (
|
||||
bucket_id = 'tracks'
|
||||
and auth.uid() is not null
|
||||
and (storage.foldername(name))[1] = 'tracks'
|
||||
and (storage.foldername(name))[2] = auth.uid()::text
|
||||
);
|
||||
|
||||
-- Update/Delete object policies
|
||||
drop policy if exists storage_owner_or_admin_update on storage.objects;
|
||||
create policy storage_owner_or_admin_update
|
||||
on storage.objects
|
||||
for update
|
||||
using (
|
||||
auth.uid() is not null
|
||||
and (
|
||||
((storage.foldername(name))[2] = auth.uid()::text and bucket_id in ('avatars', 'post-images', 'tracks'))
|
||||
or public.is_admin(auth.uid())
|
||||
)
|
||||
)
|
||||
with check (
|
||||
auth.uid() is not null
|
||||
and (
|
||||
((storage.foldername(name))[2] = auth.uid()::text and bucket_id in ('avatars', 'post-images', 'tracks'))
|
||||
or public.is_admin(auth.uid())
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists storage_owner_or_admin_delete on storage.objects;
|
||||
create policy storage_owner_or_admin_delete
|
||||
on storage.objects
|
||||
for delete
|
||||
using (
|
||||
auth.uid() is not null
|
||||
and (
|
||||
((storage.foldername(name))[2] = auth.uid()::text and bucket_id in ('avatars', 'post-images', 'tracks'))
|
||||
or public.is_admin(auth.uid())
|
||||
)
|
||||
);
|
||||
|
||||
commit;
|
||||
Reference in New Issue
Block a user