Rewrites the internal/api comment blocks to rule 32: task, milestone and rule ids gone, the stories behind them restated as the constraint they left, stale references corrected (the stream secret is wired from config, the timezone handler already refreshes the scheduler, the Android app is the native client, the design docs cited are not in the repo). Also rewraps comment lines over 80 columns left by the earlier batches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
152 lines
5.4 KiB
Go
152 lines
5.4 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/notifications"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/quarantine"
|
|
)
|
|
|
|
// 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.Quarantine) 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.quarantine.Flag(r.Context(), user.ID, trackID, body.Reason, body.Notes)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, quarantine.ErrBadReason):
|
|
writeErr(w, apierror.BadRequest("bad_reason", "invalid reason"))
|
|
case errors.Is(err, quarantine.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. 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.quarantine.Unflag(r.Context(), user.ID, id); err != nil {
|
|
if errors.Is(err, quarantine.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.quarantine.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.Quarantine.TrackID),
|
|
Reason: string(row.Quarantine.Reason),
|
|
Notes: row.Quarantine.Notes,
|
|
CreatedAt: row.Quarantine.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)
|
|
}
|