feat(server/m7-352): AssertEditable + ErrSystemPlaylistReadonly on playlists service

This commit is contained in:
2026-05-04 08:21:18 -04:00
parent 58a27e2f0f
commit d7aebafa5d
+31 -4
View File
@@ -30,10 +30,11 @@ import (
// Typed errors. The API layer maps each to its wire status code; tests
// use errors.Is to assert the right path was taken.
var (
ErrNotFound = errors.New("playlists: playlist not found")
ErrForbidden = errors.New("playlists: forbidden")
ErrInvalidInput = errors.New("playlists: invalid input")
ErrTrackNotFound = errors.New("playlists: track not found")
ErrNotFound = errors.New("playlists: playlist not found")
ErrForbidden = errors.New("playlists: forbidden")
ErrInvalidInput = errors.New("playlists: invalid input")
ErrTrackNotFound = errors.New("playlists: track not found")
ErrSystemPlaylistReadonly = errors.New("playlists: system playlist is read-only")
)
// Service wires the sqlc queries + on-disk cover-art directory together.
@@ -292,6 +293,32 @@ func (s *Service) Delete(ctx context.Context, callerID, playlistID pgtype.UUID)
return nil
}
// AssertEditable returns nil if the playlist exists, the caller owns it,
// AND the playlist is user-created (kind='user'). Returns:
// - ErrNotFound — playlist doesn't exist
// - ErrForbidden — caller is not the owner
// - ErrSystemPlaylistReadonly — playlist exists but kind='system'
//
// Edit handlers call this first; on error, the API layer maps the typed
// error to the appropriate status code.
func (s *Service) AssertEditable(ctx context.Context, callerID, playlistID pgtype.UUID) error {
q := dbq.New(s.pool)
p, err := q.GetPlaylist(ctx, playlistID)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return ErrNotFound
}
return fmt.Errorf("playlists: load for editability check: %w", err)
}
if !pgtypeUUIDEqual(p.UserID, callerID) {
return ErrForbidden
}
if p.Kind == "system" {
return ErrSystemPlaylistReadonly
}
return nil
}
// AppendTracks adds the given track ids at the end of the playlist,
// preserving order. Snapshot fields (title, artist, album, duration_sec)
// are populated from the tracks/albums/artists join at insert time.