blocker phase 6, catalog admin

This commit is contained in:
hamid
2026-08-02 22:09:45 +03:30
parent e3c988e961
commit 9a55846df3
31 changed files with 1385 additions and 13 deletions
@@ -7,7 +7,10 @@ using Baya.Application.Features.Catalog.Commands.SetServiceCategoryActive;
using Baya.Application.Features.Catalog.Commands.UpdateServiceCategory;
using Baya.Application.Features.Catalog.Commands.UpdateServiceOptionGroup;
using Baya.Application.Features.Catalog.Commands.UpdateServiceOptionValue;
using Baya.Application.Features.Catalog.Queries.GetAdminOptionGroups;
using Baya.Application.Features.Catalog.Queries.GetAdminServiceCategories;
using Baya.Application.Models.Catalog;
using Baya.Application.Models.Common;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
@@ -24,6 +27,16 @@ namespace Baya.Web.Api.Controllers.V1;
[Display(Description = "Admin: curate the catalog skeleton (categories + option groups/values; no delete)")]
public sealed class AdminCatalogController(ISender sender) : BaseController
{
[HttpGet("[action]")]
[ProducesOkApiResponseType<PagedResult<ServiceCategoryDto>>]
public async Task<IActionResult> ListCategories([FromQuery] GetAdminServiceCategoriesQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<IReadOnlyList<OptionGroupDto>>]
public async Task<IActionResult> ListOptionGroups([FromQuery(Name = "category_id")] long categoryId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetAdminOptionGroupsQuery(categoryId), cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType<ServiceCategoryDto>]
public async Task<IActionResult> CreateCategory(CreateServiceCategoryCommand command, CancellationToken cancellationToken)
@@ -19,6 +19,15 @@ public interface ICatalogRepository
/// its active values, ordered by sort order. Empty is valid (no dimensions defined yet).</summary>
Task<IReadOnlyList<OptionGroupDto>> GetApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken);
/// <summary>Admin: every category regardless of active state, ordered by sort order — so a deactivated
/// category stays reachable to review or reactivate. Not cached (admin traffic, always fresh).</summary>
Task<IReadOnlyList<ServiceCategoryDto>> ListAllCategoriesAsync(CancellationToken cancellationToken);
/// <summary>Admin: the category's own groups plus every cross-category group, <b>including inactive
/// groups and inactive values</b>, ordered by sort order — the read side of the active-flag toggles.
/// Not cached.</summary>
Task<IReadOnlyList<OptionGroupDto>> GetAllApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken);
/// <summary>Active-only category projection for variant creation — null if missing or deactivated.</summary>
Task<ServiceCategoryDto?> GetActiveCategoryAsync(long id, CancellationToken cancellationToken);
@@ -0,0 +1,16 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Catalog;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Catalog.Queries.GetAdminOptionGroups;
internal sealed class GetAdminOptionGroupsQueryHandler(IUnitOfWork unitOfWork)
: IRequestHandler<GetAdminOptionGroupsQuery, OperationResult<IReadOnlyList<OptionGroupDto>>>
{
public async ValueTask<OperationResult<IReadOnlyList<OptionGroupDto>>> Handle(GetAdminOptionGroupsQuery request, CancellationToken cancellationToken)
{
var groups = await unitOfWork.CatalogRepository.GetAllApplicableGroupsAsync(request.CategoryId, cancellationToken);
return OperationResult<IReadOnlyList<OptionGroupDto>>.SuccessResult(groups);
}
}
@@ -0,0 +1,12 @@
using Baya.Application.Models.Catalog;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Catalog.Queries.GetAdminOptionGroups;
/// <summary>
/// Admin: a category's applicable option groups — its own plus every cross-category (NULL) group —
/// <b>including inactive groups, each with every value including inactive ones</b>. The read side of
/// group/value management; the public <c>GetCategoryOptionGroupsQuery</c> deliberately hides both.
/// </summary>
public record GetAdminOptionGroupsQuery(long CategoryId) : IRequest<OperationResult<IReadOnlyList<OptionGroupDto>>>;
@@ -0,0 +1,24 @@
using Baya.Application.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Catalog;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Catalog.Queries.GetAdminServiceCategories;
internal sealed class GetAdminServiceCategoriesQueryHandler(IUnitOfWork unitOfWork)
: IRequestHandler<GetAdminServiceCategoriesQuery, OperationResult<PagedResult<ServiceCategoryDto>>>
{
public async ValueTask<OperationResult<PagedResult<ServiceCategoryDto>>> Handle(GetAdminServiceCategoriesQuery request, CancellationToken cancellationToken)
{
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
// Unlike the public read, this is deliberately uncached — an admin editing a category expects to
// see their own write reflected immediately, and admin list traffic is negligible.
var all = await unitOfWork.CatalogRepository.ListAllCategoriesAsync(cancellationToken);
var items = all.Skip((page - 1) * pageSize).Take(pageSize).ToList();
return OperationResult<PagedResult<ServiceCategoryDto>>.SuccessResult(
new PagedResult<ServiceCategoryDto>(items, all.Count, page, pageSize));
}
}
@@ -0,0 +1,10 @@
using Baya.Application.Models.Catalog;
using Baya.Application.Models.Common;
using Mediator;
namespace Baya.Application.Features.Catalog.Queries.GetAdminServiceCategories;
/// <summary>Admin: every category regardless of active state, ordered by sort order, paginated — the read
/// side of category management (a deactivated category must stay reachable to review or reactivate).</summary>
public record GetAdminServiceCategoriesQuery(int Page = 1, int PageSize = 50)
: IRequest<OperationResult<PagedResult<ServiceCategoryDto>>>;
@@ -45,6 +45,36 @@ internal sealed class CatalogRepository : ICatalogRepository
.ToList()))
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<ServiceCategoryDto>> ListAllCategoriesAsync(CancellationToken cancellationToken)
=> await _db.Set<ServiceCategory>()
.AsNoTracking()
.OrderBy(c => c.SortOrder)
.ThenBy(c => c.Id)
.Select(c => new ServiceCategoryDto(
c.Id, c.NameFa, c.NameEn, c.DescriptionFa, c.DescriptionEn, c.IconKey, c.SortOrder, c.IsActive))
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<OptionGroupDto>> GetAllApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken)
=> await _db.Set<ServiceOptionGroup>()
.AsNoTracking()
.Where(g => g.ServiceCategoryId == categoryId || g.ServiceCategoryId == null)
.OrderBy(g => g.SortOrder)
.ThenBy(g => g.Id)
.Select(g => new OptionGroupDto(
g.Id,
g.ServiceCategoryId,
g.NameFa,
g.NameEn,
g.IsRequired,
g.SortOrder,
g.IsActive,
g.Values
.OrderBy(v => v.SortOrder)
.ThenBy(v => v.Id)
.Select(v => new OptionValueDto(v.Id, v.NameFa, v.NameEn, v.SortOrder, v.IsActive))
.ToList()))
.ToListAsync(cancellationToken);
public Task<ServiceCategoryDto?> GetActiveCategoryAsync(long id, CancellationToken cancellationToken)
=> _db.Set<ServiceCategory>()
.AsNoTracking()