nkbmusic/supabase/migrations/20261031000000_admin.sql
metatroncubeswdev cf645071aa Add NK Admin dashboard (Flutter web) for admin.nkbmusic.com
Email-code sign-in for admins only; Overview with a needs-attention list;
Songs with search, playback, editing, approve/hide and bulk edit; Albums
and Singers with merge; creator applications queue. Adds the admin views,
functions and creator_applications table, a deploy-admin script and a
how-to guide. The admin nginx config no longer caches unhashed JS and
sends its security headers again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 11:35:44 -04:00

241 lines
12 KiB
PL/PgSQL

-- 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/<user id>/<file>. 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;