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>
241 lines
12 KiB
PL/PgSQL
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;
|