diff --git a/flutter_client/lib/models/admin_quarantine_item.dart b/flutter_client/lib/models/admin_quarantine_item.dart new file mode 100644 index 00000000..db1aed39 --- /dev/null +++ b/flutter_client/lib/models/admin_quarantine_item.dart @@ -0,0 +1,92 @@ +/// One user's report under an aggregated quarantine row. +class AdminQuarantineReport { + const AdminQuarantineReport({ + required this.userId, + required this.username, + required this.reason, + this.notes, + required this.createdAt, + }); + + final String userId; + final String username; + final String reason; + final String? notes; + final String createdAt; + + factory AdminQuarantineReport.fromJson(Map j) => + AdminQuarantineReport( + userId: j['user_id'] as String? ?? '', + username: j['username'] as String? ?? '', + reason: j['reason'] as String? ?? '', + notes: j['notes'] as String?, + createdAt: j['created_at'] as String? ?? '', + ); +} + +/// Mirrors `adminQueueRowView` from internal/api/admin_quarantine.go. +/// One row aggregates all reports against a single track, with +/// per-reason counts and the underlying per-user reports nested. +class AdminQuarantineItem { + const AdminQuarantineItem({ + required this.trackId, + required this.trackTitle, + required this.artistName, + required this.albumTitle, + required this.albumId, + this.lidarrAlbumMbid, + required this.reportCount, + required this.latestAt, + required this.reasonCounts, + required this.reports, + }); + + final String trackId; + final String trackTitle; + final String artistName; + final String albumTitle; + final String albumId; + final String? lidarrAlbumMbid; + final int reportCount; + final String latestAt; + + /// Map of reason → number of reports citing that reason. + final Map reasonCounts; + + final List reports; + + /// One-line summary of the most-cited reason. Returns "" when + /// only one reason exists or " (+N more)" when there are + /// additional distinct reasons. + String get topReasonSummary { + if (reasonCounts.isEmpty) return ''; + final entries = reasonCounts.entries.toList() + ..sort((a, b) => b.value.compareTo(a.value)); + final top = entries.first.key; + return entries.length > 1 + ? '$top (+${entries.length - 1} more)' + : top; + } + + factory AdminQuarantineItem.fromJson(Map j) { + final reasonCountsRaw = + (j['reason_counts'] as Map?)?.cast() ?? const {}; + final reportsRaw = (j['reports'] as List?) ?? const []; + return AdminQuarantineItem( + trackId: j['track_id'] as String? ?? '', + trackTitle: j['track_title'] as String? ?? '', + artistName: j['artist_name'] as String? ?? '', + albumTitle: j['album_title'] as String? ?? '', + albumId: j['album_id'] as String? ?? '', + lidarrAlbumMbid: j['lidarr_album_mbid'] as String?, + reportCount: (j['report_count'] as int?) ?? 0, + latestAt: j['latest_at'] as String? ?? '', + reasonCounts: + reasonCountsRaw.map((k, v) => MapEntry(k, (v as int?) ?? 0)), + reports: reportsRaw + .map((e) => + AdminQuarantineReport.fromJson((e as Map).cast())) + .toList(growable: false), + ); + } +} diff --git a/flutter_client/lib/models/admin_request.dart b/flutter_client/lib/models/admin_request.dart new file mode 100644 index 00000000..da7ba69a --- /dev/null +++ b/flutter_client/lib/models/admin_request.dart @@ -0,0 +1,64 @@ +/// Mirrors `requestView` from internal/api/requests.go. Server returns +/// these as a flat list (no envelope) from GET /api/admin/requests. +/// +/// Note the requester is exposed only as a UUID (`user_id`) — the +/// response does not include the username. The Flutter Requests screen +/// joins client-side against the AdminUser list for display. +class AdminRequest { + const AdminRequest({ + required this.id, + required this.userId, + required this.status, + required this.kind, + required this.artistName, + this.albumTitle, + this.trackTitle, + required this.requestedAt, + this.decidedAt, + this.notes, + required this.importedAlbumCount, + required this.importedTrackCount, + }); + + final String id; + final String userId; + + /// One of: pending, approved, rejected, completed, failed. + final String status; + + /// One of: artist, album, track. + final String kind; + + final String artistName; + final String? albumTitle; + final String? trackTitle; + final String requestedAt; + final String? decidedAt; + final String? notes; + final int importedAlbumCount; + final int importedTrackCount; + + /// Display label depending on the kind of request — what the user + /// asked for. For an album request it's the album title; for an + /// artist request it's the artist name; etc. + String get displayName => switch (kind) { + 'album' => albumTitle ?? artistName, + 'track' => trackTitle ?? artistName, + _ => artistName, + }; + + factory AdminRequest.fromJson(Map j) => AdminRequest( + id: j['id'] as String? ?? '', + userId: j['user_id'] as String? ?? '', + status: j['status'] as String? ?? 'pending', + kind: j['kind'] as String? ?? 'artist', + artistName: j['artist_name'] as String? ?? '', + albumTitle: j['album_title'] as String?, + trackTitle: j['track_title'] as String?, + requestedAt: j['requested_at'] as String? ?? '', + decidedAt: j['decided_at'] as String?, + notes: j['notes'] as String?, + importedAlbumCount: (j['imported_album_count'] as int?) ?? 0, + importedTrackCount: (j['imported_track_count'] as int?) ?? 0, + ); +} diff --git a/flutter_client/lib/models/admin_user.dart b/flutter_client/lib/models/admin_user.dart new file mode 100644 index 00000000..46c4a80b --- /dev/null +++ b/flutter_client/lib/models/admin_user.dart @@ -0,0 +1,33 @@ +/// Mirrors `adminUserView` from internal/api/admin_users.go. Distinct +/// from MyProfile because admin endpoints expose `auto_approve_requests` +/// and the canonical `created_at` that the user-scoped /me response +/// intentionally omits. +/// +/// The list endpoint wraps these in `{"users": [...]}`; the parsing of +/// the envelope is done in `AdminUsersApi`, not here. +class AdminUser { + const AdminUser({ + required this.id, + required this.username, + this.displayName, + required this.isAdmin, + required this.autoApproveRequests, + required this.createdAt, + }); + + final String id; + final String username; + final String? displayName; + final bool isAdmin; + final bool autoApproveRequests; + final String createdAt; + + factory AdminUser.fromJson(Map j) => AdminUser( + id: j['id'] as String? ?? '', + username: j['username'] as String? ?? '', + displayName: j['display_name'] as String?, + isAdmin: j['is_admin'] as bool? ?? false, + autoApproveRequests: j['auto_approve_requests'] as bool? ?? false, + createdAt: j['created_at'] as String? ?? '', + ); +} diff --git a/flutter_client/lib/models/invite.dart b/flutter_client/lib/models/invite.dart new file mode 100644 index 00000000..446a4394 --- /dev/null +++ b/flutter_client/lib/models/invite.dart @@ -0,0 +1,40 @@ +/// Mirrors `inviteResp` from internal/api/admin_invites.go. The list +/// endpoint wraps these in `{"invites": [...]}`; the create endpoint +/// returns a single bare invite. Envelope parsing is done in +/// `AdminInvitesApi`, not here. +/// +/// `invitedBy` is the UUID of the inviting admin (not their username); +/// the server doesn't currently denormalize it. Likewise `redeemedBy`. +/// TTL is hardcoded server-side at 24h, so admins can't customise the +/// expiry — only the optional `note`. +class Invite { + const Invite({ + required this.token, + required this.invitedBy, + this.note, + required this.createdAt, + required this.expiresAt, + this.redeemedAt, + this.redeemedBy, + }); + + final String token; + final String invitedBy; + final String? note; + final String createdAt; + final String expiresAt; + final String? redeemedAt; + final String? redeemedBy; + + bool get isRedeemed => redeemedAt != null; + + factory Invite.fromJson(Map j) => Invite( + token: j['token'] as String? ?? '', + invitedBy: j['invited_by'] as String? ?? '', + note: j['note'] as String?, + createdAt: j['created_at'] as String? ?? '', + expiresAt: j['expires_at'] as String? ?? '', + redeemedAt: j['redeemed_at'] as String?, + redeemedBy: j['redeemed_by'] as String?, + ); +}