Files
minstrel/internal/api/quarantine.go
T
bvandeusenandClaude Opus 5.5 069baeb14d feat(notifications): requests and flags reach the people who act on them (#5340)
- A new request still pending after any auto-approval notifies the
  admins (request_pending), but not the requester if they are an admin.
  A request that dedups into one already in flight is not announced again.
- Approving or rejecting a request notifies the requester, and a
  rejection carries the admin's notes as the reason. An admin deciding
  their own request gets nothing.
- The reconciler notifies the requester when their request arrives
  (request_completed), linking the matched album or artist.
- A request the re-acquisition sweeper files and cannot approve itself
  notifies the admins.
- A quarantine flag notifies every admin except the flagger, naming the
  track, the flagger and the reason.

lidarrrequests.Service.CreateTracked reports whether a request was
inserted or deduped; Create wraps it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:17:11 -04:00

152 lines
5.5 KiB
Go

package api
import (
"errors"
"net/http"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/lidarrquarantine"
"git.fabledsword.com/bvandeusen/minstrel/internal/notifications"
)
// quarantineView is the JSON shape returned by the user-facing endpoints
// that operate on a single quarantine row (POST /api/quarantine).
type quarantineView struct {
UserID string `json:"user_id"`
TrackID string `json:"track_id"`
Reason string `json:"reason"`
Notes *string `json:"notes,omitempty"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
}
func quarantineViewFrom(row dbq.LidarrQuarantine) quarantineView {
return quarantineView{
UserID: uuidToString(row.UserID),
TrackID: uuidToString(row.TrackID),
Reason: string(row.Reason),
Notes: row.Notes,
CreatedAt: row.CreatedAt,
}
}
// flagBody is the JSON body for POST /api/quarantine. track_id is sent as
// the canonical hyphenated string the SPA already gets from /api/tracks/*.
type flagBody struct {
TrackID string `json:"track_id"`
Reason string `json:"reason"`
Notes string `json:"notes"`
}
// handleFlag implements POST /api/quarantine. Upserts via Service.Flag.
func (h *handlers) handleFlag(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
var body flagBody
if !decodeBody(w, r, &body) {
return
}
trackID, ok := parseUUID(body.TrackID)
if !ok {
writeErr(w, apierror.BadRequest("bad_request", "invalid track id"))
return
}
row, err := h.lidarrQuarantine.Flag(r.Context(), user.ID, trackID, body.Reason, body.Notes)
if err != nil {
switch {
case errors.Is(err, lidarrquarantine.ErrBadReason):
writeErr(w, apierror.BadRequest("bad_reason", "invalid reason"))
case errors.Is(err, lidarrquarantine.ErrTrackNotFound):
writeErr(w, &apierror.Error{Status: 404, Code: "track_not_found", Message: "track does not exist"})
default:
h.logger.Error("api: flag", "err", err)
writeErr(w, apierror.InternalMsg("flag failed", err))
}
return
}
// Broadcast: the flagging user's other clients invalidate their
// Hidden tab; admins' clients invalidate their quarantine queue.
h.publishQuarantineEvent("quarantine.flagged", user.ID, trackID, true)
// Admins review flags (M489). An admin flagging a track needs no notice
// of their own flag.
h.notifier.NotifyLogged(r.Context(), notifications.KindQuarantineFlagged, notifications.ToAdmins(user.ID),
notifications.Payload{Name: h.trackLabel(r.Context(), trackID), Actor: userLabel(user), Reason: quarantineReasonLabel(body.Reason)}.Map())
writeJSON(w, http.StatusCreated, quarantineViewFrom(row))
}
// handleUnflag implements DELETE /api/quarantine/{track_id}.
func (h *handlers) handleUnflag(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
id, ok := requireURLUUID(w, r, "track_id")
if !ok {
return
}
if err := h.lidarrQuarantine.Unflag(r.Context(), user.ID, id); err != nil {
if errors.Is(err, lidarrquarantine.ErrQuarantineNotFound) {
writeErr(w, &apierror.Error{Status: 404, Code: "quarantine_not_found", Message: "no quarantine for that track"})
return
}
h.logger.Error("api: unflag", "err", err)
writeErr(w, apierror.InternalMsg("unflag failed", err))
return
}
// Scoped to user: only their other clients need to invalidate.
h.publishQuarantineEvent("quarantine.unflagged", user.ID, id, false)
w.WriteHeader(http.StatusNoContent)
}
// quarantineMineView is the per-row shape returned by GET /api/quarantine/mine,
// composed from the joined ListQuarantineForUserRow.
type quarantineMineView struct {
TrackID string `json:"track_id"`
Reason string `json:"reason"`
Notes *string `json:"notes,omitempty"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
TrackTitle string `json:"track_title"`
TrackDurationMs int32 `json:"track_duration_ms"`
AlbumID string `json:"album_id"`
AlbumTitle string `json:"album_title"`
AlbumCoverArtPath *string `json:"album_cover_art_path,omitempty"`
ArtistID string `json:"artist_id"`
ArtistName string `json:"artist_name"`
}
// handleListMyQuarantine implements GET /api/quarantine/mine. Returns the
// caller's quarantines with track/album/artist detail.
func (h *handlers) handleListMyQuarantine(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
rows, err := h.lidarrQuarantine.ListMine(r.Context(), user.ID)
if err != nil {
h.logger.Error("api: list mine", "err", err)
writeErr(w, apierror.InternalMsg("list failed", err))
return
}
out := make([]quarantineMineView, 0, len(rows))
for _, row := range rows {
out = append(out, quarantineMineView{
TrackID: uuidToString(row.LidarrQuarantine.TrackID),
Reason: string(row.LidarrQuarantine.Reason),
Notes: row.LidarrQuarantine.Notes,
CreatedAt: row.LidarrQuarantine.CreatedAt,
TrackTitle: row.TrackTitle,
TrackDurationMs: row.TrackDurationMs,
AlbumID: uuidToString(row.AlbumID),
AlbumTitle: row.AlbumTitle,
AlbumCoverArtPath: row.AlbumCoverArtPath,
ArtistID: uuidToString(row.ArtistID),
ArtistName: row.ArtistName,
})
}
writeJSON(w, http.StatusOK, out)
}