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
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists;
@@ -18,18 +19,13 @@ public class AddTrackToPlayListCommandHandler(
{
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists
.AsNoTracking()
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList == null)
return AppErrors.NotFound("PlayList", request.PlayListId);
if (playList.OwnerUserId != request.UserId)
return AppErrors.Forbidden("PlayList", request.PlayListId);
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (playList.IsError)
return playList.Errors;
var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
if (!trackExists)
return AppErrors.NotFound("Track", request.TrackId);
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Services;
@@ -53,7 +54,7 @@ public class CreatePlayListCommandHandler(
{
OwnerUserId = request.UserId,
Name = request.Name.Trim(),
NormalizedName = request.Name.Trim().ToUpperInvariant(),
NormalizedName = TextNormalizer.Normalize(request.Name),
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Pictures = resolvedPictures.Pictures,
Visibility = request.Visibility
@@ -119,7 +120,7 @@ public class CreatePlayListCommandHandler(
sizes.Medium,
sizes.Large),
cancellationToken);
return new Success();
return Result.Success;
}
catch (Exception exception)
{
@@ -1,10 +1,9 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists;
@@ -20,43 +19,18 @@ public class DeletePlayListCommandHandler(
{
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken);
if (playList == null)
{
logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.OwnerUserId != request.UserId)
{
logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId);
return AppErrors.Forbidden("PlayList", request.PlayListId);
}
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var playList = found.Value;
playList.LifeCycleStatus = LifeCycleStatus.Removing;
try
{
await eventBus.PublishAsync(
new DeletePlayListEvent(playList.Id, request.UserId),
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish delete playlist event for playlist {PlayListId}", request.PlayListId);
return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
}
await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken);
await database.SaveChangesAsync(cancellationToken);
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to mark playlist {PlayListId} as removing", request.PlayListId);
return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
}
logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId);
return new Success();
return Result.Success;
}
}
@@ -3,7 +3,7 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Domain.ValueObjects;
using Musify.Application.Shared;
namespace Musify.Application.PlayLists;
@@ -15,29 +15,16 @@ public class GetPlayListByIdQueryHandler(IDatabase database)
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
{
var entry = await database.PlayLists
var playList = await database.PlayLists
.AsNoTracking()
.Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active)
.Select(p => new
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.Active()
.VisibleTo(request.RequestingUserId)
.Where(p => p.Id == request.Id)
.SelectResponse()
.SingleOrDefaultAsync(cancellationToken);
if (entry == null)
return Error.NotFound();
if (entry.PlayList.Visibility == PlayListVisibility.Private
&& entry.PlayList.OwnerUserId != request.RequestingUserId)
{
return Error.NotFound();
}
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
return playList == null
? AppErrors.NotFound("PlayList", request.Id)
: playList;
}
}
@@ -2,11 +2,10 @@ using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.PlayLists;
@@ -22,33 +21,21 @@ public class GetPlayListTracksQueryHandler(IDatabase database)
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
{
var playListExists = await database.PlayLists
var playListVisible = await database.PlayLists
.AsNoTracking()
.AnyAsync(
p => p.Id == request.PlayListId
&& p.LifeCycleStatus == LifeCycleStatus.Active
&& (p.Visibility == PlayListVisibility.Public || p.OwnerUserId == request.ViewerId),
cancellationToken);
if (!playListExists)
return Error.NotFound();
.Active()
.VisibleTo(request.ViewerId)
.AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken);
if (!playListVisible)
return AppErrors.NotFound("PlayList", request.PlayListId);
var tracksQuery = database.PlayListHasTracks
return await database.PlayListHasTracks
.AsNoTracking()
.Include(plt => plt.Track.Owner)
.Include(plt => plt.Track.Tags)
.Where(plt => plt.PlayListId == request.PlayListId && plt.Track.LifeCycleStatus == LifeCycleStatus.Active);
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderBy(plt => plt.Position)
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.OrderBy(playListTrack => playListTrack.Position)
.ThenBy(playListTrack => playListTrack.Id)
.Select(playListTrack => playListTrack.Track)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -1,12 +1,8 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.PlayLists;
@@ -16,45 +12,29 @@ public record GetPlayListsByUserIdQuery(
int PageNumber,
int PageSize,
long? ViewerId = null)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
: IQuery<PaginatedResponse<PlayListApplicationResponse>>;
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsByUserIdQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
: IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>>
{
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
public async ValueTask<PaginatedResponse<PlayListApplicationResponse>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
{
var playListsQuery = database.PlayLists
.AsNoTracking()
.Where(p => p.OwnerUserId == request.UserId
&& p.LifeCycleStatus == LifeCycleStatus.Active
&& (p.Visibility == PlayListVisibility.Public || p.OwnerUserId == request.ViewerId));
.Active()
.VisibleTo(request.ViewerId)
.Where(playList => playList.OwnerUserId == request.UserId);
if (!string.IsNullOrEmpty(request.Name))
if (!string.IsNullOrWhiteSpace(request.Name))
{
var normalizedName = request.Name.Trim().ToUpperInvariant();
playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName));
var normalizedName = TextNormalizer.Normalize(request.Name);
playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName));
}
var totalCount = await playListsQuery.CountAsync(cancellationToken);
var pagedEntities = await playListsQuery
.OrderBy(p => p.CreatedAt)
.ThenBy(p => p.Id)
.Select(p => new
{
PlayList = p,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
return await playListsQuery
.OrderBy(playList => playList.CreatedAt)
.ThenBy(playList => playList.Id)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -17,14 +17,9 @@ public class RemoveTrackFromPlayListCommandHandler(
{
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists
.AsNoTracking()
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList == null)
return AppErrors.NotFound("PlayList", request.PlayListId);
if (playList.OwnerUserId != request.UserId)
return AppErrors.Forbidden("PlayList", request.PlayListId);
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (playList.IsError)
return playList.Errors;
var playListTracks = await database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId)
@@ -1,6 +1,9 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
namespace Musify.Application.PlayLists.Responses;
public record PlayListApplicationResponse(
@@ -13,13 +16,18 @@ public record PlayListApplicationResponse(
PlayListVisibility Visibility,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
IReadOnlyList<Guid> CoverTrackIds,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
int TrackCount,
double DurationSeconds)
{
public const int CoverTrackCount = 4;
public const int CoverTrackCount = PlayListProjections.CoverTrackCount;
public static PlayListApplicationResponse FromEntity(
PlayList playList,
IReadOnlyList<Guid>? coverTrackIds = null)
IReadOnlyList<Guid>? coverTrackIds = null,
int trackCount = 0,
double durationSeconds = 0)
{
return new PlayListApplicationResponse(
playList.Id,
@@ -31,6 +39,9 @@ public record PlayListApplicationResponse(
playList.Visibility,
playList.CreatedAt,
playList.UpdatedAt,
coverTrackIds ?? []);
coverTrackIds ?? [],
playList.OwnerUserId,
trackCount,
durationSeconds);
}
}
@@ -0,0 +1,37 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses;
public static class PlayListProjections
{
public const int CoverTrackCount = 4;
public static IQueryable<PlayList> Active(this IQueryable<PlayList> query) =>
query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active);
public static IQueryable<PlayList> VisibleTo(this IQueryable<PlayList> query, long? viewerId) =>
query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId);
public static IQueryable<PlayListApplicationResponse> SelectResponse(this IQueryable<PlayList> query) =>
query.Select(playList => new PlayListApplicationResponse(
playList.Id,
playList.Name,
playList.Description,
playList.Pictures!.SmallName,
playList.Pictures!.MediumName,
playList.Pictures!.LargeName,
playList.Visibility,
playList.CreatedAt,
playList.UpdatedAt,
playList.PlayListTracks
.OrderBy(playListTrack => playListTrack.Position)
.Take(CoverTrackCount)
.Select(playListTrack => playListTrack.TrackId)
.ToList(),
playList.OwnerUserId,
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active),
playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
}
@@ -50,7 +50,7 @@ public class UpdatePlayListCommandHandler(
if (!string.IsNullOrWhiteSpace(request.NewName))
{
playListEntity.Name = request.NewName.Trim();
playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
playListEntity.NormalizedName = TextNormalizer.Normalize(request.NewName);
}
if (request.NewDescription != null)
@@ -123,7 +123,7 @@ public class UpdatePlayListCommandHandler(
sizes.Medium,
sizes.Large),
cancellationToken);
return new Success();
return Result.Success;
}
catch (Exception exception)
{