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 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 singerIds; final String singerNames; String? get audioUrl => audioKey == null ? null : '${Env.mediaBaseUrl}/$audioKey'; } class AdminAlbum { AdminAlbum.fromRow(Map 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 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 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 workLinks; final String? idDocumentPath; final String? claimedSongs; final String? message; final String status; final String? artistId; final String? reviewNote; final DateTime createdAt; } class ResultPage { const ResultPage(this.items, this.total); final List 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? singerIds; final bool replaceSingers; } String _like(String word) => '%${word.replaceAll(r'\', r'\\').replaceAll('%', r'\%').replaceAll('_', r'\_')}%'; abstract final class AdminApi { // ------------------------------------------------------------ session static Future sendCode(String email) => _db.auth.signInWithOtp(email: email, shouldCreateUser: false); static Future verifyCode(String email, String code) => _db.auth.verifyOTP(type: OtpType.email, email: email, token: code); static Future isAdmin() async => await _db.rpc('is_admin') == true; static Future signOut() => _db.auth.signOut(); // ------------------------------------------------------------ overview static Future> overview() async { final json = await _db.rpc('admin_overview') as Map; return json.map((k, v) => MapEntry(k, (v as num).toInt())); } static Future> 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> 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 updateSongs(List ids, SongChanges c) async { if (ids.isEmpty) return; final row = { '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 saveSong(AdminTrack t, {required String title, String? titleTamil, String? meaning, int? year, String? albumId, required String occasion, required SongStatus status, required List 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 _reviewed(SongStatus s) => { 'status': s.id, 'reviewed_by': _db.auth.currentUser?.id, 'reviewed_at': DateTime.now().toUtc().toIso8601String(), }; static Future _setSingers(List trackIds, List artistIds, {required bool replace}) => _db.rpc('admin_set_singers', params: {'p_track_ids': trackIds, 'p_artist_ids': artistIds, 'p_replace': replace}); static Future setStatus(List ids, SongStatus status) => updateSongs(ids, SongChanges(status: status)); // ------------------------------------------------------------ albums static Future> 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 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 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 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 mergeAlbums(String from, String into) => _db.rpc('admin_merge_albums', params: {'p_from': from, 'p_into': into}); // ------------------------------------------------------------ singers static Future> 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 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 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 deleteArtist(String id) => _db.from('artists').delete().eq('id', id); static Future mergeArtists(String from, String into) => _db.rpc('admin_merge_artists', params: {'p_from': from, 'p_into': into}); static Future _hattiId(String? name) async => await _db.rpc('admin_hatti_id', params: {'p_name': name ?? ''}) as String?; // ------------------------------------------------------------ creator applications static Future> 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 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 idDocumentUrl(String path) => _db.storage.from('creator-documents').createSignedUrl(path, 600); }