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
@@ -18,23 +18,13 @@ public class AddTrackToAlbumCommandHandler(
{
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.Albums
.AsNoTracking()
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
return AppErrors.NotFound("Album", request.AlbumId);
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (album.IsError)
return album.Errors;
if (album.OwnerUserId != request.UserId)
return AppErrors.Forbidden("Album", request.AlbumId);
var track = await database.Tracks
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
if (track == null)
return AppErrors.NotFound("Track", request.TrackId);
if (track.OwnerUserId != request.UserId)
return AppErrors.Forbidden("Track", request.TrackId);
var track = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken);
if (track.IsError)
return track.Errors;
var alreadyAdded = await database.AlbumHasTracks
.AsNoTracking()
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Services;
using Musify.Domain.Entities;
@@ -53,7 +54,7 @@ public class CreateAlbumCommandHandler(
{
OwnerUserId = request.UserId,
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
NormalizedTitle = TextNormalizer.Normalize(title),
Description = request.Description,
ReleaseYear = request.ReleaseYear,
Pictures = EntityPictures.Pending(intent.ObjectName)
@@ -101,7 +102,7 @@ public class CreateAlbumCommandHandler(
sizes.Medium,
sizes.Large),
cancellationToken);
return new Success();
return Result.Success;
}
catch (Exception exception)
{
@@ -1,6 +1,5 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Events;
@@ -20,20 +19,17 @@ public class DeleteAlbumCommandHandler(
{
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.Albums
.SingleOrDefaultAsync(a => a.Id == request.AlbumId && a.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
if (album == null)
return AppErrors.NotFound("Album", request.AlbumId);
if (album.OwnerUserId != request.UserId)
return AppErrors.Forbidden("Album", request.AlbumId);
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var album = found.Value;
album.LifeCycleStatus = LifeCycleStatus.Removing;
await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Marked album {AlbumId} of user {UserId} as removing", request.AlbumId, request.UserId);
logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId);
return Result.Success;
}
@@ -3,7 +3,7 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
using Musify.Application.Shared;
namespace Musify.Application.Albums;
@@ -17,22 +17,13 @@ public class GetAlbumByIdQueryHandler(IDatabase database)
{
var album = await database.Albums
.AsNoTracking()
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active)
.Select(a => new
{
Album = a,
TrackCount = a.AlbumTracks.Count,
CoverTrackIds = a.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.Active()
.Where(a => a.Id == request.Id)
.SelectResponse()
.SingleOrDefaultAsync(cancellationToken);
if (album == null)
return Error.NotFound();
return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds);
return album == null
? AppErrors.NotFound("Album", request.Id)
: album;
}
}
@@ -1,12 +1,10 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using X.PagedList;
using X.PagedList.EF;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums;
@@ -23,27 +21,18 @@ public class GetAlbumTracksQueryHandler(IDatabase database)
{
var albumExists = await database.Albums
.AsNoTracking()
.AnyAsync(album => album.Id == request.AlbumId && album.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
.Active()
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
if (!albumExists)
return Error.NotFound();
return AppErrors.NotFound("Album", request.AlbumId);
var tracksQuery = database.AlbumHasTracks
return await database.AlbumHasTracks
.AsNoTracking()
.Include(albumTrack => albumTrack.Track.Owner)
.Include(albumTrack => albumTrack.Track.Tags)
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active);
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
.ThenBy(albumTrack => albumTrack.Id)
.Select(albumTrack => albumTrack.Track)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -1,59 +1,33 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using X.PagedList.EF;
using Musify.Domain.ValueObjects;
using Musify.Application.Shared;
namespace Musify.Application.Albums;
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
: IQuery<ErrorOr<AlbumsSearchResponse>>;
: IQuery<PaginatedResponse<AlbumApplicationResponse>>;
public class GetAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsQuery, ErrorOr<AlbumsSearchResponse>>
: IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumsSearchResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
{
var albumsQuery = database.Albums
.AsNoTracking()
.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active);
.Active();
if (!string.IsNullOrEmpty(request.Title))
if (!string.IsNullOrWhiteSpace(request.Title))
{
var normalizedTitle = request.Title.Trim().ToUpperInvariant();
var normalizedTitle = TextNormalizer.Normalize(request.Title);
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
}
var totalCount = await albumsQuery.CountAsync(cancellationToken);
var pagedEntities = await albumsQuery
return await albumsQuery
.OrderByDescending(album => album.CreatedAt)
.Select(album => new
{
Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var items = pagedEntities
.Select(entry => AlbumSearchItemResponse.FromAlbum(
AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)))
.ToList();
return new AlbumsSearchResponse(
items,
pagedEntities.PageNumber,
pagedEntities.PageSize,
pagedEntities.PageCount,
pagedEntities.TotalItemCount,
pagedEntities.HasPreviousPage,
pagedEntities.HasNextPage);
.ThenBy(album => album.Id)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -4,9 +4,6 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using X.PagedList;
using X.PagedList.EF;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums;
@@ -22,32 +19,15 @@ public class GetAlbumsByUserIdQueryHandler(IDatabase database)
.AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists)
return Error.NotFound(description: $"User {request.UserId} not found");
return AppErrors.NotFound("User", request.UserId);
var albumsQuery = database.Albums
return await database.Albums
.AsNoTracking()
.Where(album => album.OwnerUserId == request.UserId && album.LifeCycleStatus == LifeCycleStatus.Active);
var totalCount = await albumsQuery.CountAsync(cancellationToken);
var pagedEntities = await albumsQuery
.Active()
.Where(album => album.OwnerUserId == request.UserId)
.OrderByDescending(album => album.CreatedAt)
.Select(album => new
{
Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedAlbums = new StaticPagedList<AlbumApplicationResponse>(
pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<AlbumApplicationResponse>.FromPagedList(pagedAlbums);
.ThenBy(album => album.Id)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -1,61 +1,51 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums;
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
: IQuery<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>;
: IQuery<IReadOnlyList<AlbumApplicationResponse>>;
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
: IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
{
var listenedTrackIds = database.ListeningHistories
var listens = database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == request.UserId && history.IsCounted);
var ranked = await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
.Join(
listenedTrackIds,
listens,
albumTrack => albumTrack.TrackId,
history => history.TrackId,
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
.GroupBy(entry => entry.AlbumId)
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
.OrderByDescending(entry => entry.LastListenedAt)
.ThenBy(entry => entry.AlbumId)
.Take(request.Limit)
.ToListAsync(cancellationToken);
if (ranked.Count == 0)
return new List<AlbumApplicationResponse>();
return [];
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
var albums = await database.Albums
var albumsById = await database.Albums
.AsNoTracking()
.Where(album => albumIds.Contains(album.Id) && album.LifeCycleStatus == LifeCycleStatus.Active)
.Select(album => new
{
Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToDictionaryAsync(entry => entry.Album.Id, cancellationToken);
.Where(album => albumIds.Contains(album.Id))
.SelectResponse()
.ToDictionaryAsync(album => album.Id, cancellationToken);
return ranked
.Where(entry => albums.ContainsKey(entry.AlbumId))
.Select(entry => albums[entry.AlbumId])
.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))
.Where(entry => albumsById.ContainsKey(entry.AlbumId))
.Select(entry => albumsById[entry.AlbumId])
.ToList();
}
}
@@ -17,14 +17,9 @@ public class RemoveTrackFromAlbumCommandHandler(
{
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.Albums
.AsNoTracking()
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
return AppErrors.NotFound("Album", request.AlbumId);
if (album.OwnerUserId != request.UserId)
return AppErrors.Forbidden("Album", request.AlbumId);
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (album.IsError)
return album.Errors;
var albumTracks = await database.AlbumHasTracks
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
@@ -33,10 +28,7 @@ public class RemoveTrackFromAlbumCommandHandler(
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
if (link == null)
{
logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId);
return Error.NotFound(description: "Track is not in the album.");
}
return Error.NotFound("Album.TrackNotFound", "Track is not in the album.");
database.AlbumHasTracks.Remove(link);
@@ -48,6 +40,6 @@ public class RemoveTrackFromAlbumCommandHandler(
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
return new Success();
return Result.Success;
}
}
@@ -18,7 +18,7 @@ public record AlbumApplicationResponse(
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
{
public const int CoverTrackCount = 4;
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
public static AlbumApplicationResponse FromEntity(
Album album,
@@ -0,0 +1,31 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums.Responses;
public static class AlbumProjections
{
public const int CoverTrackCount = 4;
public static IQueryable<Album> Active(this IQueryable<Album> query) =>
query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active);
public static IQueryable<AlbumApplicationResponse> SelectResponse(this IQueryable<Album> query) =>
query.Select(album => new AlbumApplicationResponse(
album.Id,
album.Title,
album.Description,
album.ReleaseYear,
album.OwnerUserId,
album.AlbumTracks.Count,
album.Pictures!.SmallName,
album.Pictures!.MediumName,
album.Pictures!.LargeName,
album.CreatedAt,
album.UpdatedAt,
album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()));
}
@@ -1,6 +0,0 @@
namespace Musify.Application.Albums.Responses;
public record AlbumSearchItemResponse(AlbumApplicationResponse Album)
{
public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => new(album);
}
@@ -1,11 +0,0 @@
namespace Musify.Application.Albums.Responses;
public record AlbumsSearchResponse(
IReadOnlyList<AlbumSearchItemResponse> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasPreviousPage,
bool HasNextPage);
@@ -51,7 +51,7 @@ public class UpdateAlbumCommandHandler(
var title = request.Title.Trim();
album.Title = title;
album.NormalizedTitle = title.ToUpperInvariant();
album.NormalizedTitle = TextNormalizer.Normalize(title);
album.Description = request.Description;
album.ReleaseYear = request.ReleaseYear;
@@ -118,7 +118,7 @@ public class UpdateAlbumCommandHandler(
sizes.Medium,
sizes.Large),
cancellationToken);
return new Success();
return Result.Success;
}
catch (Exception exception)
{
@@ -19,8 +19,6 @@
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
<PackageReference Include="MimeMapping" />
<PackageReference Include="X.PagedList" />
<PackageReference Include="X.PagedList.EF" />
</ItemGroup>
<ItemGroup>
@@ -1,12 +1,11 @@
using Mediator;
using Musify.Domain.ValueObjects;
using Musify.Application.Genres.Responses;
namespace Musify.Application.Genres;
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{
@@ -2,13 +2,12 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
using Musify.Application.Genres.Responses;
namespace Musify.Application.Genres;
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public record GenreResponse(Genre Genre, int TrackCount);
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{
@@ -0,0 +1,5 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses;
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
@@ -0,0 +1,5 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses;
public record GenreResponse(Genre Genre, int TrackCount);
@@ -1,11 +1,8 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using X.PagedList;
using X.PagedList.EF;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Likes;
@@ -14,30 +11,20 @@ public record GetLikedTracksQuery(
long UserId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
public class GetLikedTracksQueryHandler(IDatabase database)
: IQueryHandler<GetLikedTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
: IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>>
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
{
var likesQuery = database.TrackLikes
return await database.TrackLikes
.AsNoTracking()
.Include(like => like.Track.Owner)
.Include(like => like.Track.Tags)
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active);
var totalCount = await likesQuery.CountAsync(cancellationToken);
var pagedEntities = await likesQuery
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active)
.OrderByDescending(like => like.CreatedAt)
.Select(like => new { like.Track, ListensCount = like.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);
.ThenBy(like => like.Id)
.Select(like => like.Track)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -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.Likes;
@@ -20,12 +21,9 @@ public class ToggleTrackLikeCommandHandler(
{
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)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
return AppErrors.NotFound("Track", request.TrackId);
var existingLike = await database.TrackLikes
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
@@ -33,7 +33,7 @@ public class GenerateMixesForUserCommandHandler(
if (libraryTrackIds.Count == 0)
{
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
return new Success();
return Result.Success;
}
var historyTrackIds = await database.ListeningHistories
@@ -69,23 +69,15 @@ public class GenerateMixesForUserCommandHandler(
if (drafts.Count == 0)
{
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
return new Success();
return Result.Success;
}
await ReplaceMixesAsync(userId, drafts, cancellationToken);
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate mixes for user {UserId}", userId);
return Error.Failure(description: $"Failed to generate mixes for user {userId}");
}
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
return new Success();
return Result.Success;
}
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
@@ -3,6 +3,7 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses;
using Musify.Application.Shared;
namespace Musify.Application.Mixes;
@@ -20,7 +21,7 @@ public class GetMixByIdQueryHandler(IDatabase database)
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
if (mix == null)
return Error.NotFound(description: $"Mix {request.MixId} was not found");
return AppErrors.NotFound("Mix", request.MixId);
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
@@ -1,4 +1,3 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
@@ -7,18 +6,19 @@ using Musify.Application.Mixes.Responses;
namespace Musify.Application.Mixes;
public record GetMixesByUserIdQuery(long UserId)
: IQuery<ErrorOr<IReadOnlyList<MixApplicationResponse>>>;
: IQuery<IReadOnlyList<MixApplicationResponse>>;
public class GetMixesByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixesByUserIdQuery, ErrorOr<IReadOnlyList<MixApplicationResponse>>>
: IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>>
{
public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
public async ValueTask<IReadOnlyList<MixApplicationResponse>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
{
var mixes = await database.Mixes
.AsNoTracking()
.Where(mix => mix.UserId == request.UserId)
.Include(mix => mix.Items)
.OrderBy(mix => mix.Position)
.ThenBy(mix => mix.Id)
.ToListAsync(cancellationToken);
var orderedItemsByMix = mixes.ToDictionary(
@@ -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)
{
@@ -4,6 +4,7 @@ using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Shared;
namespace Musify.Application.Services;
@@ -30,7 +31,7 @@ public sealed class UploadIntentValidator(
if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser)
return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire.");
return new Success();
return Result.Success;
}
public async Task<ErrorOr<UploadIntent>> ValidateAndLoadAsync(
@@ -44,20 +45,20 @@ public sealed class UploadIntentValidator(
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
if (intent == null || intent.UserId != userId)
return Error.NotFound(description: "Upload intent not found or not accessible.");
return AppErrors.NotFound("UploadIntent", intentId);
if (intent.Purpose != purpose)
return Error.Validation(description: "Upload intent was issued for a different purpose.");
if (intent.IsConsumed)
return Error.Conflict(description: "Upload intent has already been consumed.");
return AppErrors.Conflict("UploadIntent.Consumed", "Upload intent has already been consumed.");
if (intent.IsExpiredAt(DateTime.UtcNow))
return Error.Validation(description: "Upload intent has expired.");
var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken);
if (metadata == null)
return Error.NotFound(description: "Uploaded object not found in storage. Upload the file first.");
return Error.NotFound("UploadIntent.ObjectMissing", "Uploaded object not found in storage. Upload the file first.");
if (metadata.ContentLength > config.MaxUploadBytes)
return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes.");
@@ -0,0 +1,28 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Domain.Abstractions;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Shared;
public static class OwnershipExtensions
{
/// <summary>
/// Loads an active, tracked entity owned by <paramref name="userId"/>: NotFound when it is missing
/// or not active, Forbidden when someone else owns it.
/// </summary>
public static async Task<ErrorOr<T>> FindOwnedAsync<T>(
this DbSet<T> set, Guid id, long userId, CancellationToken cancellationToken)
where T : class, IOwnedEntity, IHasLifeCycle
{
var entity = await set.SingleOrDefaultAsync(
e => e.Id == id && e.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
if (entity == null)
return AppErrors.NotFound(typeof(T).Name, id);
return entity.OwnerUserId == userId
? entity
: AppErrors.Forbidden(typeof(T).Name, id);
}
}
@@ -1,25 +0,0 @@
using X.PagedList;
namespace Musify.Application.Shared;
public record PaginatedResponse<T>(
IReadOnlyCollection<T> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasNextPage,
bool HasPreviousPage)
{
public static PaginatedResponse<T> FromPagedList(IPagedList<T> pagedList)
{
return new PaginatedResponse<T>(
pagedList.ToArray(),
pagedList.PageNumber,
pagedList.PageSize,
pagedList.PageCount,
pagedList.TotalItemCount,
pagedList.HasNextPage,
pagedList.HasPreviousPage);
}
}
@@ -1,6 +1,45 @@
using Microsoft.EntityFrameworkCore;
namespace Musify.Application.Shared;
public record PageRequest(int PageNumber = 1, int PageSize = 20)
{
public const int MaxPageSize = 100;
}
public record PaginatedResponse<T>(
IReadOnlyList<T> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasNextPage,
bool HasPreviousPage);
public static class PaginationExtensions
{
/// <summary>Counts and pages the same (already filtered, ordered and projected) query.</summary>
public static async Task<PaginatedResponse<T>> ToPaginatedAsync<T>(
this IQueryable<T> query, PageRequest page, CancellationToken cancellationToken)
{
var pageNumber = Math.Max(page.PageNumber, 1);
var pageSize = Math.Clamp(page.PageSize, 1, PageRequest.MaxPageSize);
var totalCount = await query.CountAsync(cancellationToken);
var items = await query
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
var pageCount = (int)Math.Ceiling(totalCount / (double)pageSize);
return new PaginatedResponse<T>(
items,
pageNumber,
pageSize,
pageCount,
totalCount,
HasNextPage: pageNumber < pageCount,
HasPreviousPage: pageNumber > 1 && pageCount > 0);
}
}
@@ -0,0 +1,6 @@
namespace Musify.Application.Shared;
public static class TextNormalizer
{
public static string Normalize(string value) => value.Trim().ToUpperInvariant();
}
@@ -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.Services;
using Musify.Application.Tracks.Responses;
@@ -82,8 +83,8 @@ public class CreateTrackCommandHandler(
var trackEntity = new Track
{
Title = request.Title,
NormalizedTitle = request.Title.ToUpperInvariant(),
Title = request.Title.Trim(),
NormalizedTitle = TextNormalizer.Normalize(request.Title),
OwnerUserId = user.Id,
Owner = user,
IsExplicit = request.IsExplicit,
@@ -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.Tracks;
@@ -22,49 +21,21 @@ public class DeleteTrackCommandHandler(
{
public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken)
{
var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
if (track == null)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound();
}
if (track.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
return AppErrors.Forbidden("Track", request.TrackId);
}
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var track = found.Value;
if (track.Audio.IsInProgress)
{
logger.LogWarning("Track {TrackId} is currently being processed and cannot be deleted", request.TrackId);
return Error.Conflict(description: "Track is currently being processed and cannot be deleted");
}
return AppErrors.Conflict("Track.Processing", "Track is currently being processed and cannot be deleted.");
track.LifeCycleStatus = LifeCycleStatus.Removing;
try
{
await eventBus.PublishAsync(
new DeleteTrackEvent(track.Id, request.UserId),
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId);
return Error.Failure(description: $"Failed to delete track {request.TrackId}");
}
await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken);
await database.SaveChangesAsync(cancellationToken);
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to mark track {TrackId} as processing before deletion", request.TrackId);
return Error.Failure(description: $"Failed to delete track {request.TrackId}");
}
logger.LogInformation("Marked track {TrackId} as removing", request.TrackId);
return new Success();
return Result.Success;
}
}
@@ -2,40 +2,38 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks;
public record GetListeningHistoryQuery(long UserId) : IQuery<IEnumerable<TrackApplicationResponse>>;
public record GetListeningHistoryQuery(long UserId) : IQuery<IReadOnlyList<TrackApplicationResponse>>;
public class GetListeningHistoryQueryHandler(
IDatabase database)
: IQueryHandler<GetListeningHistoryQuery, IEnumerable<TrackApplicationResponse>>
public class GetListeningHistoryQueryHandler(IDatabase database)
: IQueryHandler<GetListeningHistoryQuery, IReadOnlyList<TrackApplicationResponse>>
{
public const int ListSize = 15;
public async ValueTask<IEnumerable<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
public async ValueTask<IReadOnlyList<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
{
var recentTrackIds = await database.ListeningHistories
.AsNoTracking()
.Where(l => l.UserId == query.UserId && l.IsCounted)
.GroupBy(l => l.TrackId)
.OrderByDescending(g => g.Max(l => l.ListenedAt))
.ThenBy(g => g.Key)
.Select(g => g.Key)
.Take(ListSize)
.ToListAsync(cancellationToken);
var tracksById = await database.Tracks
.AsNoTracking()
.Include(t => t.Owner)
.Include(t => t.Tags)
.Where(t => recentTrackIds.Contains(t.Id) && t.LifeCycleStatus == LifeCycleStatus.Active)
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
.Active()
.Where(t => recentTrackIds.Contains(t.Id))
.SelectResponse()
.ToDictionaryAsync(t => t.Id, cancellationToken);
return recentTrackIds
.Where(id => tracksById.ContainsKey(id))
.Select(id => tracksById[id])
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount));
.ToList();
}
}
@@ -2,8 +2,8 @@ using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks;
@@ -15,16 +15,15 @@ public class GetTrackByIdQueryHandler(IDatabase database)
{
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
{
var entity = await database.Tracks.AsNoTracking()
.Include(t => t.Owner)
.Include(t => t.Tags)
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
var track = await database.Tracks
.AsNoTracking()
.Active()
.Where(t => t.Id == request.TrackId)
.SelectResponse()
.SingleOrDefaultAsync(cancellationToken);
if (entity == null)
return Error.NotFound();
return TrackApplicationResponse.FromEntity(entity.Track, entity.ListensCount);
return track == null
? AppErrors.NotFound("Track", request.TrackId)
: track;
}
}
@@ -3,6 +3,9 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
using MimeMapping;
namespace Musify.Application.Tracks;
@@ -20,12 +23,12 @@ public class GetTrackCoverQueryHandler(
public async ValueTask<ErrorOr<TrackCoverLocation>> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken)
{
var track = await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.TrackId)
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
.Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName })
.SingleOrDefaultAsync(cancellationToken);
if (track == null)
return Error.NotFound();
return AppErrors.NotFound("TrackCover", request.TrackId);
var routes = trackConfiguration.Routes;
var size = PictureSizeParser.Parse(request.Size);
@@ -38,13 +41,9 @@ public class GetTrackCoverQueryHandler(
var key = string.IsNullOrEmpty(name) ? null : routes.BuildPicturePath(size, name);
if (string.IsNullOrEmpty(name) || key == null)
return Error.NotFound();
return AppErrors.NotFound("TrackCover", request.TrackId);
var contentType = name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)
? "image/webp"
: name.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
? "image/png"
: "image/jpeg";
var contentType = MimeUtility.GetMimeMapping(name);
return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType);
}
@@ -7,6 +7,7 @@ using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
using Musify.Application.Shared;
namespace Musify.Application.Tracks;
@@ -32,13 +33,10 @@ public class GetTrackStreamQueryHandler(
.SingleOrDefaultAsync(cancellationToken);
if (track == null)
return Error.NotFound();
return AppErrors.NotFound("Track", request.TrackId);
if (!track.Audio.IsProcessed)
{
logger.LogInformation("Stream requested for track {TrackId} but audio is not ready", request.TrackId);
return Error.Conflict(description: "Track audio is not available for streaming yet.");
}
return AppErrors.Conflict("Track.AudioNotReady", "Track audio is not available for streaming yet.");
var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
@@ -1,55 +1,37 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
using X.PagedList.EF;
namespace Musify.Application.Tracks;
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
: IQuery<ErrorOr<TracksSearchResponse>>;
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
public class GetTracksQueryHandler(IDatabase database)
: IQueryHandler<GetTracksQuery, ErrorOr<TracksSearchResponse>>
: IQueryHandler<GetTracksQuery, PaginatedResponse<TrackApplicationResponse>>
{
public async ValueTask<ErrorOr<TracksSearchResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
{
var tracksQuery = database.UserHasTracks
var tracksQuery = database.Tracks
.AsNoTracking()
.Include(ut => ut.Track.Owner)
.Include(ut => ut.Track.Tags)
.Where(ut => ut.Track.LifeCycleStatus == LifeCycleStatus.Active);
.Active();
if (!string.IsNullOrEmpty(request.Name))
if (!string.IsNullOrWhiteSpace(request.Name))
{
var normalizedName = request.Name.Trim().ToUpperInvariant();
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
var normalizedName = TextNormalizer.Normalize(request.Name);
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
}
if (request.Genre is { } genre)
tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre));
tracksQuery = tracksQuery.Where(track => track.Tags.Any(tag => tag.Tag == genre));
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderByDescending(t => t.Track.CreatedAt)
.ThenBy(t => t.Id)
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var items = pagedEntities
.Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)))
.ToList();
return new TracksSearchResponse(
items,
pagedEntities.PageNumber,
pagedEntities.PageSize,
pagedEntities.PageCount,
pagedEntities.TotalItemCount,
pagedEntities.HasPreviousPage,
pagedEntities.HasNextPage);
return await tracksQuery
.OrderByDescending(track => track.CreatedAt)
.ThenBy(track => track.Id)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -4,9 +4,6 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using X.PagedList;
using X.PagedList.EF;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks;
@@ -22,29 +19,21 @@ public class GetTracksByUserIdQueryHandler(IDatabase database)
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
{
var tracksQuery = database.UserHasTracks
var tracksQuery = database.Tracks
.AsNoTracking()
.Include(ut => ut.Track.Owner)
.Include(ut => ut.Track.Tags)
.Where(p => p.UserId == request.UserId && p.Track.LifeCycleStatus == LifeCycleStatus.Active);
.Active()
.Where(track => track.OwnerUserId == request.UserId);
if (!string.IsNullOrEmpty(request.Name))
if (!string.IsNullOrWhiteSpace(request.Name))
{
var normalizedName = request.Name.Trim().ToUpperInvariant();
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
var normalizedName = TextNormalizer.Normalize(request.Name);
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
}
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderBy(t => t.Track.CreatedAt)
.Select(t => new { t.Track, ListensCount = t.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);
return await tracksQuery
.OrderByDescending(track => track.CreatedAt)
.ThenBy(track => track.Id)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
}
}
@@ -2,6 +2,7 @@ using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.Tracks;
@@ -24,7 +25,7 @@ public class RecordListeningProgressCommandHandler(IDatabase database)
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
if (listen == null)
return Error.NotFound();
return AppErrors.NotFound("Listen", request.ListenId);
var now = DateTime.UtcNow;
var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds;
@@ -0,0 +1,24 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks.Responses;
public static class TrackProjections
{
public static IQueryable<Track> Active(this IQueryable<Track> query) =>
query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active);
public static IQueryable<TrackApplicationResponse> SelectResponse(this IQueryable<Track> query) =>
query.Select(track => new TrackApplicationResponse(
track.Id,
track.Title,
track.Owner.Name,
track.Audio.TranscodeStatus,
track.DurationSeconds,
track.ListeningHistories.Count(listen => listen.IsCounted),
track.CreatedAt,
track.UpdatedAt,
track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit));
}
@@ -1,6 +0,0 @@
namespace Musify.Application.Tracks.Responses;
public record TrackSearchItemResponse(TrackApplicationResponse Track)
{
public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) => new(track);
}
@@ -1,11 +0,0 @@
namespace Musify.Application.Tracks.Responses;
public record TracksSearchResponse(
IReadOnlyList<TrackSearchItemResponse> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasPreviousPage,
bool HasNextPage);
@@ -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;
}
}
@@ -2,7 +2,5 @@ namespace Musify.Domain.Abstractions;
public interface IOwnedEntity
{
public Guid Id { get; }
public long OwnerUserId { get; }
}