Files
minstrel/internal/api/requests.go
T
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
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>
2026-10-23 12:23:58 -04:00

265 lines
8.8 KiB
Go

package api
import (
"context"
"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/requests"
)
// requestView is the JSON shape returned by all /api/requests handlers.
// Field names follow the lowercase snake_case convention for /api/* responses.
type requestView struct {
ID pgtype.UUID `json:"id"`
UserID pgtype.UUID `json:"user_id"`
// Username names the requester. Only the admin list fills it; a user's
// own /requests page has no need to name them back to themselves.
Username string `json:"username,omitempty"`
Status string `json:"status"`
Kind string `json:"kind"`
ArtistMBID string `json:"artist_mbid"`
AlbumMBID *string `json:"album_mbid,omitempty"`
TrackMBID *string `json:"track_mbid,omitempty"`
ArtistName string `json:"artist_name"`
AlbumTitle *string `json:"album_title,omitempty"`
TrackTitle *string `json:"track_title,omitempty"`
DecidedAt pgtype.Timestamptz `json:"decided_at,omitempty"`
DecidedBy pgtype.UUID `json:"decided_by,omitempty"`
Notes *string `json:"notes,omitempty"`
CompletedAt pgtype.Timestamptz `json:"completed_at,omitempty"`
MatchedTrackID pgtype.UUID `json:"matched_track_id,omitempty"`
MatchedAlbumID pgtype.UUID `json:"matched_album_id,omitempty"`
MatchedArtistID pgtype.UUID `json:"matched_artist_id,omitempty"`
RequestedAt pgtype.Timestamptz `json:"requested_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
// Progress counters, computed at response time from the matched
// entity's children. 0 when nothing has been ingested yet (or when
// the request has no matched entity yet). For artist requests both
// counters fill; for album/track requests only ImportedTrackCount.
ImportedAlbumCount int `json:"imported_album_count"`
ImportedTrackCount int `json:"imported_track_count"`
}
func requestViewFrom(row dbq.Request) requestView {
return requestView{
ID: row.ID,
UserID: row.UserID,
Status: string(row.Status),
Kind: string(row.Kind),
ArtistMBID: row.ArtistMbid,
AlbumMBID: row.AlbumMbid,
TrackMBID: row.TrackMbid,
ArtistName: row.ArtistName,
AlbumTitle: row.AlbumTitle,
TrackTitle: row.TrackTitle,
DecidedAt: row.DecidedAt,
DecidedBy: row.DecidedBy,
Notes: row.Notes,
CompletedAt: row.CompletedAt,
MatchedTrackID: row.MatchedTrackID,
MatchedAlbumID: row.MatchedAlbumID,
MatchedArtistID: row.MatchedArtistID,
RequestedAt: row.RequestedAt,
UpdatedAt: row.UpdatedAt,
}
}
// fillProgress fills ImportedAlbumCount + ImportedTrackCount on a view by
// querying the count of children for the request's matched entity.
//
// kind=artist → counts all albums + tracks under the matched artist.
// kind=album → counts tracks under the matched album.
// kind=track → ImportedTrackCount = 1 once matched_track_id is set.
//
// N+1 against the request list: acceptable at admin scale (dozens of
// in-flight requests at most). Errors are swallowed — progress is a
// reporting nicety, not a correctness gate.
func fillProgress(ctx context.Context, q *dbq.Queries, row dbq.Request, view *requestView) {
switch row.Kind {
case dbq.RequestKindArtist:
if !row.MatchedArtistID.Valid {
return
}
if n, err := q.CountAlbumsByArtist(ctx, row.MatchedArtistID); err == nil {
view.ImportedAlbumCount = int(n)
}
if n, err := q.CountTracksByArtist(ctx, row.MatchedArtistID); err == nil {
view.ImportedTrackCount = int(n)
}
case dbq.RequestKindAlbum:
if !row.MatchedAlbumID.Valid {
return
}
view.ImportedAlbumCount = 1
if n, err := q.CountTracksByAlbum(ctx, row.MatchedAlbumID); err == nil {
view.ImportedTrackCount = int(n)
}
case dbq.RequestKindTrack:
if row.MatchedTrackID.Valid {
view.ImportedTrackCount = 1
}
}
}
// createRequestBody is the decoded JSON for POST /api/requests. album_mbid
// is the album's MusicBrainz release group, as catalogue search returns it.
type createRequestBody struct {
Kind string `json:"kind"`
ArtistMBID string `json:"artist_mbid"`
AlbumMBID string `json:"album_mbid"`
TrackMBID string `json:"track_mbid"`
ArtistName string `json:"artist_name"`
AlbumTitle string `json:"album_title"`
TrackTitle string `json:"track_title"`
}
// handleCreateRequest implements POST /api/requests.
func (h *handlers) handleCreateRequest(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
var body createRequestBody
if !decodeBody(w, r, &body) {
return
}
row, created, err := h.requests.CreateTracked(r.Context(), user.ID, requests.CreateParams{
Kind: body.Kind,
ArtistMBID: body.ArtistMBID,
AlbumMBID: body.AlbumMBID,
TrackMBID: body.TrackMBID,
ArtistName: body.ArtistName,
AlbumTitle: body.AlbumTitle,
TrackTitle: body.TrackTitle,
})
if err != nil {
if errors.Is(err, requests.ErrInvalidKindFields) {
writeErr(w, apierror.BadRequest("mbid_required", err.Error()))
return
}
h.logger.Error("api: create request", "err", err)
writeErr(w, apierror.InternalMsg("create failed", err))
return
}
// Auto-approve: when an admin has set the user's
// auto_approve_requests flag, the new request goes straight onto the
// wanted list as if approved. A failure leaves it pending for an admin,
// the same as a manual approval hitting the same error. The approver is
// the user themselves, acting under the privilege the admin granted; the
// grant is in audit_log (ActionAutoApproveToggle).
if user.AutoApproveRequests {
approved, aerr := h.requests.Approve(r.Context(), row.ID, user.ID, pgtype.UUID{})
if aerr == nil {
row = approved
} else {
h.logger.Warn("api: auto-approve failed; request stays pending",
"user_id", uuidToString(user.ID),
"request_id", uuidToString(row.ID),
"err", aerr)
}
}
// A new request still pending after any auto-approval waits on an admin.
// One that deduped into a request already in flight was announced when it
// was first made, and the requester, if they are an admin themselves,
// needs no notice of their own request.
if created && row.Status == dbq.RequestStatusPending {
p := requestPayload(row)
p.Actor = userLabel(user)
h.notifier.NotifyLogged(r.Context(), notifications.KindRequestPending, notifications.ToAdmins(user.ID), p.Map())
}
h.publishRequestStatusChanged(row)
writeJSON(w, http.StatusCreated, requestViewFrom(row))
}
// handleListRequests implements GET /api/requests — returns caller's own
// requests.
func (h *handlers) handleListRequests(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
rows, err := h.requests.ListForUser(r.Context(), user.ID, 100)
if err != nil {
h.logger.Error("api: list requests", "err", err)
writeErr(w, apierror.InternalMsg("list failed", err))
return
}
q := dbq.New(h.pool)
out := make([]requestView, 0, len(rows))
for _, row := range rows {
v := requestViewFrom(row)
fillProgress(r.Context(), q, row, &v)
out = append(out, v)
}
writeJSON(w, http.StatusOK, out)
}
// handleGetRequest implements GET /api/requests/:id.
// The caller must own the request, or be an admin.
func (h *handlers) handleGetRequest(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
row, apiErr := resolveByID(r, "id", dbq.New(h.pool).GetRequestByID, "request")
if apiErr != nil {
writeErr(w, apiErr)
return
}
// Allow if caller owns the request or is an admin.
if row.UserID != user.ID && !user.IsAdmin {
writeErr(w, apierror.NotFound("request"))
return
}
v := requestViewFrom(row)
fillProgress(r.Context(), dbq.New(h.pool), row, &v)
writeJSON(w, http.StatusOK, v)
}
// handleCancelRequest implements DELETE /api/requests/:id.
// Cancels the caller's own pending request.
func (h *handlers) handleCancelRequest(w http.ResponseWriter, r *http.Request) {
user, ok := requireUser(w, r)
if !ok {
return
}
id, ok := requireURLUUID(w, r, "id")
if !ok {
return
}
row, err := h.requests.Cancel(r.Context(), id, user.ID)
if err != nil {
switch {
case errors.Is(err, requests.ErrNotPending):
writeErr(w, apierror.Conflict("request_not_pending", "request is not pending"))
case errors.Is(err, requests.ErrNotFound):
writeErr(w, apierror.NotFound("request"))
default:
h.logger.Error("api: cancel request", "err", err)
writeErr(w, apierror.InternalMsg("cancel failed", err))
}
return
}
h.publishRequestStatusChanged(row)
writeJSON(w, http.StatusOK, requestViewFrom(row))
}