diff --git a/api/dbv1/queries/track_collaborators.sql b/api/dbv1/queries/track_collaborators.sql index 045c5894..68fa602b 100644 --- a/api/dbv1/queries/track_collaborators.sql +++ b/api/dbv1/queries/track_collaborators.sql @@ -1,12 +1,14 @@ --- Accepted collaborators for a set of tracks, used to embed a `collaborators` --- array on track responses. Returns one row per (track, collaborator); the Go --- layer bulk-resolves the user objects. Backed by the track_collaborators --- primary key (track_id leads), so the ANY(...) lookup is index-served. +-- Accepted and pending collaborators for a set of tracks, used to embed a +-- `collaborators` array (accepted) on every track response and a +-- `pending_collaborators` array on the owner's own tracks. Returns one row per +-- (track, collaborator) with status; the Go layer splits by status and +-- bulk-resolves the user objects. Backed by the track_collaborators primary key +-- (track_id leads), so the ANY(...) lookup is index-served. -- name: GetTrackCollaborators :many -SELECT track_id, collaborator_user_id +SELECT track_id, collaborator_user_id, status FROM track_collaborators WHERE track_id = ANY(@track_ids::int[]) - AND status = 'accepted' + AND status IN ('accepted', 'pending') ORDER BY track_id, created_at; -- A user's collaborator invites/credits, optionally filtered by status diff --git a/api/dbv1/track_collaborators.sql.go b/api/dbv1/track_collaborators.sql.go index c7ef1b84..8bdf0b26 100644 --- a/api/dbv1/track_collaborators.sql.go +++ b/api/dbv1/track_collaborators.sql.go @@ -65,22 +65,25 @@ func (q *Queries) GetTrackCollaboratorInvitesForUser(ctx context.Context, arg Ge } const getTrackCollaborators = `-- name: GetTrackCollaborators :many -SELECT track_id, collaborator_user_id +SELECT track_id, collaborator_user_id, status FROM track_collaborators WHERE track_id = ANY($1::int[]) - AND status = 'accepted' + AND status IN ('accepted', 'pending') ORDER BY track_id, created_at ` type GetTrackCollaboratorsRow struct { - TrackID int32 `json:"track_id"` - CollaboratorUserID int32 `json:"collaborator_user_id"` + TrackID int32 `json:"track_id"` + CollaboratorUserID int32 `json:"collaborator_user_id"` + Status string `json:"status"` } -// Accepted collaborators for a set of tracks, used to embed a `collaborators` -// array on track responses. Returns one row per (track, collaborator); the Go -// layer bulk-resolves the user objects. Backed by the track_collaborators -// primary key (track_id leads), so the ANY(...) lookup is index-served. +// Accepted and pending collaborators for a set of tracks, used to embed a +// `collaborators` array (accepted) on every track response and a +// `pending_collaborators` array on the owner's own tracks. Returns one row per +// (track, collaborator) with status; the Go layer splits by status and +// bulk-resolves the user objects. Backed by the track_collaborators primary key +// (track_id leads), so the ANY(...) lookup is index-served. func (q *Queries) GetTrackCollaborators(ctx context.Context, trackIds []int32) ([]GetTrackCollaboratorsRow, error) { rows, err := q.db.Query(ctx, getTrackCollaborators, trackIds) if err != nil { @@ -90,7 +93,7 @@ func (q *Queries) GetTrackCollaborators(ctx context.Context, trackIds []int32) ( var items []GetTrackCollaboratorsRow for rows.Next() { var i GetTrackCollaboratorsRow - if err := rows.Scan(&i.TrackID, &i.CollaboratorUserID); err != nil { + if err := rows.Scan(&i.TrackID, &i.CollaboratorUserID, &i.Status); err != nil { return nil, err } items = append(items, i) diff --git a/api/dbv1/tracks.go b/api/dbv1/tracks.go index 434d01d7..542a4338 100644 --- a/api/dbv1/tracks.go +++ b/api/dbv1/tracks.go @@ -30,7 +30,10 @@ type Track struct { UserID trashid.HashId `json:"user_id"` User User `json:"user"` Collaborators []User `json:"collaborators"` - Access Access `json:"access"` + // PendingCollaborators is populated only on the requester's own tracks (so + // the owner's edit form can preserve still-pending invites); empty otherwise. + PendingCollaborators []User `json:"pending_collaborators"` + Access Access `json:"access"` FolloweeReposts []*FolloweeRepost `json:"followee_reposts"` FolloweeFavorites []*FolloweeFavorite `json:"followee_favorites"` @@ -70,16 +73,35 @@ func (q *Queries) TracksKeyed(ctx context.Context, arg TracksParams) (map[int32] collectSplitUserIds(rawTrack.DownloadConditions) } - // Fetch accepted collaborators for these tracks in one query, and fold - // their user IDs into the bulk user fetch below so each is fully resolved. + // Fetch accepted + pending collaborators for these tracks in one query, and + // fold their user IDs into the bulk user fetch below so each is fully + // resolved. Accepted are embedded on every response; pending are embedded + // only on the requester's own tracks (for the owner's edit form), so their + // user IDs are only resolved for owned tracks. + myID := arg.MyID.(int32) + ownedTracks := map[int32]bool{} + for _, rawTrack := range rawTracks { + if rawTrack.UserID == myID { + ownedTracks[rawTrack.TrackID] = true + } + } collaboratorRows, err := q.GetTrackCollaborators(ctx, trackIds) if err != nil { return nil, err } collaboratorsByTrack := map[int32][]int32{} + pendingByTrack := map[int32][]int32{} for _, cr := range collaboratorRows { - collaboratorsByTrack[cr.TrackID] = append(collaboratorsByTrack[cr.TrackID], cr.CollaboratorUserID) - userIds = append(userIds, cr.CollaboratorUserID) + switch cr.Status { + case "accepted": + collaboratorsByTrack[cr.TrackID] = append(collaboratorsByTrack[cr.TrackID], cr.CollaboratorUserID) + userIds = append(userIds, cr.CollaboratorUserID) + case "pending": + if ownedTracks[cr.TrackID] { + pendingByTrack[cr.TrackID] = append(pendingByTrack[cr.TrackID], cr.CollaboratorUserID) + userIds = append(userIds, cr.CollaboratorUserID) + } + } } userMap, err := q.UsersKeyed(ctx, GetUsersParams{ @@ -153,6 +175,14 @@ func (q *Queries) TracksKeyed(ctx context.Context, arg TracksParams) (map[int32] } } + // Resolve pending collaborators (only present for the owner's own tracks). + pendingCollaborators := []User{} + for _, cid := range pendingByTrack[rawTrack.TrackID] { + if cu, ok := userMap[cid]; ok { + pendingCollaborators = append(pendingCollaborators, cu) + } + } + // Get access from the bulk access map access := accessMap[rawTrack.TrackID] @@ -193,22 +223,23 @@ func (q *Queries) TracksKeyed(ctx context.Context, arg TracksParams) (map[int32] } track := Track{ - GetTracksRow: rawTrack, - IsStreamable: !rawTrack.IsDelete && !user.IsDeactivated, - Permalink: fmt.Sprintf("/%s/%s", user.Handle.String, rawTrack.Slug.String), - Artwork: squareImageStruct(rawTrack.CoverArtSizes, rawTrack.CoverArt), - Stream: stream, - Download: download, - Preview: preview, - User: user, - UserID: user.ID, - Collaborators: collaborators, - FolloweeFavorites: fullFolloweeFavorites(rawTrack.FolloweeFavorites), - FolloweeReposts: fullFolloweeReposts(rawTrack.FolloweeReposts), - RemixOf: fullRemixOf, - StreamConditions: rawTrack.StreamConditions, - DownloadConditions: rawTrack.DownloadConditions, - Access: access, + GetTracksRow: rawTrack, + IsStreamable: !rawTrack.IsDelete && !user.IsDeactivated, + Permalink: fmt.Sprintf("/%s/%s", user.Handle.String, rawTrack.Slug.String), + Artwork: squareImageStruct(rawTrack.CoverArtSizes, rawTrack.CoverArt), + Stream: stream, + Download: download, + Preview: preview, + User: user, + UserID: user.ID, + Collaborators: collaborators, + PendingCollaborators: pendingCollaborators, + FolloweeFavorites: fullFolloweeFavorites(rawTrack.FolloweeFavorites), + FolloweeReposts: fullFolloweeReposts(rawTrack.FolloweeReposts), + RemixOf: fullRemixOf, + StreamConditions: rawTrack.StreamConditions, + DownloadConditions: rawTrack.DownloadConditions, + Access: access, } trackMap[rawTrack.TrackID] = track } diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 40f1ead2..acf01f55 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -14958,6 +14958,11 @@ components: description: Accepted collaborator artists on the track. items: $ref: "#/components/schemas/user" + pending_collaborators: + type: array + description: Pending collaborator invites; only present on the owner's own tracks. + items: + $ref: "#/components/schemas/user" duration: type: integer is_downloadable: @@ -16439,6 +16444,11 @@ components: description: Accepted collaborator artists on the track. items: $ref: "#/components/schemas/user" + pending_collaborators: + type: array + description: Pending collaborator invites; only present on the owner's own tracks. + items: + $ref: "#/components/schemas/user" duration: type: integer is_downloadable: diff --git a/api/v1_track_collaborators_test.go b/api/v1_track_collaborators_test.go index 9939ebe7..c2eee2b5 100644 --- a/api/v1_track_collaborators_test.go +++ b/api/v1_track_collaborators_test.go @@ -174,3 +174,33 @@ func TestCollaboratorSeesPrivateTrack(t *testing.T) { assert.NoError(t, err) assert.Len(t, rows, 0, "a rejected collaborator must not see the private track") } + +// Pending collaborator invites are embedded only on the owner's own tracks (so +// their edit form can preserve them); accepted collaborators stay public. +func TestPendingCollaboratorsVisibleToOwnerOnly(t *testing.T) { + app := testAppWithFixtures(t) + ctx := context.Background() + + // Track 700 is owned by user 500: user 1 accepted, user 2 still pending. + now := time.Now() + database.SeedTable(app.pool.Replicas[0], "track_collaborators", []map[string]any{ + {"track_id": 700, "collaborator_user_id": 1, "invited_by": 500, "status": "accepted", "created_at": now, "updated_at": now}, + {"track_id": 700, "collaborator_user_id": 2, "invited_by": 500, "status": "pending", "created_at": now, "updated_at": now}, + }) + + // As the owner (my_id = 500): accepted embedded + pending visible. + owned, err := app.queries.TracksKeyed(ctx, dbv1.TracksParams{ + GetTracksParams: dbv1.GetTracksParams{Ids: []int32{700}, MyID: int32(500)}, + }) + assert.NoError(t, err) + assert.Len(t, owned[700].Collaborators, 1, "accepted collaborator is embedded") + assert.Len(t, owned[700].PendingCollaborators, 1, "owner sees the pending invite") + + // As a non-owner (my_id = 1): accepted still embedded, pending hidden. + other, err := app.queries.TracksKeyed(ctx, dbv1.TracksParams{ + GetTracksParams: dbv1.GetTracksParams{Ids: []int32{700}, MyID: int32(1)}, + }) + assert.NoError(t, err) + assert.Len(t, other[700].Collaborators, 1, "accepted collaborator stays public") + assert.Len(t, other[700].PendingCollaborators, 0, "pending invite is hidden from non-owners") +}