Files
YungR1otz/supabase/migrations/20260506170000_riotz_production_schema.sql
Lucas Saburido cabf2025cd first commit
2026-05-13 16:26:45 +01:00

597 lines
19 KiB
PL/PgSQL

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