597 lines
19 KiB
PL/PgSQL
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;
|
|
$$;
|
|
|
|
-- =========================
|
|
-- 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 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
|
|
);
|
|
$$;
|
|
|
|
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;
|