backend phase 5: service catalog & nurse pricing variants

Two-tier service model the marketplace is priced and searched on. Admin
catalog skeleton (categories + EAV option groups/values, addable as data
not migrations; NULL category = cross-category) and the nurse pricing layer
(nurse_service_variants — the atomic bookable unit: category + one value per
required dimension at the nurse's own IRR price and price unit).

- New `catalog` schema via one additive migration; Price BIGINT (no floats),
  on the wire as a string of digits; total = price + unit + session_count.
- Duplicate-listing guard: deterministic option_set_hash + filtered
  UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS
  NULL + friendly 409 pre-check. One value per dimension; required groups
  (incl. cross-category) enforced; deactivate, never delete.
- Public catalog browse cached behind a CatalogCache generation token,
  invalidated on any admin write. IVariantSnapshotSerializer shipped for b8.
- Contract (catalog.md) + handoff + report published; swagger refreshed.
  122 tests green; zero new build warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-02 22:21:53 +03:30
parent 4b4243c451
commit f77a23cb25
84 changed files with 8229 additions and 4 deletions
@@ -0,0 +1,61 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Catalog.Commands.CreateServiceCategory;
using Baya.Application.Features.Catalog.Commands.CreateServiceOptionGroup;
using Baya.Application.Features.Catalog.Commands.CreateServiceOptionValue;
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.Models.Catalog;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize(ConstantPolicies.DynamicPermission)]
[Display(Description = "Admin: curate the catalog skeleton (categories + option groups/values; no delete)")]
public sealed class AdminCatalogController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<ServiceCategoryDto>]
public async Task<IActionResult> CreateCategory(CreateServiceCategoryCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<ServiceCategoryDto>]
public async Task<IActionResult> UpdateCategory(long id, UpdateServiceCategoryCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> SetCategoryActive(long id, SetServiceCategoryActiveCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType<OptionGroupDto>]
public async Task<IActionResult> CreateOptionGroup(CreateServiceOptionGroupCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<OptionGroupDto>]
public async Task<IActionResult> UpdateOptionGroup(long id, UpdateServiceOptionGroupCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]")]
[ProducesOkApiResponseType<OptionValueDto>]
public async Task<IActionResult> CreateOptionValue(CreateServiceOptionValueCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<OptionValueDto>]
public async Task<IActionResult> UpdateOptionValue(long id, UpdateServiceOptionValueCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
}
@@ -0,0 +1,29 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Catalog.Queries.GetCatalogCategories;
using Baya.Application.Features.Catalog.Queries.GetCategoryOptionGroups;
using Baya.Application.Models.Catalog;
using Baya.Application.Models.Common;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Display(Description = "Public catalog browse: active categories and a category's applicable option groups")]
public sealed class CatalogController(ISender sender) : BaseController
{
[HttpGet("[action]")]
[ProducesOkApiResponseType<PagedResult<ServiceCategoryDto>>]
public async Task<IActionResult> Categories([FromQuery] GetCatalogCategoriesQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<IReadOnlyList<OptionGroupDto>>]
public async Task<IActionResult> OptionGroups([FromQuery(Name = "category_id")] long categoryId, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetCategoryOptionGroupsQuery(categoryId), cancellationToken));
}
@@ -0,0 +1,51 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Variants.Commands.CreateVariant;
using Baya.Application.Features.Variants.Commands.SetVariantActive;
using Baya.Application.Features.Variants.Commands.UpdateVariant;
using Baya.Application.Features.Variants.Queries.GetVariant;
using Baya.Application.Features.Variants.Queries.ListMyVariants;
using Baya.Application.Models.Catalog;
using Baya.Application.Models.Common;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize]
[Display(Description = "The signed-in nurse's priced service variants (the bookable unit)")]
public sealed class NurseVariantsController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<VariantDto>]
public async Task<IActionResult> Create(CreateVariantCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType<VariantDto>]
public async Task<IActionResult> Update(long id, UpdateVariantCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpPost("[action]/{id}")]
[ProducesOkApiResponseType]
public async Task<IActionResult> SetActive(long id, SetVariantActiveCommand command, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(command with { Id = id }, cancellationToken));
[HttpGet("[action]")]
[ProducesOkApiResponseType<PagedResult<VariantDto>>]
public async Task<IActionResult> List([FromQuery] ListMyVariantsQuery query, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(query, cancellationToken));
// Owner/admin get the full view; any other caller (public nurse-profile view) gets active-only.
[AllowAnonymous]
[HttpGet("[action]/{id}")]
[ProducesOkApiResponseType<VariantDto>]
public async Task<IActionResult> Get(long id, CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new GetVariantQuery(id), cancellationToken));
}