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>
191 lines
5.7 KiB
Go
191 lines
5.7 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"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/notifications"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/requests"
|
|
"git.fabledsword.com/bvandeusen/minstrel/internal/wanted"
|
|
)
|
|
|
|
// validRequestStatuses is the set of allowed values for the ?status= param.
|
|
var validRequestStatuses = map[string]bool{
|
|
"pending": true,
|
|
"approved": true,
|
|
"rejected": true,
|
|
"completed": true,
|
|
"failed": true,
|
|
}
|
|
|
|
// handleListAdminRequests implements GET /api/admin/requests.
|
|
// ?status= defaults to "pending"; ?limit= defaults to 50, max 200.
|
|
func (h *handlers) handleListAdminRequests(w http.ResponseWriter, r *http.Request) {
|
|
status := r.URL.Query().Get("status")
|
|
if status == "" {
|
|
status = "pending"
|
|
}
|
|
if !validRequestStatuses[status] {
|
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_status")
|
|
return
|
|
}
|
|
|
|
limitStr := r.URL.Query().Get("limit")
|
|
limit := 50
|
|
if limitStr != "" {
|
|
v, err := strconv.Atoi(limitStr)
|
|
if err != nil || v <= 0 {
|
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_limit")
|
|
return
|
|
}
|
|
if v > 200 {
|
|
v = 200
|
|
}
|
|
limit = v
|
|
}
|
|
|
|
rows, err := h.requests.ListByStatus(r.Context(), status, int32(limit))
|
|
if err != nil {
|
|
h.logger.Error("admin: list requests", "err", err)
|
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
|
return
|
|
}
|
|
|
|
q := dbq.New(h.pool)
|
|
names := requesterNames(r.Context(), q, rows)
|
|
out := make([]requestView, 0, len(rows))
|
|
for _, row := range rows {
|
|
v := requestViewFrom(row)
|
|
v.Username = names[row.UserID]
|
|
fillProgress(r.Context(), q, row, &v)
|
|
out = append(out, v)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
// requesterNames maps each request's user id to that user's username, in one
|
|
// query for the whole page. Like fillProgress, a failed lookup is swallowed:
|
|
// the names are a reporting nicety, and the page falls back to the id.
|
|
func requesterNames(ctx context.Context, q *dbq.Queries, rows []dbq.Request) map[pgtype.UUID]string {
|
|
ids := make([]pgtype.UUID, 0, len(rows))
|
|
for _, row := range rows {
|
|
ids = append(ids, row.UserID)
|
|
}
|
|
names := make(map[pgtype.UUID]string, len(rows))
|
|
users, err := q.ListUsernamesByIDs(ctx, ids)
|
|
if err != nil {
|
|
return names
|
|
}
|
|
for _, u := range users {
|
|
names[u.ID] = u.Username
|
|
}
|
|
return names
|
|
}
|
|
|
|
// approveRequestBody is the optional JSON body for POST
|
|
// /api/admin/requests/:id/approve. profile_id picks the release-type profile an
|
|
// artist request is followed on; omitted, the default profile.
|
|
type approveRequestBody struct {
|
|
ProfileID string `json:"profile_id"`
|
|
}
|
|
|
|
// handleApproveRequest implements POST /api/admin/requests/:id/approve. The
|
|
// request goes on the wanted list: an album or track request
|
|
// wants its album, an artist request follows the artist.
|
|
func (h *handlers) handleApproveRequest(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, "id"))
|
|
if !ok {
|
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
|
return
|
|
}
|
|
|
|
var body approveRequestBody
|
|
// Body is entirely optional; ignore decode errors.
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
var profile pgtype.UUID
|
|
if body.ProfileID != "" {
|
|
if profile, ok = parseUUID(body.ProfileID); !ok {
|
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_profile")
|
|
return
|
|
}
|
|
}
|
|
|
|
row, err := h.requests.Approve(r.Context(), id, admin.ID, profile)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, requests.ErrNotFound):
|
|
writeAdminJSONErr(w, http.StatusNotFound, "request_not_found")
|
|
case errors.Is(err, requests.ErrNotPending):
|
|
writeAdminJSONErr(w, http.StatusConflict, "request_not_pending")
|
|
case errors.Is(err, wanted.ErrProfileNotFound):
|
|
writeAdminJSONErr(w, http.StatusBadRequest, "profile_not_found")
|
|
case errors.Is(err, wanted.ErrNotFollowable):
|
|
writeAdminJSONErr(w, http.StatusBadRequest, "not_followable")
|
|
default:
|
|
h.logger.Error("admin: approve request", "err", err)
|
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
|
}
|
|
return
|
|
}
|
|
|
|
h.notifyRequestDecided(r.Context(), notifications.KindRequestApproved, admin, row, "")
|
|
h.publishRequestStatusChanged(row)
|
|
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
|
}
|
|
|
|
// rejectRequestBody is the optional JSON body for POST
|
|
// /api/admin/requests/:id/reject.
|
|
type rejectRequestBody struct {
|
|
Notes string `json:"notes"`
|
|
}
|
|
|
|
// handleRejectRequest implements POST /api/admin/requests/:id/reject.
|
|
func (h *handlers) handleRejectRequest(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, "id"))
|
|
if !ok {
|
|
writeAdminJSONErr(w, http.StatusBadRequest, "invalid_id")
|
|
return
|
|
}
|
|
|
|
var body rejectRequestBody
|
|
_ = json.NewDecoder(r.Body).Decode(&body)
|
|
|
|
row, err := h.requests.Reject(r.Context(), id, admin.ID, body.Notes)
|
|
if err != nil {
|
|
switch {
|
|
case errors.Is(err, requests.ErrNotFound):
|
|
writeAdminJSONErr(w, http.StatusNotFound, "request_not_found")
|
|
case errors.Is(err, requests.ErrNotPending):
|
|
writeAdminJSONErr(w, http.StatusConflict, "request_not_pending")
|
|
default:
|
|
h.logger.Error("admin: reject request", "err", err)
|
|
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
|
}
|
|
return
|
|
}
|
|
|
|
h.notifyRequestDecided(r.Context(), notifications.KindRequestRejected, admin, row, body.Notes)
|
|
h.publishRequestStatusChanged(row)
|
|
writeJSON(w, http.StatusOK, requestViewFrom(row))
|
|
}
|