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>
384 lines
14 KiB
Dart
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);
|
|
}
|