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:
+72
@@ -0,0 +1,72 @@
|
||||
#nullable enable
|
||||
using System.Globalization;
|
||||
using Baya.Application.Contracts.Search;
|
||||
using Baya.Application.Models.Common;
|
||||
using Baya.Application.Models.Search;
|
||||
using Baya.Domain.Entities.Search;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Baya.Infrastructure.Persistence.Services.Search;
|
||||
|
||||
/// <summary>
|
||||
/// The MVP <see cref="INurseSearch"/> backend — the real, production search over the maintained
|
||||
/// <c>nurse_search_index</c>. It reads <b>only</b> <c>is_searchable = 1</c> rows (an unverified, suspended,
|
||||
/// paused, or deactivated nurse/variant never surfaces), applies the category/city/district/gender/price
|
||||
/// filters and the rating sort, and paginates. Served from the covering search index; a later
|
||||
/// <c>ElasticNurseSearch</c> replaces this class behind the same interface with no caller changes.
|
||||
/// </summary>
|
||||
internal sealed class SqlNurseSearch(ApplicationDbContext db) : INurseSearch
|
||||
{
|
||||
public async Task<PagedResult<NurseSearchResultDto>> SearchAsync(NurseSearchCriteria criteria, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = db.Set<NurseSearchIndex>()
|
||||
.AsNoTracking()
|
||||
.Where(r => r.IsSearchable
|
||||
&& r.ServiceCategoryId == criteria.ServiceCategoryId
|
||||
&& r.CityId == criteria.CityId);
|
||||
|
||||
// NULL-district = "whole city". A district search matches that district's rows PLUS the whole-city
|
||||
// (NULL) rows; a city-only search (no district) matches every row in the city, NULL or not.
|
||||
if (criteria.DistrictId is { } districtId)
|
||||
query = query.Where(r => r.DistrictId == districtId || r.DistrictId == null);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(criteria.NurseGender))
|
||||
query = query.Where(r => r.NurseGender == criteria.NurseGender);
|
||||
|
||||
if (criteria.MinPrice is { } min)
|
||||
query = query.Where(r => r.Price >= min);
|
||||
|
||||
if (criteria.MaxPrice is { } max)
|
||||
query = query.Where(r => r.Price <= max);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(criteria.PriceUnit))
|
||||
query = query.Where(r => r.PriceUnit == criteria.PriceUnit);
|
||||
|
||||
var total = await query.CountAsync(cancellationToken);
|
||||
|
||||
var rows = await query
|
||||
// Rating sort is the only MVP sort; the tiebreak on reviews then nurse_id keeps paging deterministic.
|
||||
.OrderByDescending(r => r.AverageRating)
|
||||
.ThenByDescending(r => r.TotalReviews)
|
||||
.ThenBy(r => r.NurseId)
|
||||
.ThenBy(r => r.VariantId)
|
||||
.Skip((criteria.Page - 1) * criteria.PageSize)
|
||||
.Take(criteria.PageSize)
|
||||
.Select(r => new Row(
|
||||
r.VariantId, r.NurseId, r.ServiceCategoryId, r.Price, r.PriceUnit, r.NurseGender,
|
||||
r.AverageRating, r.TotalReviews, r.TotalCompletedBookings, r.CityId, r.DistrictId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
// Format price to a digit string in memory (no long.ToString translation required in SQL).
|
||||
var items = rows.Select(r => new NurseSearchResultDto(
|
||||
r.VariantId, r.NurseId, r.ServiceCategoryId,
|
||||
r.Price.ToString(CultureInfo.InvariantCulture), r.PriceUnit, r.NurseGender,
|
||||
r.AverageRating, r.TotalReviews, r.TotalCompletedBookings, r.CityId, r.DistrictId)).ToList();
|
||||
|
||||
return new PagedResult<NurseSearchResultDto>(items, total, criteria.Page, criteria.PageSize);
|
||||
}
|
||||
|
||||
private sealed record Row(
|
||||
long VariantId, long NurseId, long ServiceCategoryId, long Price, string PriceUnit, string NurseGender,
|
||||
decimal AverageRating, int TotalReviews, int TotalCompletedBookings, long CityId, long? DistrictId);
|
||||
}
|
||||
Reference in New Issue
Block a user