feat(flutter): admin DTOs (request, quarantine item, user, invite)

Hand-rolled to match the server's actual JSON shapes:
- AdminRequest carries user_id (UUID, not username); kind switches
  which title field is the display name
- AdminQuarantineItem aggregates reports per track with reason_counts
  and a nested reports[] list, mirroring adminQueueRowView
- AdminUser includes display_name + auto_approve_requests
- Invite uses invited_by UUID + note (server hardcodes 24h TTL)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 21:51:33 -04:00
parent dfc08650e7
commit 2b595c40cd
4 changed files with 229 additions and 0 deletions
@@ -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<String, dynamic> 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<String, int> reasonCounts;
final List<AdminQuarantineReport> reports;
/// One-line summary of the most-cited reason. Returns "<top>" when
/// only one reason exists or "<top> (+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<String, dynamic> j) {
final reasonCountsRaw =
(j['reason_counts'] as Map?)?.cast<String, dynamic>() ?? 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<String, dynamic>()))
.toList(growable: false),
);
}
}
@@ -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<String, dynamic> 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,
);
}
+33
View File
@@ -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<String, dynamic> 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? ?? '',
);
}
+40
View File
@@ -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<String, dynamic> 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?,
);
}