feat(api): /api/quarantine user + admin endpoints + Service wiring
User-facing /api/quarantine handlers (POST flag, DELETE unflag, GET mine) plus the five /api/admin/quarantine endpoints (queue, resolve, delete-file, delete-via-lidarr, actions). Mount() and the handlers struct now accept the lidarrquarantine.Service, constructed in server.go alongside the existing lidarrrequests wiring.
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarr"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
|
||||
)
|
||||
|
||||
// adminQueueRowView is a single aggregated row in the admin queue response.
|
||||
type adminQueueRowView struct {
|
||||
TrackID string `json:"track_id"`
|
||||
TrackTitle string `json:"track_title"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumTitle string `json:"album_title"`
|
||||
AlbumID string `json:"album_id"`
|
||||
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
|
||||
ReportCount int32 `json:"report_count"`
|
||||
LatestAt string `json:"latest_at"`
|
||||
ReasonCounts map[string]int `json:"reason_counts"`
|
||||
Reports []adminQueueReportView `json:"reports"`
|
||||
}
|
||||
|
||||
// adminQueueReportView is one user's report under an aggregated admin row.
|
||||
type adminQueueReportView struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
Reason string `json:"reason"`
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// formatTimestamp renders a pgtype.Timestamptz as RFC3339 or empty string.
|
||||
func formatTimestamp(ts pgtype.Timestamptz) string {
|
||||
if !ts.Valid {
|
||||
return ""
|
||||
}
|
||||
return ts.Time.Format("2006-01-02T15:04:05Z07:00")
|
||||
}
|
||||
|
||||
// handleListAdminQuarantine implements GET /api/admin/quarantine.
|
||||
func (h *handlers) handleListAdminQuarantine(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := h.lidarrQuarantine.ListAdminQueue(r.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("admin: list quarantine", "err", err)
|
||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||
return
|
||||
}
|
||||
out := make([]adminQueueRowView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
reports := make([]adminQueueReportView, 0, len(row.Reports))
|
||||
for _, rep := range row.Reports {
|
||||
reports = append(reports, adminQueueReportView{
|
||||
UserID: uuidToString(rep.UserID),
|
||||
Username: rep.Username,
|
||||
Reason: rep.Reason,
|
||||
Notes: rep.Notes,
|
||||
CreatedAt: formatTimestamp(rep.CreatedAt),
|
||||
})
|
||||
}
|
||||
out = append(out, adminQueueRowView{
|
||||
TrackID: uuidToString(row.TrackID),
|
||||
TrackTitle: row.TrackTitle,
|
||||
ArtistName: row.ArtistName,
|
||||
AlbumTitle: row.AlbumTitle,
|
||||
AlbumID: uuidToString(row.AlbumID),
|
||||
LidarrAlbumMBID: row.LidarrAlbumMBID,
|
||||
ReportCount: row.ReportCount,
|
||||
LatestAt: formatTimestamp(row.LatestAt),
|
||||
ReasonCounts: row.ReasonCounts,
|
||||
Reports: reports,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// actionResultView is the response shape for the three admin destructive
|
||||
// actions (resolve, delete-file, delete-via-lidarr).
|
||||
type actionResultView struct {
|
||||
ActionID string `json:"action_id"`
|
||||
AffectedUsers int32 `json:"affected_users"`
|
||||
DeletedTrackCount *int `json:"deleted_track_count,omitempty"`
|
||||
}
|
||||
|
||||
// handleResolveQuarantine implements POST /api/admin/quarantine/{track_id}/resolve.
|
||||
func (h *handlers) handleResolveQuarantine(w http.ResponseWriter, r *http.Request) {
|
||||
admin, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, ok := parseUUID(chi.URLParam(r, "track_id"))
|
||||
if !ok {
|
||||
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||
return
|
||||
}
|
||||
action, err := h.lidarrQuarantine.Resolve(r.Context(), id, admin.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, lidarrquarantine.ErrTrackNotFound) {
|
||||
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
||||
return
|
||||
}
|
||||
h.logger.Error("admin: resolve quarantine", "err", err)
|
||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, actionResultView{
|
||||
ActionID: uuidToString(action.ID),
|
||||
AffectedUsers: action.AffectedUsers,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteQuarantineFile implements POST /api/admin/quarantine/{track_id}/delete-file.
|
||||
func (h *handlers) handleDeleteQuarantineFile(w http.ResponseWriter, r *http.Request) {
|
||||
admin, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, ok := parseUUID(chi.URLParam(r, "track_id"))
|
||||
if !ok {
|
||||
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||
return
|
||||
}
|
||||
action, err := h.lidarrQuarantine.DeleteFile(r.Context(), id, admin.ID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
|
||||
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
||||
default:
|
||||
h.logger.Error("admin: delete file", "err", err)
|
||||
writeAdminJSONErr(w, http.StatusInternalServerError, "file_delete_failed")
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, actionResultView{
|
||||
ActionID: uuidToString(action.ID),
|
||||
AffectedUsers: action.AffectedUsers,
|
||||
})
|
||||
}
|
||||
|
||||
// handleDeleteQuarantineViaLidarr implements POST /api/admin/quarantine/{track_id}/delete-via-lidarr.
|
||||
func (h *handlers) handleDeleteQuarantineViaLidarr(w http.ResponseWriter, r *http.Request) {
|
||||
admin, ok := auth.UserFromContext(r.Context())
|
||||
if !ok {
|
||||
writeAdminJSONErr(w, http.StatusUnauthorized, "unauthorized")
|
||||
return
|
||||
}
|
||||
id, ok := parseUUID(chi.URLParam(r, "track_id"))
|
||||
if !ok {
|
||||
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
||||
return
|
||||
}
|
||||
action, deleted, err := h.lidarrQuarantine.DeleteViaLidarr(r.Context(), id, admin.ID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, lidarrquarantine.ErrLidarrDisabled):
|
||||
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_disabled")
|
||||
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
|
||||
writeAdminJSONErr(w, http.StatusNotFound, "track_not_found")
|
||||
case errors.Is(err, lidarrquarantine.ErrAlbumMBIDMissing):
|
||||
writeAdminJSONErr(w, http.StatusNotFound, "album_mbid_missing")
|
||||
case errors.Is(err, lidarrquarantine.ErrLidarrAlbumNotFound):
|
||||
writeAdminJSONErr(w, http.StatusBadGateway, "lidarr_album_lookup_failed")
|
||||
case errors.Is(err, lidarr.ErrUnreachable):
|
||||
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_unreachable")
|
||||
case errors.Is(err, lidarr.ErrAuthFailed):
|
||||
writeAdminJSONErr(w, http.StatusServiceUnavailable, "lidarr_auth_failed")
|
||||
default:
|
||||
h.logger.Error("admin: delete via lidarr", "err", err)
|
||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, actionResultView{
|
||||
ActionID: uuidToString(action.ID),
|
||||
AffectedUsers: action.AffectedUsers,
|
||||
DeletedTrackCount: &deleted,
|
||||
})
|
||||
}
|
||||
|
||||
// actionLogView is the row shape returned by GET /api/admin/quarantine/actions.
|
||||
type actionLogView struct {
|
||||
ID string `json:"id"`
|
||||
TrackID string `json:"track_id"`
|
||||
TrackTitle string `json:"track_title"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumTitle *string `json:"album_title,omitempty"`
|
||||
Action string `json:"action"`
|
||||
AdminID *string `json:"admin_id,omitempty"`
|
||||
LidarrAlbumMBID *string `json:"lidarr_album_mbid,omitempty"`
|
||||
AffectedUsers int32 `json:"affected_users"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
// handleListQuarantineActions implements GET /api/admin/quarantine/actions.
|
||||
// ?limit= defaults to 50, capped at 200.
|
||||
func (h *handlers) handleListQuarantineActions(w http.ResponseWriter, r *http.Request) {
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
limit := int32(50)
|
||||
if limitStr != "" {
|
||||
if v, err := strconv.Atoi(limitStr); err == nil && v > 0 {
|
||||
if v > 200 {
|
||||
v = 200
|
||||
}
|
||||
limit = int32(v)
|
||||
}
|
||||
}
|
||||
rows, err := dbq.New(h.pool).ListQuarantineActions(r.Context(), limit)
|
||||
if err != nil {
|
||||
h.logger.Error("admin: list actions", "err", err)
|
||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||
return
|
||||
}
|
||||
out := make([]actionLogView, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
var adminID *string
|
||||
if row.AdminID.Valid {
|
||||
s := uuidToString(row.AdminID)
|
||||
adminID = &s
|
||||
}
|
||||
out = append(out, actionLogView{
|
||||
ID: uuidToString(row.ID),
|
||||
TrackID: uuidToString(row.TrackID),
|
||||
TrackTitle: row.TrackTitle,
|
||||
ArtistName: row.ArtistName,
|
||||
AlbumTitle: row.AlbumTitle,
|
||||
Action: string(row.Action),
|
||||
AdminID: adminID,
|
||||
LidarrAlbumMBID: row.LidarrAlbumMbid,
|
||||
AffectedUsers: row.AffectedUsers,
|
||||
CreatedAt: formatTimestamp(row.CreatedAt),
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
Reference in New Issue
Block a user