From cb046bba9e8301c4cf928af317a0550cd622c6d5 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Wed, 23 Sep 2026 22:55:14 +0200 Subject: [PATCH] Let users browse followers and following lists Following lists are public; followers are visible to the user and to mutual followers. Adds GET /users/{id}/followers and /following, exposes isFollowing and canViewFollowers on the profile, and returns isFollowedByViewer from user search so the web player no longer calls /is-following per user. --- .../Users/FollowVisibility.cs | 30 +++++ .../Users/GetUserFollowers.cs | 46 +++++++ .../Users/GetUserFollowing.cs | 43 +++++++ .../Users/GetUserProfile.cs | 18 ++- .../Core/Musify.Application/Users/GetUsers.cs | 20 ++- .../Users/Responses/UserSummaryResponse.cs | 13 ++ .../Musify.Api/Endpoints/UserEndpoints.cs | 45 ++++++- .../GetUserFollowersQueryHandlerTests.cs | 95 ++++++++++++++ .../GetUserFollowingQueryHandlerTests.cs | 75 +++++++++++ .../Users/GetUserProfileQueryHandlerTests.cs | 40 ++++++ .../Users/GetUsersQueryHandlerTests.cs | 14 ++ web-player/CLAUDE.md | 2 +- web-player/src/lib/api/schema.d.ts | 121 +++++++++++++++++- .../components/ui/layout/PageHeader.svelte | 6 +- .../lib/components/ui/media/UserRow.svelte | 28 ++++ web-player/src/lib/config.ts | 1 + web-player/src/lib/server/follows.ts | 17 +++ web-player/src/lib/types.ts | 2 + web-player/src/params/followList.ts | 5 + .../src/routes/(app)/explore/+page.server.ts | 23 +--- .../src/routes/(app)/explore/+page.svelte | 13 +- .../routes/(app)/user/[id]/+page.server.ts | 10 +- .../src/routes/(app)/user/[id]/+page.svelte | 21 ++- .../[id]/[list=followList]/+page.server.ts | 32 +++++ .../user/[id]/[list=followList]/+page.svelte | 93 ++++++++++++++ .../users/[id]/[list=followList]/+server.ts | 19 +++ 26 files changed, 768 insertions(+), 64 deletions(-) create mode 100644 backend/Core/Musify.Application/Users/FollowVisibility.cs create mode 100644 backend/Core/Musify.Application/Users/GetUserFollowers.cs create mode 100644 backend/Core/Musify.Application/Users/GetUserFollowing.cs create mode 100644 backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs create mode 100644 backend/Tests/Musify.Application.Tests/Users/GetUserFollowersQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs create mode 100644 web-player/src/lib/components/ui/media/UserRow.svelte create mode 100644 web-player/src/lib/server/follows.ts create mode 100644 web-player/src/params/followList.ts create mode 100644 web-player/src/routes/(app)/user/[id]/[list=followList]/+page.server.ts create mode 100644 web-player/src/routes/(app)/user/[id]/[list=followList]/+page.svelte create mode 100644 web-player/src/routes/api/users/[id]/[list=followList]/+server.ts diff --git a/backend/Core/Musify.Application/Users/FollowVisibility.cs b/backend/Core/Musify.Application/Users/FollowVisibility.cs new file mode 100644 index 0000000..b3c45a8 --- /dev/null +++ b/backend/Core/Musify.Application/Users/FollowVisibility.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; + +namespace Musify.Application.Users +{ + public static class FollowVisibility + { + public static async Task CanViewFollowersAsync( + IDatabase database, + long? viewerId, + long userId, + CancellationToken cancellationToken) + { + if (viewerId is not { } viewer) + return false; + + if (viewer == userId) + return true; + + var mutualRows = await database.UserFollows + .AsNoTracking() + .CountAsync(f => + (f.FollowerId == viewer && f.FollowedId == userId) || + (f.FollowerId == userId && f.FollowedId == viewer), + cancellationToken); + + return mutualRows == 2; + } + } +} diff --git a/backend/Core/Musify.Application/Users/GetUserFollowers.cs b/backend/Core/Musify.Application/Users/GetUserFollowers.cs new file mode 100644 index 0000000..e5b0d9e --- /dev/null +++ b/backend/Core/Musify.Application/Users/GetUserFollowers.cs @@ -0,0 +1,46 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Application.Users.Responses; +using X.PagedList.EF; + +namespace Musify.Application.Users +{ + public record GetUserFollowersQuery(long? ViewerId, long UserId, int PageNumber, int PageSize) + : IQuery>>; + + public class GetUserFollowersQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken) + { + if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) + return Error.NotFound(); + + if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken)) + return Error.Forbidden(); + + var followsQuery = database.UserFollows + .AsNoTracking() + .Where(f => f.FollowedId == request.UserId); + + var totalCount = await followsQuery.CountAsync(cancellationToken); + var viewerId = request.ViewerId; + + var page = await followsQuery + .OrderByDescending(f => f.CreatedAt) + .Select(f => new UserSummaryResponse( + f.Follower.Id, + f.Follower.Name, + f.Follower.FirstName, + f.Follower.SecondName, + f.Follower.ProfilePictureUrl, + viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId))) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + return PaginatedResponse.FromPagedList(page); + } + } +} diff --git a/backend/Core/Musify.Application/Users/GetUserFollowing.cs b/backend/Core/Musify.Application/Users/GetUserFollowing.cs new file mode 100644 index 0000000..baa9221 --- /dev/null +++ b/backend/Core/Musify.Application/Users/GetUserFollowing.cs @@ -0,0 +1,43 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Shared; +using Musify.Application.Users.Responses; +using X.PagedList.EF; + +namespace Musify.Application.Users +{ + public record GetUserFollowingQuery(long? ViewerId, long UserId, int PageNumber, int PageSize) + : IQuery>>; + + public class GetUserFollowingQueryHandler(IDatabase database) + : IQueryHandler>> + { + public async ValueTask>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken) + { + if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken)) + return Error.NotFound(); + + var followsQuery = database.UserFollows + .AsNoTracking() + .Where(f => f.FollowerId == request.UserId); + + var totalCount = await followsQuery.CountAsync(cancellationToken); + var viewerId = request.ViewerId; + + var page = await followsQuery + .OrderByDescending(f => f.CreatedAt) + .Select(f => new UserSummaryResponse( + f.Followed.Id, + f.Followed.Name, + f.Followed.FirstName, + f.Followed.SecondName, + f.Followed.ProfilePictureUrl, + viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId))) + .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); + + return PaginatedResponse.FromPagedList(page); + } + } +} diff --git a/backend/Core/Musify.Application/Users/GetUserProfile.cs b/backend/Core/Musify.Application/Users/GetUserProfile.cs index 4d897d2..7066d90 100644 --- a/backend/Core/Musify.Application/Users/GetUserProfile.cs +++ b/backend/Core/Musify.Application/Users/GetUserProfile.cs @@ -7,7 +7,7 @@ using Musify.Application.Serialization; namespace Musify.Application.Users { - public record GetUserProfileQuery(long UserId) : IQuery>; + public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery>; public record UserProfileResponse( [property: JsonConverter(typeof(LongAsStringConverter))] long Id, @@ -17,7 +17,9 @@ namespace Musify.Application.Users string? ProfilePictureUrl, DateTime CreatedAt, int FollowersCount, - int FollowingCount); + int FollowingCount, + bool IsFollowing, + bool CanViewFollowers); public class GetUserProfileQueryHandler(IDatabase database) : IQueryHandler> @@ -49,6 +51,14 @@ namespace Musify.Application.Users .AsNoTracking() .CountAsync(f => f.FollowerId == request.UserId, cancellationToken); + var isFollowing = request.ViewerId is { } viewerId && viewerId != request.UserId && + await database.UserFollows + .AsNoTracking() + .AnyAsync(f => f.FollowerId == viewerId && f.FollowedId == request.UserId, cancellationToken); + + var canViewFollowers = await FollowVisibility.CanViewFollowersAsync( + database, request.ViewerId, request.UserId, cancellationToken); + return new UserProfileResponse( user.Id, user.Name, @@ -57,7 +67,9 @@ namespace Musify.Application.Users user.ProfilePictureUrl, user.CreatedAt, followersCount, - followingCount); + followingCount, + isFollowing, + canViewFollowers); } } } diff --git a/backend/Core/Musify.Application/Users/GetUsers.cs b/backend/Core/Musify.Application/Users/GetUsers.cs index 90955bb..7023c84 100644 --- a/backend/Core/Musify.Application/Users/GetUsers.cs +++ b/backend/Core/Musify.Application/Users/GetUsers.cs @@ -8,13 +8,13 @@ using Musify.Application.Contracts; namespace Musify.Application.Users { - public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch) - : IQuery>>; + public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null) + : IQuery>>; public class GetUsersQueryHandler(IDatabase database) - : IQueryHandler>> + : IQueryHandler>> { - public async ValueTask>> Handle(GetUsersQuery request, CancellationToken cancellationToken) + public async ValueTask>> Handle(GetUsersQuery request, CancellationToken cancellationToken) { var usersQuery = database.Users .AsNoTracking() @@ -27,11 +27,19 @@ namespace Musify.Application.Users } var totalCount = await usersQuery.CountAsync(cancellationToken); + var viewerId = request.ViewerId; var pagedUsers = await usersQuery - .Select(u => UserApplicationResponse.FromEntity(u)) + .OrderBy(u => u.Name) + .Select(u => new UserSummaryResponse( + u.Id, + u.Name, + u.FirstName, + u.SecondName, + u.ProfilePictureUrl, + viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == u.Id))) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); - return PaginatedResponse.FromPagedList(pagedUsers); + return PaginatedResponse.FromPagedList(pagedUsers); } } } diff --git a/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs b/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs new file mode 100644 index 0000000..6d0af7f --- /dev/null +++ b/backend/Core/Musify.Application/Users/Responses/UserSummaryResponse.cs @@ -0,0 +1,13 @@ +using System.Text.Json.Serialization; +using Musify.Application.Serialization; + +namespace Musify.Application.Users.Responses +{ + public record UserSummaryResponse( + [property: JsonConverter(typeof(LongAsStringConverter))] long Id, + string Name, + string? FirstName, + string? SecondName, + string? ProfilePictureUrl, + bool IsFollowedByViewer); +} diff --git a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs index c1c97f3..63448eb 100644 --- a/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/UserEndpoints.cs @@ -21,7 +21,7 @@ public static class UserEndpoints group.MapGet("/", GetUsers) .WithName("GetUsers") .WithSummary("Get Paginated Users.") - .Produces>(); + .Produces>(); group.MapGet("/{id}", GetUserById) .WithName("GetUserById") @@ -52,6 +52,19 @@ public static class UserEndpoints .Produces() .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id}/followers", GetFollowers) + .WithName("GetUserFollowers") + .WithSummary("Get The Users Following A User. Visible To The User And To Mutual Followers.") + .Produces>() + .Produces(StatusCodes.Status403Forbidden) + .Produces(StatusCodes.Status404NotFound); + + group.MapGet("/{id}/following", GetFollowing) + .WithName("GetUserFollowing") + .WithSummary("Get The Users A User Follows.") + .Produces>() + .Produces(StatusCodes.Status404NotFound); + group.MapGet("/{id}/is-following", IsFollowing) .WithName("IsFollowingUser") .WithSummary("Check Whether The Current User Follows Another User.") @@ -88,12 +101,13 @@ public static class UserEndpoints private static async Task GetUsers( IMediator mediator, + CurrentUser currentUser, CancellationToken cancellationToken, string? usernameSearch, int pageNumber = 1, int pageSize = 10) { - var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch), cancellationToken); + var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch, currentUser.Id), cancellationToken); return result.ToHttpResult(); } @@ -133,10 +147,35 @@ public static class UserEndpoints private static async Task GetUserProfile( IMediator mediator, + CurrentUser currentUser, long id, CancellationToken cancellationToken) { - var result = await mediator.Send(new GetUserProfileQuery(id), cancellationToken); + var result = await mediator.Send(new GetUserProfileQuery(id, currentUser.Id), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetFollowers( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken, + int pageNumber = 1, + int pageSize = 20) + { + var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken); + return result.ToHttpResult(); + } + + private static async Task GetFollowing( + IMediator mediator, + CurrentUser currentUser, + long id, + CancellationToken cancellationToken, + int pageNumber = 1, + int pageSize = 20) + { + var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken); return result.ToHttpResult(); } diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserFollowersQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowersQueryHandlerTests.cs new file mode 100644 index 0000000..825e7ff --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowersQueryHandlerTests.cs @@ -0,0 +1,95 @@ +using ErrorOr; +using Musify.Application.Shared; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Musify.Application.Users.Responses; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class GetUserFollowersQueryHandlerTests : HandlerTestBase + { + private GetUserFollowersQueryHandler CreateHandler() => new(Database); + + private async Task>> Send(long? viewerId, long userId) => + await CreateHandler().Handle(new GetUserFollowersQuery(viewerId, userId, 1, 20), TestContext.Current.CancellationToken); + + [Fact] + public async Task Handle_OwnProfile_ReturnsFollowers() + { + var user = TestEntities.User(1, "user"); + var follower = TestEntities.User(2, "follower"); + await SeedAsync(user, follower, new UserFollow { FollowerId = follower.Id, FollowedId = user.Id }); + + var result = await Send(user.Id, user.Id); + + Assert.False(result.IsError); + var item = Assert.Single(result.Value.Items); + Assert.Equal(follower.Id, item.Id); + Assert.False(item.IsFollowedByViewer); + } + + [Fact] + public async Task Handle_MutualFollow_ReturnsFollowersWithViewerFollowState() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + var other = TestEntities.User(3, "other"); + await SeedAsync( + viewer, user, other, + new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }, + new UserFollow { FollowerId = other.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = viewer.Id, FollowedId = other.Id }); + + var result = await Send(viewer.Id, user.Id); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + Assert.All(result.Value.Items, item => Assert.Equal(item.Id == other.Id, item.IsFollowedByViewer)); + } + + [Fact] + public async Task Handle_ViewerFollowsButNotFollowedBack_ReturnsForbidden() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }); + + var result = await Send(viewer.Id, user.Id); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } + + [Fact] + public async Task Handle_FollowedByUserButNotFollowingBack_ReturnsForbidden() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + await SeedAsync(viewer, user, new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }); + + var result = await Send(viewer.Id, user.Id); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } + + [Fact] + public async Task Handle_Anonymous_ReturnsForbidden() + { + await SeedAsync(TestEntities.User(1, "user")); + + var result = await Send(null, 1); + + Assert.Equal(ErrorType.Forbidden, result.FirstError.Type); + } + + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await Send(1, 404); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs new file mode 100644 index 0000000..c074d00 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserFollowingQueryHandlerTests.cs @@ -0,0 +1,75 @@ +using ErrorOr; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Users; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Users +{ + public sealed class GetUserFollowingQueryHandlerTests : HandlerTestBase + { + private GetUserFollowingQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_Anonymous_ReturnsFollowedUsers() + { + var user = TestEntities.User(1, "user"); + var followedA = TestEntities.User(2, "followedA"); + var followedB = TestEntities.User(3, "followedB"); + await SeedAsync( + user, followedA, followedB, + new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id }); + + var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, user.Id, 1, 20), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Equal(2, result.Value.TotalItemCount); + Assert.All(result.Value.Items, item => Assert.False(item.IsFollowedByViewer)); + } + + [Fact] + public async Task Handle_Viewer_MarksUsersTheViewerFollows() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + var followedA = TestEntities.User(3, "followedA"); + var followedB = TestEntities.User(4, "followedB"); + await SeedAsync( + viewer, user, followedA, followedB, + new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id }, + new UserFollow { FollowerId = viewer.Id, FollowedId = followedA.Id }); + + var result = await CreateHandler().Handle(new GetUserFollowingQuery(viewer.Id, user.Id, 1, 20), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.All(result.Value.Items, item => Assert.Equal(item.Id == followedA.Id, item.IsFollowedByViewer)); + } + + [Fact] + public async Task Handle_Pagination_ReturnsRequestedPage() + { + await SeedAsync( + TestEntities.User(1, "user"), TestEntities.User(2, "a"), TestEntities.User(3, "b"), TestEntities.User(4, "c"), + new UserFollow { FollowerId = 1, FollowedId = 2 }, + new UserFollow { FollowerId = 1, FollowedId = 3 }, + new UserFollow { FollowerId = 1, FollowedId = 4 }); + + var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 1, 2, 2), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.Single(result.Value.Items); + Assert.Equal(3, result.Value.TotalItemCount); + Assert.False(result.Value.HasNextPage); + } + + [Fact] + public async Task Handle_UserMissing_ReturnsNotFound() + { + var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 404, 1, 20), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs index 57b734e..45665aa 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUserProfileQueryHandlerTests.cs @@ -30,6 +30,46 @@ namespace Musify.Application.Tests.Users Assert.Equal(1, result.Value.FollowingCount); } + [Fact] + public async Task Handle_MutualFollow_ViewerCanSeeFollowersAndIsFollowing() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + await SeedAsync( + viewer, user, + new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }, + new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id }); + + var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken); + + Assert.True(result.Value.IsFollowing); + Assert.True(result.Value.CanViewFollowers); + } + + [Fact] + public async Task Handle_OneWayFollow_ViewerCannotSeeFollowers() + { + var viewer = TestEntities.User(1, "viewer"); + var user = TestEntities.User(2, "user"); + await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id }); + + var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken); + + Assert.True(result.Value.IsFollowing); + Assert.False(result.Value.CanViewFollowers); + } + + [Fact] + public async Task Handle_OwnProfile_CanSeeFollowersButIsNotFollowing() + { + await SeedAsync(TestEntities.User(1, "user")); + + var result = await CreateHandler().Handle(new GetUserProfileQuery(1, 1), TestContext.Current.CancellationToken); + + Assert.False(result.Value.IsFollowing); + Assert.True(result.Value.CanViewFollowers); + } + [Fact] public async Task Handle_UserMissing_ReturnsNotFound() { diff --git a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs index f60f180..a57cfb4 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetUsersQueryHandlerTests.cs @@ -1,5 +1,6 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Users; +using Musify.Domain.Entities; using Xunit; namespace Musify.Application.Tests.Users @@ -30,5 +31,18 @@ namespace Musify.Application.Tests.Users var user = Assert.Single(result.Value.Items); Assert.Equal(1, user.Id); } + + [Fact] + public async Task Handle_Viewer_MarksFollowedUsers() + { + await SeedAsync( + TestEntities.User(1, "viewer"), TestEntities.User(2, "alice"), TestEntities.User(3, "bob"), + new UserFollow { FollowerId = 1, FollowedId = 2 }); + + var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + Assert.All(result.Value.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer)); + } } } diff --git a/web-player/CLAUDE.md b/web-player/CLAUDE.md index 3cd9c5b..e1da22c 100644 --- a/web-player/CLAUDE.md +++ b/web-player/CLAUDE.md @@ -42,7 +42,7 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail - `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, and so on). - `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone). - `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). This is different from `components/layout/` above, which is the global app chrome. -- Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`. +- Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`). ## Styles diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 4af5481..3c0035e 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -607,6 +607,40 @@ export interface paths { patch?: never; trace?: never; }; + "/users/{id}/followers": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get The Users Following A User. Visible To The User And To Mutual Followers. */ + get: operations["GetUserFollowers"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/users/{id}/following": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get The Users A User Follows. */ + get: operations["GetUserFollowing"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/{id}/is-following": { parameters: { query?: never; @@ -807,8 +841,8 @@ export interface components { hasNextPage: boolean; hasPreviousPage: boolean; }; - PaginatedResponseOfUserApplicationResponse: { - items: components["schemas"]["UserApplicationResponse"][]; + PaginatedResponseOfUserSummaryResponse: { + items: components["schemas"]["UserSummaryResponse"][]; /** Format: int32 */ pageNumber: number | string; /** Format: int32 */ @@ -976,6 +1010,16 @@ export interface components { followersCount: number | string; /** Format: int32 */ followingCount: number | string; + isFollowing: boolean; + canViewFollowers: boolean; + }; + UserSummaryResponse: { + id: string; + name: string; + firstName: null | string; + secondName: null | string; + profilePictureUrl: null | string; + isFollowedByViewer: boolean; }; }; responses: never; @@ -2277,7 +2321,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PaginatedResponseOfUserApplicationResponse"]; + "application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"]; }; }; }; @@ -2460,6 +2504,77 @@ export interface operations { }; }; }; + GetUserFollowers: { + parameters: { + query?: { + pageNumber?: number | string; + pageSize?: number | string; + }; + header?: never; + path: { + id: number | string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"]; + }; + }; + /** @description Forbidden */ + 403: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + GetUserFollowing: { + parameters: { + query?: { + pageNumber?: number | string; + pageSize?: number | string; + }; + header?: never; + path: { + id: number | string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"]; + }; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; IsFollowingUser: { parameters: { query?: never; diff --git a/web-player/src/lib/components/ui/layout/PageHeader.svelte b/web-player/src/lib/components/ui/layout/PageHeader.svelte index 2cd8dc0..645d727 100644 --- a/web-player/src/lib/components/ui/layout/PageHeader.svelte +++ b/web-player/src/lib/components/ui/layout/PageHeader.svelte @@ -5,7 +5,7 @@ title: string; eyebrow?: string; description?: string; - meta?: string; + meta?: string | Snippet; cover?: Snippet; actions?: Snippet; align?: 'center' | 'end'; @@ -45,8 +45,10 @@ {#if description}

{description}

{/if} - {#if meta} + {#if typeof meta === 'string'}

{meta}

+ {:else if meta} +

{@render meta()}

{/if} {#if actions && !cover} diff --git a/web-player/src/lib/components/ui/media/UserRow.svelte b/web-player/src/lib/components/ui/media/UserRow.svelte new file mode 100644 index 0000000..a9201e5 --- /dev/null +++ b/web-player/src/lib/components/ui/media/UserRow.svelte @@ -0,0 +1,28 @@ + + +
+ + {#snippet art()} + + {/snippet} + + {#if canFollow} + + {/if} +
diff --git a/web-player/src/lib/config.ts b/web-player/src/lib/config.ts index 19ab8b8..8b94487 100644 --- a/web-player/src/lib/config.ts +++ b/web-player/src/lib/config.ts @@ -17,5 +17,6 @@ export const HOME_POPULAR_MAX = 8; export const PLAYLIST_PICKER_PAGE_SIZE = 50; export const LIKED_TRACKS_PAGE_SIZE = 500; export const EXPLORE_USERS_LIMIT = 12; +export const FOLLOW_LIST_PAGE_SIZE = 30; export const MENU_WIDTH = 240; export const SUBMENU_WIDTH = 256; diff --git a/web-player/src/lib/server/follows.ts b/web-player/src/lib/server/follows.ts new file mode 100644 index 0000000..c51719c --- /dev/null +++ b/web-player/src/lib/server/follows.ts @@ -0,0 +1,17 @@ +import type { createApiClient } from '$lib/server/api'; +import type { FollowList } from '$lib/types'; + +type ApiClient = ReturnType; + +export function fetchFollowList( + api: ApiClient, + list: FollowList, + id: string, + pageNumber: number, + pageSize: number +) { + const options = { params: { path: { id }, query: { pageNumber, pageSize } } }; + return list === 'followers' + ? api.GET('/users/{id}/followers', options) + : api.GET('/users/{id}/following', options); +} diff --git a/web-player/src/lib/types.ts b/web-player/src/lib/types.ts index bb2191e..dddcee1 100644 --- a/web-player/src/lib/types.ts +++ b/web-player/src/lib/types.ts @@ -4,3 +4,5 @@ export interface SessionUser { email: string; picture: string; } + +export type FollowList = 'followers' | 'following'; diff --git a/web-player/src/params/followList.ts b/web-player/src/params/followList.ts new file mode 100644 index 0000000..2e95370 --- /dev/null +++ b/web-player/src/params/followList.ts @@ -0,0 +1,5 @@ +import type { ParamMatcher } from '@sveltejs/kit'; +import type { FollowList } from '$lib/types'; + +export const match = ((param: string): param is FollowList => + param === 'followers' || param === 'following') satisfies ParamMatcher; diff --git a/web-player/src/routes/(app)/explore/+page.server.ts b/web-player/src/routes/(app)/explore/+page.server.ts index 227e316..176bde1 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -46,31 +46,12 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'); - const userItems = usersRes?.data?.items ?? []; - const followedIds = user - ? await Promise.all( - userItems - .filter((u) => u.id !== user.sub) - .map((u) => - api - .GET('/users/{id}/is-following', { params: { path: { id: u.id } } }) - .then((res) => (res.data ? u.id : null)) - .catch(() => null) - ) - ) - : []; - const followed = new Set(followedIds); - return { query, tracks, albums: (albumsRes?.data?.items ?? []).map((item) => item.album), - users: userItems.map((u) => ({ - ...u, - isSelf: user?.sub === u.id, - isFollowing: followed.has(u.id) - })), - canFollow: !!user, + users: usersRes?.data?.items ?? [], + viewerId: user?.sub ?? null, playlists: playlistsRes?.data?.items ?? [] }; }; diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index 36c08f8..a8c8d35 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -10,7 +10,7 @@ import InfiniteScroll from '$lib/components/ui/primitives/InfiniteScroll.svelte'; import Artwork from '$lib/components/ui/media/Artwork.svelte'; import Avatar from '$lib/components/ui/media/Avatar.svelte'; - import FollowButton from '$lib/components/ui/media/FollowButton.svelte'; + import UserRow from '$lib/components/ui/media/UserRow.svelte'; import PlayButton from '$lib/components/ui/media/PlayButton.svelte'; import EqBars from '$lib/components/ui/media/EqBars.svelte'; import ListRow from '$lib/components/ui/media/ListRow.svelte'; @@ -402,15 +402,8 @@
    {#each capped(sfilter, users) as u (u.id)} -
  • - - {#snippet art()} - - {/snippet} - - {#if data.canFollow && !u.isSelf} - - {/if} +
  • +
  • {/each}
diff --git a/web-player/src/routes/(app)/user/[id]/+page.server.ts b/web-player/src/routes/(app)/user/[id]/+page.server.ts index a936bef..9675eaa 100644 --- a/web-player/src/routes/(app)/user/[id]/+page.server.ts +++ b/web-player/src/routes/(app)/user/[id]/+page.server.ts @@ -39,15 +39,7 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent const isOwnProfile = viewer !== null && viewer.sub === params.id; - const isFollowing = - viewer && !isOwnProfile - ? await api - .GET('/users/{id}/is-following', { params: { path: { id: params.id } } }) - .then((res) => res.data ?? false) - .catch(() => false) - : false; - - return { profile, playlists, isOwnProfile, isFollowing, isAnonymous: viewer === null }; + return { profile, playlists, isOwnProfile, isAnonymous: viewer === null }; }; export const actions: Actions = { diff --git a/web-player/src/routes/(app)/user/[id]/+page.svelte b/web-player/src/routes/(app)/user/[id]/+page.svelte index 5de8dd2..322cfbe 100644 --- a/web-player/src/routes/(app)/user/[id]/+page.svelte +++ b/web-player/src/routes/(app)/user/[id]/+page.svelte @@ -22,18 +22,27 @@ {#snippet followAction()} - + +{/snippet} + +{#snippet followStats()} + {@const followers = plural(Number(profile.followersCount), 'seguidor', 'seguidores')} + {#if data.isOwnProfile || profile.canViewFollowers} + {followers} + {:else} + {followers} + {/if} + · + + {profile.followingCount} siguiendo + {/snippet} diff --git a/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.server.ts b/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.server.ts new file mode 100644 index 0000000..66dd166 --- /dev/null +++ b/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.server.ts @@ -0,0 +1,32 @@ +import type { PageServerLoad, Actions } from './$types'; +import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api'; +import { fetchFollowList } from '$lib/server/follows'; +import { followUserAction, unfollowUserAction } from '$lib/server/followActions'; +import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config'; + +export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => { + const { allowAnonymousListening } = await parent(); + const viewer = optionalUser(locals, url, allowAnonymousListening); + const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + + const [profileRes, listRes] = await Promise.all([ + api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }), + fetchFollowList(api, params.list, params.id, 1, FOLLOW_LIST_PAGE_SIZE) + ]); + + const profile = unwrapOrError(profileRes, 'Usuario no encontrado.', 404); + const forbidden = listRes.response?.status === 403; + + return { + list: params.list, + profile, + users: forbidden ? null : unwrapOrError(listRes, 'No se pudo cargar la lista.'), + forbidden, + viewerId: viewer?.sub ?? null + }; +}; + +export const actions: Actions = { + follow: followUserAction, + unfollow: unfollowUserAction +}; diff --git a/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.svelte b/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.svelte new file mode 100644 index 0000000..e87c147 --- /dev/null +++ b/web-player/src/routes/(app)/user/[id]/[list=followList]/+page.svelte @@ -0,0 +1,93 @@ + + + + {title} · {data.profile.name} + + + + + + {#if form?.message} + {form.message} + {/if} + + {#if data.forbidden} + + {:else if items.length === 0} + + {:else} +
    + {#each items as user (user.id)} +
  • + +
  • + {/each} +
+ + {/if} +
diff --git a/web-player/src/routes/api/users/[id]/[list=followList]/+server.ts b/web-player/src/routes/api/users/[id]/[list=followList]/+server.ts new file mode 100644 index 0000000..b90b4a8 --- /dev/null +++ b/web-player/src/routes/api/users/[id]/[list=followList]/+server.ts @@ -0,0 +1,19 @@ +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { createApiClient, unwrapOrError } from '$lib/server/api'; +import { fetchFollowList } from '$lib/server/follows'; +import { FOLLOW_LIST_PAGE_SIZE } from '$lib/config'; + +export const GET: RequestHandler = async ({ params, url, locals, fetch }) => { + const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1); + const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const result = await fetchFollowList( + api, + params.list, + params.id, + pageNumber, + FOLLOW_LIST_PAGE_SIZE + ); + + return json(unwrapOrError(result, 'No se pudo cargar la lista.', result.response?.status)); +};