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
@@ -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();
}