// Package requests owns the lifecycle of what users ask to have added. The // synchronous Service handles Create/List/Approve/Reject/Cancel; approving // records the request on the wanted list (M502 #5520): an album or a track // wants its album's release group, an artist is followed. The async // Reconciler (separate file) completes approved requests once the library // holds what they asked for. package requests import ( "context" "errors" "fmt" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/wanted" ) // Public errors. Handlers map these to API error codes. var ( ErrInvalidKindFields = errors.New("requests: missing required fields for kind") ErrNotPending = errors.New("requests: request is not pending") // ErrNotFound aliases apierror.ErrNotFound so handlers can errors.Is // against the shared sentinel. ErrNotFound = apierror.ErrNotFound ) // CreateParams is the input for a new request from a user. AlbumMBID is the // album's MusicBrainz release group. type CreateParams struct { Kind string // "artist", "album", or "track" ArtistMBID string AlbumMBID string // required for kind=album/track TrackMBID string // required for kind=track ArtistName string AlbumTitle string // required for kind=album/track TrackTitle string // required for kind=track } // Service owns the request lifecycle. type Service struct { pool *pgxpool.Pool } // NewService constructs a Service. func NewService(pool *pgxpool.Pool) *Service { return &Service{pool: pool} } // Create validates the kind→required-fields invariant and inserts a // pending row. func (s *Service) Create(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.Request, error) { row, _, err := s.CreateTracked(ctx, userID, p) return row, err } // CreateTracked is Create, also reporting whether a row was inserted. A // request that dedupes into one already in flight is not new, so callers // that announce new requests (M489) say nothing for it. func (s *Service) CreateTracked(ctx context.Context, userID pgtype.UUID, p CreateParams) (dbq.Request, bool, error) { if err := validateKindFields(p); err != nil { return dbq.Request{}, false, err } q := dbq.New(s.pool) // Dedup: if a non-terminal request for this MBID already exists, // return it instead of inserting a duplicate. The Discover/search UI // already hides requested candidates; this backstops races and direct // API callers. Match on the new request's own-kind MBID. dedupMBID := p.ArtistMBID switch p.Kind { case "album": dedupMBID = p.AlbumMBID case "track": dedupMBID = p.TrackMBID } if existing, derr := q.GetNonTerminalRequestForMBID(ctx, dedupMBID); derr == nil { return existing, false, nil } else if !errors.Is(derr, pgx.ErrNoRows) { return dbq.Request{}, false, fmt.Errorf("create: dedup check: %w", derr) } row, err := q.CreateRequest(ctx, dbq.CreateRequestParams{ UserID: userID, Kind: dbq.RequestKind(p.Kind), ArtistMbid: p.ArtistMBID, AlbumMbid: strPtr(p.AlbumMBID), TrackMbid: strPtr(p.TrackMBID), ArtistName: p.ArtistName, AlbumTitle: strPtr(p.AlbumTitle), TrackTitle: strPtr(p.TrackTitle), }) if err != nil { return dbq.Request{}, false, fmt.Errorf("create: %w", err) } return row, true, nil } // DisplayName is a request as a person would say it: "Moe Shop" for an // artist, "Moe Shop – WWW" for an album or a track. func DisplayName(row dbq.Request) string { if t := requestedTitle(row); t != "" { return row.ArtistName + " – " + t } return row.ArtistName } // requestedTitle is the album or track a request names, or "" for an // artist request. DisplayName is the artist and this, joined. func requestedTitle(row dbq.Request) string { switch { case row.Kind == dbq.RequestKindTrack && row.TrackTitle != nil: return *row.TrackTitle case row.Kind == dbq.RequestKindAlbum && row.AlbumTitle != nil: return *row.AlbumTitle } return "" } func validateKindFields(p CreateParams) error { if p.ArtistMBID == "" || p.ArtistName == "" { return fmt.Errorf("%w: artist_mbid and artist_name are always required", ErrInvalidKindFields) } switch p.Kind { case "artist": // fine case "album": if p.AlbumMBID == "" || p.AlbumTitle == "" { return fmt.Errorf("%w: album kind requires album_mbid and album_title", ErrInvalidKindFields) } case "track": if p.AlbumMBID == "" || p.AlbumTitle == "" { return fmt.Errorf("%w: track kind requires album_mbid and album_title (its album is what is wanted)", ErrInvalidKindFields) } if p.TrackMBID == "" || p.TrackTitle == "" { return fmt.Errorf("%w: track kind requires track_mbid and track_title", ErrInvalidKindFields) } default: return fmt.Errorf("%w: unknown kind %q", ErrInvalidKindFields, p.Kind) } return nil } // ListByStatus returns requests with the given status, ordered by requested_at DESC. func (s *Service) ListByStatus(ctx context.Context, status string, limit int32) ([]dbq.Request, error) { return dbq.New(s.pool).ListRequestsByStatus(ctx, dbq.ListRequestsByStatusParams{ Status: dbq.RequestStatus(status), Limit: limit, }) } // ListForUser returns all requests by a user, ordered by requested_at DESC. func (s *Service) ListForUser(ctx context.Context, userID pgtype.UUID, limit int32) ([]dbq.Request, error) { return dbq.New(s.pool).ListRequestsForUser(ctx, dbq.ListRequestsForUserParams{ UserID: userID, Limit: limit, }) } // Approve puts a pending request on the wanted list and marks it approved, // in one transaction. An album or track request wants the album's release // group, recorded as the requester's. An artist request follows the artist // on profileID (the zero id means the default profile) and also wants what // they have already released that the profile passes: someone asking for an // artist wants their music, not only what comes next. func (s *Service) Approve(ctx context.Context, requestID, approverID, profileID pgtype.UUID) (dbq.Request, error) { var approved dbq.Request err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { q := dbq.New(tx) row, err := q.GetRequestByID(ctx, requestID) if errors.Is(err, pgx.ErrNoRows) { return ErrNotFound } if err != nil { return fmt.Errorf("approve: get: %w", err) } if row.Status != dbq.RequestStatusPending { return ErrNotPending } switch row.Kind { case dbq.RequestKindArtist: if _, err := wanted.FollowWith(ctx, q, wanted.FollowParams{ ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName, ProfileID: profileID, By: approverID, WantExisting: true, }); err != nil { return err } default: if _, err := wanted.WantWith(ctx, q, wantFor(row)); err != nil { return err } } approved, err = q.ApproveRequest(ctx, dbq.ApproveRequestParams{ID: requestID, DecidedBy: approverID}) if err != nil { return fmt.Errorf("approve: persist: %w", err) } return nil }) return approved, err } // wantFor is the want an approved album or track request records. func wantFor(row dbq.Request) wanted.WantParams { p := wanted.WantParams{ ArtistMBID: row.ArtistMbid, ArtistName: row.ArtistName, Reason: wanted.ReasonRequest, CreatedBy: row.UserID, } if row.AlbumMbid != nil { p.GroupMBID = *row.AlbumMbid } if row.AlbumTitle != nil { p.Title = *row.AlbumTitle } return p } // Reject transitions a pending request to rejected, recording notes and // who decided. Returns ErrNotPending if the request is not pending, // ErrNotFound if no such request exists. func (s *Service) Reject(ctx context.Context, requestID pgtype.UUID, adminID pgtype.UUID, notes string) (dbq.Request, error) { row, err := dbq.New(s.pool).RejectRequest(ctx, dbq.RejectRequestParams{ ID: requestID, Notes: strPtr(notes), DecidedBy: adminID, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { // Either not found OR not pending — check which. cur, gerr := dbq.New(s.pool).GetRequestByID(ctx, requestID) if gerr != nil { return dbq.Request{}, ErrNotFound } if cur.Status != dbq.RequestStatusPending { return dbq.Request{}, ErrNotPending } return dbq.Request{}, ErrNotFound } return dbq.Request{}, fmt.Errorf("reject: %w", err) } return row, nil } // Cancel lets a user withdraw their own pending request. The SQL WHERE // clause enforces both ownership (user_id = $2) and pending status // (status = 'pending'). A zero-rows result always means ErrNotPending // from the caller's perspective. func (s *Service) Cancel(ctx context.Context, requestID pgtype.UUID, userID pgtype.UUID) (dbq.Request, error) { row, err := dbq.New(s.pool).CancelRequest(ctx, dbq.CancelRequestParams{ ID: requestID, DecidedBy: userID, }) if err != nil { if errors.Is(err, pgx.ErrNoRows) { return dbq.Request{}, ErrNotPending } return dbq.Request{}, fmt.Errorf("cancel: %w", err) } return row, nil } func strPtr(s string) *string { if s == "" { return nil } return &s }