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,285 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Search;
using Baya.Application.Models.Search;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Search;
using Baya.Domain.Entities.Verification;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Services.Search;
/// <summary>
/// The inline SQL implementation of <see cref="ISearchIndexMaintainer"/>. It shares the request-scoped
/// <see cref="ApplicationDbContext"/> with the calling handler's <c>IUnitOfWork</c>, so it only <b>stages</b>
/// index changes — the handler's single <c>CommitAsync</c> flushes source + projection in one transaction.
/// (<see cref="RebuildAsync"/> is the exception: a standalone job that owns its own batched commits.)
/// <para>
/// The visibility gate is recomputed on every call: a row is searchable only when the nurse is verified,
/// not suspended, accepting bookings, and the variant is active. Each (variant × area) has exactly one live
/// row; a soft-deleted row is resurrected on re-upsert rather than duplicated.
/// </para>
/// </summary>
internal sealed class SearchIndexMaintainer(ApplicationDbContext db, IDateTimeProvider clock) : ISearchIndexMaintainer
{
public async Task ReindexVariantAsync(NurseServiceVariant variant, CancellationToken cancellationToken)
{
var ctx = await LoadNurseContextAsync(variant.NurseId, cancellationToken);
if (ctx is null)
return;
var status = await LoadStatusAsync(variant.NurseId, cancellationToken);
var bookable = NurseBookable(ctx.IsVerified, ctx.IsAcceptingBookings, status);
var areas = await LoadActiveAreasAsync(variant.NurseId, cancellationToken);
var vk = new VariantKey(variant.Id, variant.ServiceCategoryId, variant.Price, variant.PriceUnit, variant.IsActive);
foreach (var area in areas)
await UpsertRowAsync(variant, vk, area, variant.NurseId, ctx, bookable, cancellationToken);
// Reconcile: soft-delete this variant's live rows whose area the nurse no longer covers.
if (variant.Id != 0)
{
var live = await db.Set<NurseSearchIndex>()
.Where(r => r.VariantId == variant.Id)
.ToListAsync(cancellationToken);
var covered = areas.ToHashSet();
foreach (var row in live)
if (!covered.Contains(new AreaKey(row.CityId, row.DistrictId)))
SoftDelete(row);
}
}
public async Task ReindexNurseAsync(NurseProfile profile, VerificationStatus? verificationStatus, CancellationToken cancellationToken)
{
var status = verificationStatus ?? await LoadStatusAsync(profile.Id, cancellationToken);
var gender = await LoadGenderAsync(profile.Id, cancellationToken);
// Bookability + aggregates come from the tracked profile (its just-changed values); gender is stable
// for these triggers so it is read from the database.
var ctx = new NurseContext(
profile.IsVerified, profile.IsAcceptingBookings, gender,
profile.AverageRating, profile.TotalReviews, profile.TotalCompletedBookings);
var bookable = NurseBookable(profile.IsVerified, profile.IsAcceptingBookings, status);
var areas = await LoadActiveAreasAsync(profile.Id, cancellationToken);
var variants = await LoadVariantsAsync(profile.Id, cancellationToken);
var target = new HashSet<(long VariantId, long CityId, long? DistrictId)>();
foreach (var variant in variants)
foreach (var area in areas)
{
await UpsertRowAsync(null, variant, area, profile.Id, ctx, bookable, cancellationToken);
target.Add((variant.Id, area.CityId, area.DistrictId));
}
// Prune live rows no longer derivable (variant deleted / area removed).
var liveRows = await db.Set<NurseSearchIndex>()
.Where(r => r.NurseId == profile.Id)
.ToListAsync(cancellationToken);
foreach (var row in liveRows)
if (!target.Contains((row.VariantId, row.CityId, row.DistrictId)))
SoftDelete(row);
}
public async Task FanOutServiceAreaAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken)
{
var ctx = await LoadNurseContextAsync(nurseId, cancellationToken);
if (ctx is null)
return;
var status = await LoadStatusAsync(nurseId, cancellationToken);
var bookable = NurseBookable(ctx.IsVerified, ctx.IsAcceptingBookings, status);
var variants = await LoadVariantsAsync(nurseId, cancellationToken);
var area = new AreaKey(cityId, districtId);
foreach (var variant in variants)
await UpsertRowAsync(null, variant, area, nurseId, ctx, bookable, cancellationToken);
}
public async Task RemoveServiceAreaRowsAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken)
{
var rows = await db.Set<NurseSearchIndex>()
.Where(r => r.NurseId == nurseId && r.CityId == cityId && r.DistrictId == districtId)
.ToListAsync(cancellationToken);
foreach (var row in rows)
SoftDelete(row);
}
public async Task<SearchIndexRebuildResult> RebuildAsync(CancellationToken cancellationToken)
{
// Idempotent full rebuild: drop the whole projection, then re-derive from source in nurse-batches.
await db.Set<NurseSearchIndex>().IgnoreQueryFilters().ExecuteDeleteAsync(cancellationToken);
const int batchSize = 200;
var pageIndex = 0;
var nursesProcessed = 0;
var rowsWritten = 0;
while (true)
{
var nurseIds = await db.Set<NurseProfile>()
.OrderBy(p => p.Id)
.Skip(pageIndex * batchSize)
.Take(batchSize)
.Select(p => p.Id)
.ToListAsync(cancellationToken);
if (nurseIds.Count == 0)
break;
foreach (var nurseId in nurseIds)
{
rowsWritten += await BuildFreshRowsForNurseAsync(nurseId, cancellationToken);
nursesProcessed++;
}
await db.SaveChangesAsync(cancellationToken);
pageIndex++;
}
return new SearchIndexRebuildResult(nursesProcessed, rowsWritten);
}
private async Task<int> BuildFreshRowsForNurseAsync(long nurseId, CancellationToken cancellationToken)
{
var ctx = await LoadNurseContextAsync(nurseId, cancellationToken);
if (ctx is null)
return 0;
var status = await LoadStatusAsync(nurseId, cancellationToken);
var bookable = NurseBookable(ctx.IsVerified, ctx.IsAcceptingBookings, status);
var areas = await LoadActiveAreasAsync(nurseId, cancellationToken);
var variants = await LoadVariantsAsync(nurseId, cancellationToken);
var count = 0;
foreach (var variant in variants)
foreach (var area in areas)
{
await db.Set<NurseSearchIndex>().AddAsync(
NewRow(null, variant, area, nurseId, ctx, bookable && variant.IsActive), cancellationToken);
count++;
}
return count;
}
private async Task UpsertRowAsync(
NurseServiceVariant? variantEntity,
VariantKey variant,
AreaKey area,
long nurseId,
NurseContext ctx,
bool nurseBookable,
CancellationToken cancellationToken)
{
var isSearchable = nurseBookable && variant.IsActive;
// A new variant (id 0) can have no existing rows; otherwise look past the soft-delete filter so a
// previously-removed (variant × area) row is resurrected rather than duplicated.
var existing = variant.Id == 0
? null
: await db.Set<NurseSearchIndex>()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(
r => r.VariantId == variant.Id && r.CityId == area.CityId && r.DistrictId == area.DistrictId,
cancellationToken);
if (existing is null)
{
await db.Set<NurseSearchIndex>().AddAsync(
NewRow(variantEntity, variant, area, nurseId, ctx, isSearchable), cancellationToken);
return;
}
existing.NurseId = nurseId;
existing.ServiceCategoryId = variant.ServiceCategoryId;
existing.Price = variant.Price;
existing.PriceUnit = variant.PriceUnit;
existing.NurseGender = ctx.Gender;
existing.AverageRating = ctx.AverageRating;
existing.TotalReviews = ctx.TotalReviews;
existing.TotalCompletedBookings = ctx.TotalCompletedBookings;
existing.IsSearchable = isSearchable;
existing.UpdatedAt = clock.UtcNow;
existing.DeletedAt = null;
}
private NurseSearchIndex NewRow(
NurseServiceVariant? variantEntity, VariantKey variant, AreaKey area, long nurseId, NurseContext ctx, bool isSearchable)
{
var row = new NurseSearchIndex
{
VariantId = variant.Id,
NurseId = nurseId,
ServiceCategoryId = variant.ServiceCategoryId,
Price = variant.Price,
PriceUnit = variant.PriceUnit,
CityId = area.CityId,
DistrictId = area.DistrictId,
NurseGender = ctx.Gender,
AverageRating = ctx.AverageRating,
TotalReviews = ctx.TotalReviews,
TotalCompletedBookings = ctx.TotalCompletedBookings,
IsSearchable = isSearchable,
UpdatedAt = clock.UtcNow
};
// For a freshly-created variant the id is not assigned yet — attach the tracked principal so EF sets
// the generated variant_id in the same graph insert.
if (variant.Id == 0 && variantEntity is not null)
row.Variant = variantEntity;
return row;
}
private void SoftDelete(NurseSearchIndex row)
{
row.DeletedAt = clock.UtcNow;
row.IsSearchable = false;
row.UpdatedAt = clock.UtcNow;
}
private static bool NurseBookable(bool isVerified, bool isAccepting, VerificationStatus? status)
=> isVerified && isAccepting && status != VerificationStatus.Suspended;
private Task<NurseContext?> LoadNurseContextAsync(long nurseId, CancellationToken cancellationToken)
=> db.Set<NurseProfile>()
.Where(p => p.Id == nurseId)
.Select(p => new NurseContext(
p.IsVerified, p.IsAcceptingBookings, p.User.Gender,
p.AverageRating, p.TotalReviews, p.TotalCompletedBookings))
.FirstOrDefaultAsync(cancellationToken);
private async Task<string> LoadGenderAsync(long nurseId, CancellationToken cancellationToken)
=> await db.Set<NurseProfile>()
.Where(p => p.Id == nurseId)
.Select(p => p.User.Gender)
.FirstOrDefaultAsync(cancellationToken) ?? string.Empty;
private Task<VerificationStatus?> LoadStatusAsync(long nurseId, CancellationToken cancellationToken)
=> db.Set<NurseVerification>()
.Where(v => v.NurseId == nurseId)
.Select(v => (VerificationStatus?)v.Status)
.FirstOrDefaultAsync(cancellationToken);
private Task<List<AreaKey>> LoadActiveAreasAsync(long nurseId, CancellationToken cancellationToken)
=> db.Set<NurseServiceArea>()
.Where(a => a.NurseId == nurseId && a.IsActive)
.Select(a => new AreaKey(a.CityId, a.DistrictId))
.ToListAsync(cancellationToken);
private Task<List<VariantKey>> LoadVariantsAsync(long nurseId, CancellationToken cancellationToken)
=> db.Set<NurseServiceVariant>()
.Where(v => v.NurseId == nurseId)
.Select(v => new VariantKey(v.Id, v.ServiceCategoryId, v.Price, v.PriceUnit, v.IsActive))
.ToListAsync(cancellationToken);
private readonly record struct AreaKey(long CityId, long? DistrictId);
private readonly record struct VariantKey(long Id, long ServiceCategoryId, long Price, string PriceUnit, bool IsActive);
private sealed record NurseContext(
bool IsVerified, bool IsAcceptingBookings, string Gender,
decimal AverageRating, int TotalReviews, int TotalCompletedBookings);
}
@@ -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);
}