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:
@@ -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));
|
||||
}
|
||||
Reference in New Issue
Block a user