-- NK Admin (admin.nkbmusic.com): list views, admin functions and the creator applications queue. -- Every function checks is_admin(); views use the caller's row-level security unless noted. create or replace function nk_require_admin() returns void language plpgsql stable security definer set search_path = public as $$ begin if not is_admin() then raise exception 'admins only' using errcode = '42501'; end if; end $$; -- ---------------------------------------------------------------- list views -- One row per song with its album and singers, so the admin can search and filter in one query. create view admin_tracks with (security_invoker = true) as select t.id, t.title_latin, t.title_tamil, t.title_en_meaning, t.album_id, al.title_latin as album_title, t.occasion, t.year, t.duration_ms, t.audio_key_128, t.cover_url, t.tier, t.status, t.rights_status, t.source_note, t.review_note, t.reviewed_at, t.created_at, coalesce(s.ids, '{}') as singer_ids, coalesce(s.names, '') as singer_names, lower(concat_ws(' ', t.title_latin, t.title_tamil, t.title_en_meaning, al.title_latin, s.names, t.source_note)) as search_text from tracks t left join albums al on al.id = t.album_id left join lateral ( select array_agg(ar.id order by ta.position) as ids, string_agg(ar.name_latin, ', ' order by ta.position) as names from track_artists ta join artists ar on ar.id = ta.artist_id where ta.track_id = t.id and ta.role = 'singer' ) s on true; create view admin_albums with (security_invoker = true) as select al.id, al.title_latin, al.title_tamil, al.year, al.cover_url, (select count(*) from tracks t where t.album_id = al.id)::int as track_count, (select count(*) from tracks t where t.album_id = al.id and t.status = 'approved')::int as approved_count from albums al; create view admin_artists with (security_invoker = true) as select ar.id, ar.name_latin, ar.name_tamil, ar.hatti_id, h.name_latin as hatti_name, ar.bio, ar.photo_url, ar.photo_permission, ar.verified, ar.claimed_by, ar.created_at, (select count(distinct ta.track_id) from track_artists ta where ta.artist_id = ar.id and ta.role = 'singer')::int as track_count from artists ar left join hattis h on h.id = ar.hatti_id; revoke all on admin_tracks, admin_albums, admin_artists from anon; grant select on admin_tracks, admin_albums, admin_artists to authenticated; -- ---------------------------------------------------------------- creator applications create type application_status as enum ('pending', 'needs_info', 'approved', 'rejected'); create table creator_applications ( id uuid primary key default gen_random_uuid(), user_id uuid not null references profiles on delete cascade, stage_name text not null, real_name text not null, hatti_name text, instagram text, whatsapp text, work_links text[] not null default '{}', id_document_path text, -- object in the private creator-documents bucket claimed_songs text, -- the applicant's own words; admins link real songs later message text, status application_status not null default 'pending', artist_id uuid references artists on delete set null, -- set on approval review_note text, reviewed_by uuid references profiles, reviewed_at timestamptz, created_at timestamptz not null default now() ); create index creator_applications_status_idx on creator_applications (status); create unique index creator_applications_one_open on creator_applications (user_id) where status in ('pending', 'needs_info'); alter table creator_applications enable row level security; create policy own_application_read on creator_applications for select using (user_id = auth.uid() or is_admin()); create policy own_application_insert on creator_applications for insert with check (user_id = auth.uid() and status = 'pending' and artist_id is null and reviewed_by is null); create policy admin_applications on creator_applications for all using (is_admin()) with check (is_admin()); -- Applications with the applicant's email and profile. Runs as the owner (to read auth.users), -- so it filters to admins itself. create view admin_applications as select ca.*, u.email, p.display_name, p.phone, p.role as user_role from creator_applications ca join auth.users u on u.id = ca.user_id join profiles p on p.id = ca.user_id where is_admin(); revoke all on admin_applications from anon; grant select on admin_applications to authenticated; -- ID documents: creator-documents//. Applicants upload their own; only admins read. insert into storage.buckets (id, name, public, file_size_limit, allowed_mime_types) values ('creator-documents', 'creator-documents', false, 10485760, array['image/jpeg','image/png','image/webp','application/pdf']) on conflict (id) do nothing; create policy nk_own_creator_documents on storage.objects for insert to authenticated with check (bucket_id = 'creator-documents' and (storage.foldername(name))[1] = auth.uid()::text); create policy nk_admin_creator_documents on storage.objects for select to authenticated using (bucket_id = 'creator-documents' and public.is_admin()); -- ---------------------------------------------------------------- admin functions create function admin_overview() returns json language plpgsql stable security definer set search_path = public as $$ begin perform nk_require_admin(); return json_build_object( 'songs', (select count(*) from tracks), 'approved', (select count(*) from tracks where status = 'approved'), 'pending', (select count(*) from tracks where status = 'pending'), 'needs_changes', (select count(*) from tracks where status = 'needs_changes'), 'hidden', (select count(*) from tracks where status in ('taken_down', 'rejected')), 'no_singer', (select count(*) from tracks t where not exists (select 1 from track_artists ta where ta.track_id = t.id and ta.role = 'singer')), 'no_album', (select count(*) from tracks where album_id is null), 'albums', (select count(*) from albums), 'empty_albums', (select count(*) from albums al where not exists (select 1 from tracks t where t.album_id = al.id)), 'singers', (select count(*) from artists), 'listeners', (select count(*) from profiles), 'applications', (select count(*) from creator_applications where status = 'pending'), 'takedowns', (select count(*) from takedown_requests where status = 'open')); end $$; -- Sets the singers of one or more songs, in the given order. p_replace = false adds them -- after the existing singers instead. create function admin_set_singers(p_track_ids uuid[], p_artist_ids uuid[], p_replace boolean default true) returns void language plpgsql security definer set search_path = public as $$ begin perform nk_require_admin(); if p_replace then delete from track_artists where track_id = any (p_track_ids) and role = 'singer'; end if; insert into track_artists (track_id, artist_id, role, position) select t.id, a.id, 'singer', coalesce((select max(x.position) + 1 from track_artists x where x.track_id = t.id and x.role = 'singer'), 0) + a.ord::int - 1 from unnest(p_track_ids) as t(id) cross join unnest(p_artist_ids) with ordinality as a(id, ord) on conflict do nothing; end $$; -- Moves every song of one album into another and deletes the first (for duplicates). create function admin_merge_albums(p_from uuid, p_into uuid) returns void language plpgsql security definer set search_path = public as $$ begin perform nk_require_admin(); if p_from = p_into then return; end if; update tracks set album_id = p_into where album_id = p_from; update albums a set title_tamil = coalesce(a.title_tamil, f.title_tamil), year = coalesce(a.year, f.year), cover_url = coalesce(a.cover_url, f.cover_url) from albums f where a.id = p_into and f.id = p_from; delete from albums where id = p_from; end $$; -- Moves every credit of one singer onto another and deletes the first (for duplicates). create function admin_merge_artists(p_from uuid, p_into uuid) returns void language plpgsql security definer set search_path = public as $$ begin perform nk_require_admin(); if p_from = p_into then return; end if; insert into track_artists (track_id, artist_id, role, position) select track_id, p_into, role, position from track_artists where artist_id = p_from on conflict do nothing; delete from track_artists where artist_id = p_from; update consents set artist_id = p_into where artist_id = p_from; update creator_applications set artist_id = p_into where artist_id = p_from; update artists a set name_tamil = coalesce(a.name_tamil, f.name_tamil), hatti_id = coalesce(a.hatti_id, f.hatti_id), bio = coalesce(a.bio, f.bio), verified = a.verified or f.verified, claimed_by = coalesce(a.claimed_by, f.claimed_by) from artists f where a.id = p_into and f.id = p_from; delete from artists where id = p_from; end $$; -- Finds or creates a hatti by its Latin name; null for an empty name. create function admin_hatti_id(p_name text) returns uuid language plpgsql security definer set search_path = public as $$ declare v_id uuid; begin perform nk_require_admin(); if nullif(trim(p_name), '') is null then return null; end if; select id into v_id from hattis where lower(name_latin) = lower(trim(p_name)); if v_id is null then insert into hattis (name_latin) values (trim(p_name)) returning id into v_id; end if; return v_id; end $$; -- Records the decision on a creator application. Approving makes the user an artist and -- links (or creates) their verified singer profile. Returns the singer id when approved. create function admin_review_application(p_id uuid, p_decision application_status, p_note text default null, p_artist_id uuid default null) returns uuid language plpgsql security definer set search_path = public as $$ declare app creator_applications; v_artist uuid; begin perform nk_require_admin(); select * into app from creator_applications where id = p_id for update; if not found then raise exception 'application not found'; end if; if p_decision = 'approved' then v_artist := coalesce(p_artist_id, app.artist_id); if v_artist is null then insert into artists (name_latin, hatti_id, verified, claimed_by) values (app.stage_name, admin_hatti_id(app.hatti_name), true, app.user_id) returning id into v_artist; else update artists set verified = true, claimed_by = app.user_id where id = v_artist; end if; update profiles set role = 'artist' where id = app.user_id and role = 'listener'; end if; update creator_applications set status = p_decision, review_note = p_note, reviewed_by = auth.uid(), reviewed_at = now(), artist_id = coalesce(v_artist, artist_id) where id = p_id; return v_artist; end $$; revoke all on function nk_require_admin(), admin_overview(), admin_set_singers(uuid[], uuid[], boolean), admin_merge_albums(uuid, uuid), admin_merge_artists(uuid, uuid), admin_hatti_id(text), admin_review_application(uuid, application_status, text, uuid) from public, anon; grant execute on function nk_require_admin(), admin_overview(), admin_set_singers(uuid[], uuid[], boolean), admin_merge_albums(uuid, uuid), admin_merge_artists(uuid, uuid), admin_hatti_id(text), admin_review_application(uuid, application_status, text, uuid) to authenticated;