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
@@ -1,6 +1,7 @@
using Baya.Application.Contracts.Audit;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Features.Verification.Commands.ReviewStep;
using Baya.Application.Features.Verification.Commands.ScanExpiringCredentials;
@@ -25,6 +26,7 @@ public class AdminVerificationHandlersTests
private readonly ICacheService _cache = Substitute.For<ICacheService>();
private readonly IAuditLogger _audit = Substitute.For<IAuditLogger>();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
private readonly ISearchIndexMaintainer _searchIndex = Substitute.For<ISearchIndexMaintainer>();
public AdminVerificationHandlersTests()
{
@@ -44,7 +46,7 @@ public class AdminVerificationHandlersTests
}
private AdminReviewStepCommandHandler ReviewHandler(ICredentialVerifier credentialVerifier)
=> new(_currentUser, _unitOfWork, credentialVerifier, _audit, _cache, _clock);
=> new(_currentUser, _unitOfWork, credentialVerifier, _audit, _cache, _clock, _searchIndex);
[Fact]
public async Task Review_ApproveCredentialStep_RecordsCredentialAndFlipsVerified()
@@ -121,7 +123,7 @@ public class AdminVerificationHandlersTests
var profile = new NurseProfile();
profile.MarkVerified();
_nurses.GetTrackedByIdAsync(42, Arg.Any<CancellationToken>()).Returns(profile);
var handler = new AdminSuspendVerificationCommandHandler(_currentUser, _unitOfWork, _audit, _cache, _clock);
var handler = new AdminSuspendVerificationCommandHandler(_currentUser, _unitOfWork, _audit, _cache, _clock, _searchIndex);
var result = await handler.Handle(new AdminSuspendVerificationCommand(10, "Fraud reported"), CancellationToken.None);
@@ -151,7 +153,7 @@ public class AdminVerificationHandlersTests
var alerts = Substitute.For<ISupportAlertService>();
var notifications = Substitute.For<INotificationDispatcher>();
var handler = new ScanExpiringCredentialsCommandHandler(_unitOfWork, alerts, notifications, _cache, _clock);
var handler = new ScanExpiringCredentialsCommandHandler(_unitOfWork, alerts, notifications, _cache, _clock, _searchIndex);
var result = await handler.Handle(new ScanExpiringCredentialsCommand(), CancellationToken.None);
@@ -193,7 +195,7 @@ public class AdminVerificationHandlersTests
_nurses.GetTrackedByIdAsync(99, Arg.Any<CancellationToken>()).Returns(profileB);
var handler = new ScanExpiringCredentialsCommandHandler(
_unitOfWork, Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), _cache, _clock);
_unitOfWork, Substitute.For<ISupportAlertService>(), Substitute.For<INotificationDispatcher>(), _cache, _clock, _searchIndex);
var result = await handler.Handle(new ScanExpiringCredentialsCommand(), CancellationToken.None);
@@ -1,5 +1,6 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Features.Verification.Commands.RunBankAccountVerification;
using Baya.Application.Features.Verification.Commands.RunIdentityKyc;
@@ -24,6 +25,7 @@ public class RunStepHandlersTests
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
private readonly INurseBankAccountRepository _accounts = Substitute.For<INurseBankAccountRepository>();
private readonly IDateTimeProvider _clock = Substitute.For<IDateTimeProvider>();
private readonly ISearchIndexMaintainer _searchIndex = Substitute.For<ISearchIndexMaintainer>();
public RunStepHandlersTests()
{
@@ -54,7 +56,7 @@ public class RunStepHandlersTests
var identityKyc = Substitute.For<IIdentityKycProvider>();
identityKyc.VerifyAsync("0012345678", null, Arg.Any<CancellationToken>())
.Returns(new IdentityKycResult(true, "Verified Nurse", "ref", "{}", null));
var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock);
var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock, _searchIndex);
var result = await handler.Handle(new RunIdentityKycCommand("0012345678", null), CancellationToken.None);
@@ -75,7 +77,7 @@ public class RunStepHandlersTests
var identityKyc = Substitute.For<IIdentityKycProvider>();
identityKyc.VerifyAsync("0000000000", null, Arg.Any<CancellationToken>())
.Returns(new IdentityKycResult(false, null, "ref", "{}", "could not verify"));
var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock);
var handler = new RunIdentityKycCommandHandler(_currentUser, _unitOfWork, identityKyc, _clock, _searchIndex);
var result = await handler.Handle(new RunIdentityKycCommand("0000000000", null), CancellationToken.None);
@@ -96,7 +98,7 @@ public class RunStepHandlersTests
shahkar.MatchAsync("09120000000", "0012345678", Arg.Any<CancellationToken>())
.Returns(new ShahkarMatchResult(false, true, "ref", "{}", "shared sim"));
var alerts = Substitute.For<ISupportAlertService>();
var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock);
var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock, _searchIndex);
var result = await handler.Handle(new RunShahkarMatchCommand(), CancellationToken.None);
@@ -115,7 +117,7 @@ public class RunStepHandlersTests
_verif.GetTrackedUserAsync(7, Arg.Any<CancellationToken>()).Returns(new User { PhoneNumber = "09121112233" });
var shahkar = Substitute.For<IShahkarVerifier>();
var alerts = Substitute.For<ISupportAlertService>();
var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock);
var handler = new RunShahkarMatchCommandHandler(_currentUser, _unitOfWork, shahkar, alerts, _clock, _searchIndex);
var result = await handler.Handle(new RunShahkarMatchCommand(), CancellationToken.None);
@@ -135,7 +137,7 @@ public class RunStepHandlersTests
var verifier = Substitute.For<IBankAccountOwnershipVerifier>();
verifier.VerifyOwnershipAsync(account.Iban, "0012345678", Arg.Any<CancellationToken>())
.Returns(new OwnershipInquiryResult(false, "Someone Else", "ref"));
var handler = new RunBankAccountVerificationCommandHandler(_currentUser, _unitOfWork, verifier, _clock);
var handler = new RunBankAccountVerificationCommandHandler(_currentUser, _unitOfWork, verifier, _clock, _searchIndex);
var result = await handler.Handle(new RunBankAccountVerificationCommand(), CancellationToken.None);