Add shared pagination, projections and ownership helpers; simplify query and command handlers

This commit is contained in:
2026-09-24 11:59:34 +02:00
parent 5a0f82b77b
commit 4b147d9ee3
84 changed files with 547 additions and 767 deletions
@@ -24,16 +24,13 @@ public class FollowUserCommandHandler(
.AsNoTracking()
.AnyAsync(u => u.Id == request.FollowedId, cancellationToken);
if (!followedExists)
{
logger.LogInformation("User {FollowedId} not found", request.FollowedId);
return Error.NotFound(description: $"User {request.FollowedId} not found");
}
return AppErrors.NotFound("User", request.FollowedId);
var alreadyFollowing = await database.UserFollows
.AsNoTracking()
.AnyAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken);
if (alreadyFollowing)
return new Success();
return Result.Success;
await database.UserFollows.AddAsync(new UserFollow
{
@@ -49,6 +46,6 @@ public class FollowUserCommandHandler(
logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId);
return new Success();
return Result.Success;
}
}
@@ -1,13 +1,12 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Users.Responses;
namespace Musify.Application.Users;
public record GetListeningStatsQuery(long UserId) : IQuery<ListeningStatsResponse>;
public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays);
public class GetListeningStatsQueryHandler(IDatabase database)
: IQueryHandler<GetListeningStatsQuery, ListeningStatsResponse>
{
@@ -3,6 +3,7 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Users.Responses;
using Musify.Application.Shared;
namespace Musify.Application.Users;
@@ -20,9 +21,8 @@ public class GetUserByIdQueryHandler(IDatabase database)
.Select(u => UserApplicationResponse.FromEntity(u))
.SingleOrDefaultAsync(cancellationToken);
if (user == null)
return Error.NotFound();
return user;
return user == null
? AppErrors.NotFound("User", request.Id)
: user;
}
}
@@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Users.Responses;
using X.PagedList.EF;
namespace Musify.Application.Users;
@@ -17,20 +16,18 @@ public class GetUserFollowersQueryHandler(IDatabase database)
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken)
{
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
return Error.NotFound();
return AppErrors.NotFound("User", request.UserId);
if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken))
return Error.Forbidden();
return AppErrors.Forbidden("User", request.UserId);
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
return await database.UserFollows
.AsNoTracking()
.Where(f => f.FollowedId == request.UserId)
.OrderByDescending(f => f.CreatedAt)
.ThenBy(f => f.Id)
.Select(f => new UserSummaryResponse(
f.Follower.Id,
f.Follower.Name,
@@ -38,8 +35,6 @@ public class GetUserFollowersQueryHandler(IDatabase database)
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<UserSummaryResponse>.FromPagedList(page);
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -4,7 +4,6 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Users.Responses;
using X.PagedList.EF;
namespace Musify.Application.Users;
@@ -17,17 +16,15 @@ public class GetUserFollowingQueryHandler(IDatabase database)
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken)
{
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
return Error.NotFound();
return AppErrors.NotFound("User", request.UserId);
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
return await database.UserFollows
.AsNoTracking()
.Where(f => f.FollowerId == request.UserId)
.OrderByDescending(f => f.CreatedAt)
.ThenBy(f => f.Id)
.Select(f => new UserSummaryResponse(
f.Followed.Id,
f.Followed.Name,
@@ -35,8 +32,6 @@ public class GetUserFollowingQueryHandler(IDatabase database)
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<UserSummaryResponse>.FromPagedList(page);
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -1,26 +1,14 @@
using System.Text.Json.Serialization;
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Serialization;
using Musify.Application.Shared;
using Musify.Application.Users.Responses;
namespace Musify.Application.Users;
public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery<ErrorOr<UserProfileResponse>>;
public record UserProfileResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
string Name,
string? FirstName,
string? SecondName,
string? ProfilePictureUrl,
DateTime CreatedAt,
int FollowersCount,
int FollowingCount,
bool IsFollowing,
bool CanViewFollowers);
public class GetUserProfileQueryHandler(IDatabase database)
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
{
@@ -41,7 +29,7 @@ public class GetUserProfileQueryHandler(IDatabase database)
.SingleOrDefaultAsync(cancellationToken);
if (user == null)
return Error.NotFound();
return AppErrors.NotFound("User", request.UserId);
var followersCount = await database.UserFollows
.AsNoTracking()
@@ -1,43 +1,40 @@
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 GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null)
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
: IQuery<PaginatedResponse<UserSummaryResponse>>;
public class GetUsersQueryHandler(IDatabase database)
: IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
: IQueryHandler<GetUsersQuery, PaginatedResponse<UserSummaryResponse>>
{
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
public async ValueTask<PaginatedResponse<UserSummaryResponse>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
{
var usersQuery = database.Users
.AsNoTracking();
if (!string.IsNullOrEmpty(request.UsernameSearch))
if (!string.IsNullOrWhiteSpace(request.UsernameSearch))
{
var normalizedUsername = request.UsernameSearch.Trim().ToUpperInvariant();
usersQuery = usersQuery.Where(u => u.NormalizedName.Contains(normalizedUsername));
var normalizedUsername = TextNormalizer.Normalize(request.UsernameSearch);
usersQuery = usersQuery.Where(user => user.NormalizedName.Contains(normalizedUsername));
}
var totalCount = await usersQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var pagedUsers = await usersQuery
.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<UserSummaryResponse>.FromPagedList(pagedUsers);
return await usersQuery
.OrderBy(user => user.Name)
.ThenBy(user => user.Id)
.Select(user => new UserSummaryResponse(
user.Id,
user.Name,
user.FirstName,
user.SecondName,
user.ProfilePictureUrl,
viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == user.Id)))
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -0,0 +1,3 @@
namespace Musify.Application.Users.Responses;
public record ListeningStatsResponse(int TracksThisWeek, int SecondsThisWeek, int StreakDays);
@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
namespace Musify.Application.Users.Responses;
public record UserProfileResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
string Name,
string? FirstName,
string? SecondName,
string? ProfilePictureUrl,
DateTime CreatedAt,
int FollowersCount,
int FollowingCount,
bool IsFollowing,
bool CanViewFollowers);
@@ -3,6 +3,7 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities;
namespace Musify.Application.Users;
@@ -29,7 +30,7 @@ public class SyncUserCommandHandler(IDatabase database, ILogger<SyncUserCommandH
{
Id = request.Id,
Name = request.Name,
NormalizedName = request.Name.ToUpperInvariant(),
NormalizedName = TextNormalizer.Normalize(request.Name),
FirstName = request.FirstName,
SecondName = request.SecondName,
ProfilePictureUrl = request.ProfilePictureUrl
@@ -38,11 +39,11 @@ public class SyncUserCommandHandler(IDatabase database, ILogger<SyncUserCommandH
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Provisioned user {UserId}", request.Id);
return new Success();
return Result.Success;
}
user.Name = request.Name;
user.NormalizedName = request.Name.ToUpperInvariant();
user.NormalizedName = TextNormalizer.Normalize(request.Name);
user.FirstName = request.FirstName;
user.SecondName = request.SecondName;
user.ProfilePictureUrl = request.ProfilePictureUrl;
@@ -51,6 +52,6 @@ public class SyncUserCommandHandler(IDatabase database, ILogger<SyncUserCommandH
if (written > 0)
logger.LogInformation("Synced profile for user {UserId}", request.Id);
return new Success();
return Result.Success;
}
}
@@ -19,13 +19,13 @@ public class UnfollowUserCommandHandler(
.SingleOrDefaultAsync(f => f.FollowerId == request.FollowerId && f.FollowedId == request.FollowedId, cancellationToken);
if (follow == null)
return new Success();
return Result.Success;
database.UserFollows.Remove(follow);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {FollowerId} unfollowed user {FollowedId}", request.FollowerId, request.FollowedId);
return new Success();
return Result.Success;
}
}