Add shared pagination, projections and ownership helpers; simplify query and command handlers
This commit is contained in:
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user