backend phase 7: search & matching (nurse_search_index)

Add the discovery layer: the denormalized nurse_search_index read model
(one row per bookable variant x covered service area), maintained inline
inside each source write's transaction, plus the single public search
query behind the INurseSearch seam.

- Entity + EF config + migration (search schema): covering search index,
  filtered-unique (variant_id, city_id, district_id) pair with NULL
  district participating, nurse_id index, soft-delete.
- ISearchIndexMaintainer (write seam) + SearchIndexMaintainer: reindex
  variant / nurse / fan-out / remove-area / full rebuild, staged in the
  owning source write's unit of work; wired into the b3/b4/b5/b6 handlers.
- INurseSearch (read seam) + SqlNurseSearch (real MVP backend): reads only
  is_searchable=1, category/city/district(NULL-aware)/gender/price filters,
  rating sort, pagination. Elasticsearch deferred (config Search:Backend).
- SearchNursesQuery (+ validator) and RebuildSearchIndexCommand; public
  SearchController (GET search/nurses) + admin AdminSearchController
  (POST admin_search/rebuild_index).
- Tests: 9 DB-backed maintainer/search + 4 WebApplicationFactory; updated
  affected b3/b4/b5/b6 handler tests. Build clean, 167 tests green.
- Docs: server CLAUDE.md project map, contract search.md, swagger refresh,
  handoff, report, mocks-registry rows, STATUS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-05 17:24:26 +03:30
parent e2b22df2d6
commit 5839b3508f
47 changed files with 5743 additions and 41 deletions
@@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Search.Commands.RebuildSearchIndex;
using Baya.Application.Models.Search;
using Baya.Infrastructure.Identity.Identity.PermissionManager;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Baya.WebFramework.ServiceConfiguration;
using Mediator;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// Admin maintenance for the search index. The rebuild is the idempotent convergence/reconciliation path —
/// its result must match the incrementally-maintained index.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Authorize(ConstantPolicies.DynamicPermission)]
[EnableRateLimiting(RateLimitingServiceExtension.SensitivePolicy)]
[Display(Description = "Admin search-index maintenance (full rebuild / reconciliation)")]
public sealed class AdminSearchController(ISender sender) : BaseController
{
[HttpPost("[action]")]
[ProducesOkApiResponseType<SearchIndexRebuildResult>]
public async Task<IActionResult> RebuildIndex(CancellationToken cancellationToken)
=> OperationResult(await sender.Send(new RebuildSearchIndexCommand(), cancellationToken));
}
@@ -0,0 +1,43 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using Asp.Versioning;
using Baya.Application.Features.Search.Queries.SearchNurses;
using Baya.Application.Models.Common;
using Baya.Application.Models.Search;
using Baya.WebFramework.Attributes;
using Baya.WebFramework.BaseController;
using Mediator;
using Microsoft.AspNetCore.Mvc;
namespace Baya.Web.Api.Controllers.V1;
/// <summary>
/// Public nurse discovery. Pre-auth (families browse before signing in) and covered by the per-IP global
/// rate limiter. Reads only searchable (verified + accepting + active) rows via the <c>INurseSearch</c> seam.
/// </summary>
[ApiVersion("1")]
[ApiController]
[Route("api/v{version:apiVersion}/[controller]")]
[Display(Description = "Public nurse search: category + city/district geo, same-gender filter, price range, rating sort")]
public sealed class SearchController(ISender sender) : BaseController
{
[HttpGet("[action]")]
[ProducesOkApiResponseType<PagedResult<NurseSearchResultDto>>]
public async Task<IActionResult> Nurses(
[FromQuery(Name = "service_category_id")] long serviceCategoryId,
[FromQuery(Name = "city_id")] long cityId,
[FromQuery(Name = "district_id")] long? districtId,
[FromQuery(Name = "nurse_gender")] string? nurseGender,
[FromQuery(Name = "min_price")] long? minPrice,
[FromQuery(Name = "max_price")] long? maxPrice,
[FromQuery(Name = "price_unit")] string? priceUnit,
[FromQuery(Name = "page")] int page,
[FromQuery(Name = "page_size")] int pageSize,
CancellationToken cancellationToken)
=> OperationResult(await sender.Send(
new SearchNursesQuery(
serviceCategoryId, cityId, districtId, nurseGender, minPrice, maxPrice, priceUnit,
page <= 0 ? 1 : page,
pageSize <= 0 ? Application.Common.Pagination.DefaultPageSize : pageSize),
cancellationToken));
}