nkbmusic/admin/lib/api.dart
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

384 lines
14 KiB
Dart

import 'package:supabase_flutter/supabase_flutter.dart';
import 'env.dart';
SupabaseClient get _db => Supabase.instance.client;
const occasions = {
'huttu': 'Huttu · birth',
'habba': 'Habba · festival',
'madhuvae': 'Madhuvae · wedding',
'devotional': 'Devotional',
'folk': 'Folk',
'saavu': 'Saavu · remembrance',
'instrumental': 'Instrumental',
};
/// Song status as stored in tracks.status, with the words admins see.
enum SongStatus {
pending('pending', 'Waiting for review'),
approved('approved', 'Live'),
needsChanges('needs_changes', 'Needs changes'),
draft('draft', 'Draft'),
rejected('rejected', 'Rejected'),
takenDown('taken_down', 'Hidden');
const SongStatus(this.id, this.label);
final String id;
final String label;
static SongStatus fromId(String? id) =>
values.firstWhere((s) => s.id == id, orElse: () => SongStatus.pending);
}
/// Filters on the Songs page; each maps to a query (and to a ?show= value in the URL).
enum SongFilter {
all('all', 'All'),
pending('pending', 'Waiting for review'),
live('live', 'Live'),
hidden('hidden', 'Hidden'),
needsChanges('needs_changes', 'Needs changes'),
noSinger('no_singer', 'No singer'),
noAlbum('no_album', 'No album');
const SongFilter(this.id, this.label);
final String id;
final String label;
static SongFilter fromId(String? id) => values.firstWhere((f) => f.id == id, orElse: () => SongFilter.all);
}
class AdminTrack {
AdminTrack.fromRow(Map<String, dynamic> r)
: id = r['id'] as String,
title = r['title_latin'] as String,
titleTamil = r['title_tamil'] as String?,
meaning = r['title_en_meaning'] as String?,
albumId = r['album_id'] as String?,
albumTitle = r['album_title'] as String?,
occasion = r['occasion'] as String? ?? 'folk',
year = (r['year'] as num?)?.toInt(),
duration = Duration(milliseconds: (r['duration_ms'] as num?)?.toInt() ?? 0),
audioKey = r['audio_key_128'] as String?,
status = SongStatus.fromId(r['status'] as String?),
sourceNote = r['source_note'] as String?,
reviewNote = r['review_note'] as String?,
createdAt = DateTime.parse(r['created_at'] as String),
singerIds = [for (final id in (r['singer_ids'] as List? ?? const [])) id as String],
singerNames = r['singer_names'] as String? ?? '';
final String id;
final String title;
final String? titleTamil;
final String? meaning;
final String? albumId;
final String? albumTitle;
final String occasion;
final int? year;
final Duration duration;
final String? audioKey;
final SongStatus status;
final String? sourceNote;
final String? reviewNote;
final DateTime createdAt;
final List<String> singerIds;
final String singerNames;
String? get audioUrl => audioKey == null ? null : '${Env.mediaBaseUrl}/$audioKey';
}
class AdminAlbum {
AdminAlbum.fromRow(Map<String, dynamic> r)
: id = r['id'] as String,
title = r['title_latin'] as String,
titleTamil = r['title_tamil'] as String?,
year = (r['year'] as num?)?.toInt(),
coverUrl = r['cover_url'] as String?,
trackCount = (r['track_count'] as num?)?.toInt() ?? 0,
approvedCount = (r['approved_count'] as num?)?.toInt() ?? 0;
final String id;
final String title;
final String? titleTamil;
final int? year;
final String? coverUrl;
final int trackCount;
final int approvedCount;
}
class AdminArtist {
AdminArtist.fromRow(Map<String, dynamic> r)
: id = r['id'] as String,
name = r['name_latin'] as String,
nameTamil = r['name_tamil'] as String?,
hattiName = r['hatti_name'] as String?,
bio = r['bio'] as String?,
photoUrl = r['photo_url'] as String?,
verified = r['verified'] as bool? ?? false,
claimed = r['claimed_by'] != null,
trackCount = (r['track_count'] as num?)?.toInt() ?? 0;
final String id;
final String name;
final String? nameTamil;
final String? hattiName;
final String? bio;
final String? photoUrl;
final bool verified;
final bool claimed;
final int trackCount;
}
class CreatorApplication {
CreatorApplication.fromRow(Map<String, dynamic> r)
: id = r['id'] as String,
email = r['email'] as String?,
displayName = r['display_name'] as String?,
phone = r['phone'] as String?,
stageName = r['stage_name'] as String,
realName = r['real_name'] as String,
hattiName = r['hatti_name'] as String?,
instagram = r['instagram'] as String?,
whatsapp = r['whatsapp'] as String?,
workLinks = [for (final l in (r['work_links'] as List? ?? const [])) l as String],
idDocumentPath = r['id_document_path'] as String?,
claimedSongs = r['claimed_songs'] as String?,
message = r['message'] as String?,
status = r['status'] as String,
artistId = r['artist_id'] as String?,
reviewNote = r['review_note'] as String?,
createdAt = DateTime.parse(r['created_at'] as String);
final String id;
final String? email;
final String? displayName;
final String? phone;
final String stageName;
final String realName;
final String? hattiName;
final String? instagram;
final String? whatsapp;
final List<String> workLinks;
final String? idDocumentPath;
final String? claimedSongs;
final String? message;
final String status;
final String? artistId;
final String? reviewNote;
final DateTime createdAt;
}
class ResultPage<T> {
const ResultPage(this.items, this.total);
final List<T> items;
final int total;
}
/// Changes for many songs at once. Null fields are left as they are.
class SongChanges {
SongChanges({this.albumId, this.clearAlbum = false, this.occasion, this.status, this.singerIds,
this.replaceSingers = true});
final String? albumId;
final bool clearAlbum;
final String? occasion;
final SongStatus? status;
final List<String>? singerIds;
final bool replaceSingers;
}
String _like(String word) => '%${word.replaceAll(r'\', r'\\').replaceAll('%', r'\%').replaceAll('_', r'\_')}%';
abstract final class AdminApi {
// ------------------------------------------------------------ session
static Future<void> sendCode(String email) => _db.auth.signInWithOtp(email: email, shouldCreateUser: false);
static Future<void> verifyCode(String email, String code) =>
_db.auth.verifyOTP(type: OtpType.email, email: email, token: code);
static Future<bool> isAdmin() async => await _db.rpc('is_admin') == true;
static Future<void> signOut() => _db.auth.signOut();
// ------------------------------------------------------------ overview
static Future<Map<String, int>> overview() async {
final json = await _db.rpc('admin_overview') as Map<String, dynamic>;
return json.map((k, v) => MapEntry(k, (v as num).toInt()));
}
static Future<List<AdminTrack>> recentPending({int limit = 8}) async {
final rows = await _db
.from('admin_tracks')
.select()
.eq('status', 'pending')
.order('created_at', ascending: false)
.limit(limit);
return rows.map(AdminTrack.fromRow).toList();
}
// ------------------------------------------------------------ songs
static Future<ResultPage<AdminTrack>> tracks({
String query = '',
SongFilter filter = SongFilter.all,
String? albumId,
String? artistId,
bool newestFirst = true,
int page = 0,
int pageSize = 50,
}) async {
var q = _db.from('admin_tracks').select();
for (final word in query.toLowerCase().split(RegExp(r'\s+')).where((w) => w.isNotEmpty)) {
q = q.ilike('search_text', _like(word));
}
q = switch (filter) {
SongFilter.all => q,
SongFilter.pending => q.eq('status', 'pending'),
SongFilter.live => q.eq('status', 'approved'),
SongFilter.hidden => q.inFilter('status', ['taken_down', 'rejected']),
SongFilter.needsChanges => q.eq('status', 'needs_changes'),
SongFilter.noSinger => q.eq('singer_names', ''),
SongFilter.noAlbum => q.isFilter('album_id', null),
};
if (albumId != null) q = q.eq('album_id', albumId);
if (artistId != null) q = q.contains('singer_ids', [artistId]);
final ordered = newestFirst
? q.order('created_at', ascending: false).order('title_latin', ascending: true)
: q.order('title_latin', ascending: true);
final res = await ordered.range(page * pageSize, (page + 1) * pageSize - 1).count(CountOption.exact);
return ResultPage(res.data.map(AdminTrack.fromRow).toList(), res.count);
}
static Future<void> updateSongs(List<String> ids, SongChanges c) async {
if (ids.isEmpty) return;
final row = <String, dynamic>{
'occasion': ?c.occasion,
if (c.albumId != null || c.clearAlbum) 'album_id': c.albumId,
if (c.status != null) ..._reviewed(c.status!),
};
if (row.isNotEmpty) await _db.from('tracks').update(row).inFilter('id', ids);
if (c.singerIds != null) await _setSingers(ids, c.singerIds!, replace: c.replaceSingers);
}
/// Saves every field of one song as given (empty text clears a field).
static Future<void> saveSong(AdminTrack t,
{required String title,
String? titleTamil,
String? meaning,
int? year,
String? albumId,
required String occasion,
required SongStatus status,
required List<String> singerIds}) async {
await _db.from('tracks').update({
'title_latin': title,
'title_tamil': titleTamil,
'title_en_meaning': meaning,
'year': year,
'album_id': albumId,
'occasion': occasion,
if (status != t.status) ..._reviewed(status),
}).eq('id', t.id);
if (singerIds.join(',') != t.singerIds.join(',')) await _setSingers([t.id], singerIds, replace: true);
}
static Map<String, dynamic> _reviewed(SongStatus s) => {
'status': s.id,
'reviewed_by': _db.auth.currentUser?.id,
'reviewed_at': DateTime.now().toUtc().toIso8601String(),
};
static Future<void> _setSingers(List<String> trackIds, List<String> artistIds, {required bool replace}) =>
_db.rpc('admin_set_singers', params: {'p_track_ids': trackIds, 'p_artist_ids': artistIds, 'p_replace': replace});
static Future<void> setStatus(List<String> ids, SongStatus status) => updateSongs(ids, SongChanges(status: status));
// ------------------------------------------------------------ albums
static Future<List<AdminAlbum>> albums({String query = ''}) async {
var q = _db.from('admin_albums').select();
if (query.trim().isNotEmpty) q = q.ilike('title_latin', _like(query.trim()));
final rows = await q.order('title_latin');
return rows.map(AdminAlbum.fromRow).toList();
}
static Future<String> createAlbum({required String title, String? titleTamil, int? year}) async {
final row = await _db
.from('albums')
.insert({'title_latin': title, 'title_tamil': titleTamil, 'year': year})
.select('id')
.single();
return row['id'] as String;
}
static Future<void> updateAlbum(String id, {required String title, String? titleTamil, int? year}) =>
_db.from('albums').update({'title_latin': title, 'title_tamil': titleTamil, 'year': year}).eq('id', id);
static Future<void> deleteAlbum(String id) async {
await _db.from('tracks').update({'album_id': null}).eq('album_id', id);
await _db.from('albums').delete().eq('id', id);
}
static Future<void> mergeAlbums(String from, String into) =>
_db.rpc('admin_merge_albums', params: {'p_from': from, 'p_into': into});
// ------------------------------------------------------------ singers
static Future<List<AdminArtist>> artists({String query = ''}) async {
var q = _db.from('admin_artists').select();
if (query.trim().isNotEmpty) q = q.ilike('name_latin', _like(query.trim()));
final rows = await q.order('name_latin');
return rows.map(AdminArtist.fromRow).toList();
}
static Future<String> createArtist({required String name, String? nameTamil, String? hatti, String? bio,
bool verified = false}) async {
final row = await _db
.from('artists')
.insert({
'name_latin': name,
'name_tamil': nameTamil,
'hatti_id': await _hattiId(hatti),
'bio': bio,
'verified': verified,
})
.select('id')
.single();
return row['id'] as String;
}
static Future<void> updateArtist(String id,
{required String name, String? nameTamil, String? hatti, String? bio, required bool verified}) async =>
_db.from('artists').update({
'name_latin': name,
'name_tamil': nameTamil,
'hatti_id': await _hattiId(hatti),
'bio': bio,
'verified': verified,
}).eq('id', id);
static Future<void> deleteArtist(String id) => _db.from('artists').delete().eq('id', id);
static Future<void> mergeArtists(String from, String into) =>
_db.rpc('admin_merge_artists', params: {'p_from': from, 'p_into': into});
static Future<String?> _hattiId(String? name) async =>
await _db.rpc('admin_hatti_id', params: {'p_name': name ?? ''}) as String?;
// ------------------------------------------------------------ creator applications
static Future<List<CreatorApplication>> applications(String status) async {
final rows = await _db
.from('admin_applications')
.select()
.eq('status', status)
.order('created_at', ascending: status != 'pending');
return rows.map(CreatorApplication.fromRow).toList();
}
static Future<void> reviewApplication(String id, String decision, {String? note, String? artistId}) =>
_db.rpc('admin_review_application',
params: {'p_id': id, 'p_decision': decision, 'p_note': note, 'p_artist_id': artistId});
/// A link to the applicant's ID document that works for 10 minutes.
static Future<String> idDocumentUrl(String path) =>
_db.storage.from('creator-documents').createSignedUrl(path, 600);
}