diff --git a/api/dbv1/get_tracks.sql.go b/api/dbv1/get_tracks.sql.go index 2c940ed2..16229f94 100644 --- a/api/dbv1/get_tracks.sql.go +++ b/api/dbv1/get_tracks.sql.go @@ -237,7 +237,11 @@ FROM tracks t JOIN aggregate_track using (track_id) LEFT JOIN aggregate_plays on play_item_id = t.track_id LEFT JOIN track_routes on t.track_id = track_routes.track_id and track_routes.is_current = true -WHERE (is_unlisted = false OR t.owner_id = $1 OR $2::bool = TRUE) +WHERE (is_unlisted = false OR t.owner_id = $1 OR $2::bool = TRUE + OR EXISTS (SELECT 1 FROM track_collaborators tc + WHERE tc.track_id = t.track_id + AND tc.collaborator_user_id = $1 + AND tc.status IN ('pending', 'accepted'))) AND t.track_id = ANY($3::int[]) AND (t.access_authorities IS NULL OR (COALESCE($4, '') <> '' diff --git a/api/dbv1/queries/get_tracks.sql b/api/dbv1/queries/get_tracks.sql index d921e554..0e7af399 100644 --- a/api/dbv1/queries/get_tracks.sql +++ b/api/dbv1/queries/get_tracks.sql @@ -226,7 +226,11 @@ FROM tracks t JOIN aggregate_track using (track_id) LEFT JOIN aggregate_plays on play_item_id = t.track_id LEFT JOIN track_routes on t.track_id = track_routes.track_id and track_routes.is_current = true -WHERE (is_unlisted = false OR t.owner_id = @my_id OR @include_unlisted::bool = TRUE) +WHERE (is_unlisted = false OR t.owner_id = @my_id OR @include_unlisted::bool = TRUE + OR EXISTS (SELECT 1 FROM track_collaborators tc + WHERE tc.track_id = t.track_id + AND tc.collaborator_user_id = @my_id + AND tc.status IN ('pending', 'accepted'))) AND t.track_id = ANY(@ids::int[]) AND (t.access_authorities IS NULL OR (COALESCE(@authed_wallet, '') <> '' diff --git a/api/v1_track_collaborators_test.go b/api/v1_track_collaborators_test.go index 552c9a49..9939ebe7 100644 --- a/api/v1_track_collaborators_test.go +++ b/api/v1_track_collaborators_test.go @@ -128,3 +128,49 @@ func TestTrackCollaboratorNotificationsGenerated(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 1, acceptCount, "accepted credit should notify the inviter (user 500)") } + +// An invited collaborator can see a private (unlisted) track they're on; other +// users cannot. Exercises the get_tracks visibility clause directly. +func TestCollaboratorSeesPrivateTrack(t *testing.T) { + app := testAppWithFixtures(t) + ctx := context.Background() + + // Track 700 (owned by user 500) is private/unlisted. + _, err := app.pool.Replicas[0].Exec(ctx, + "UPDATE tracks SET is_unlisted = true WHERE track_id = 700 AND is_current = true") + assert.NoError(t, err) + + // User 1 is a pending collaborator (hasn't accepted yet) — they still need + // to see the track to decide. + 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": "pending", "created_at": now, "updated_at": now}, + }) + + // Collaborator (user 1) sees the private track. + rows, err := app.queries.GetTracks(ctx, dbv1.GetTracksParams{ + Ids: []int32{700}, + MyID: int32(1), + }) + assert.NoError(t, err) + assert.Len(t, rows, 1, "an invited collaborator should see the private track") + + // A non-collaborator (user 2) does not. + rows, err = app.queries.GetTracks(ctx, dbv1.GetTracksParams{ + Ids: []int32{700}, + MyID: int32(2), + }) + assert.NoError(t, err) + assert.Len(t, rows, 0, "a non-collaborator must not see the private track") + + // Once the collaborator declines, they no longer see it. + _, err = app.pool.Replicas[0].Exec(ctx, + "UPDATE track_collaborators SET status = 'rejected' WHERE track_id = 700 AND collaborator_user_id = 1") + assert.NoError(t, err) + rows, err = app.queries.GetTracks(ctx, dbv1.GetTracksParams{ + Ids: []int32{700}, + MyID: int32(1), + }) + assert.NoError(t, err) + assert.Len(t, rows, 0, "a rejected collaborator must not see the private track") +} diff --git a/api/v1_users_tracks.go b/api/v1_users_tracks.go index 8955018b..47d9f5d7 100644 --- a/api/v1_users_tracks.go +++ b/api/v1_users_tracks.go @@ -92,6 +92,12 @@ func (app *ApiServer) v1UserTracks(c *fiber.Ctx) error { // another owner for collab tracks, so the pin references the profile user. ownerFilter = "(t.owner_id = @user_id OR t.track_id = ANY(@collab_track_ids))" pinExpr = "t.track_id = (SELECT artist_pick_track_id FROM users WHERE user_id = @user_id)" + // Surface the user's own unlisted collaborations on their own profile + // (my_id == user_id); a private collab track stays hidden from other + // viewers, who only see it once it's public. + if params.FilterTracks != "public" { + trackFilter = "(" + trackFilter + " OR (t.track_id = ANY(@collab_track_ids) AND @my_id = @user_id))" + } } // The profile lists a user's own tracks plus tracks they've accepted a