-- 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;