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,20 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Search;
namespace Baya.Application.Contracts.Search;
/// <summary>
/// The search-service seam. Discovery callers depend <b>only</b> on this interface — never on raw SQL or an
/// Elasticsearch client — so the MVP→Elastic swap is a registration/config change with no caller edits.
/// <para>
/// The MVP implementation (<c>SqlNurseSearch</c>) is the <b>real, production backend</b>, not a mock: it
/// reads the maintained <c>nurse_search_index</c> where <c>is_searchable = 1</c>, applies the
/// category/city/district/gender/price filters and the rating sort, and paginates. A later
/// <c>ElasticNurseSearch</c> is a config-selected drop-in; the SQL index stays the projection/fallback.
/// </para>
/// </summary>
public interface INurseSearch
{
Task<PagedResult<NurseSearchResultDto>> SearchAsync(NurseSearchCriteria criteria, CancellationToken cancellationToken);
}
@@ -0,0 +1,55 @@
#nullable enable
using Baya.Application.Models.Search;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Verification;
namespace Baya.Application.Contracts.Search;
/// <summary>
/// The index-maintenance seam (the "<c>ISearchIndexWriter</c>" shape). It keeps <c>nurse_search_index</c>
/// consistent with its source tables. Each method is invoked by the handler that owns the source write and
/// <b>stages</b> its index changes on the same unit of work — the handler's single <c>CommitAsync</c> then
/// persists the source change and its projection atomically. A source write that rolls back rolls back its
/// index change too; the projection can never diverge on a successful commit.
/// <para>
/// The projection is written <b>only by the code path that owns the source row</b>: a variant write
/// reindexes that variant, a profile/verification write reindexes that nurse, a service-area write fans
/// out / removes that nurse's rows for the area. The inline SQL path applies these today; the same change
/// events can later be routed to an outbox/queue for an Elasticsearch feeder without touching callers.
/// </para>
/// <para>
/// The maintainer intentionally reads the facts a given trigger does <i>not</i> change from the database and
/// takes the facts it <i>does</i> change as tracked arguments — so it never reads a stale, pre-commit value.
/// </para>
/// </summary>
public interface ISearchIndexMaintainer
{
/// <summary>Variant create / edit / activate / deactivate. Reprojects <b>this variant across all the
/// nurse's active service areas</b> (upsert one row per area) and reconciles away rows for areas no
/// longer covered. A deactivated variant keeps its rows with <c>is_searchable = 0</c>. Pass the tracked
/// variant entity — a freshly-created one (id still 0) is inserted in the same graph.</summary>
Task ReindexVariantAsync(NurseServiceVariant variant, CancellationToken cancellationToken);
/// <summary>A change to the nurse's bookability or copied aggregates: the <c>is_verified</c> flip,
/// suspend / un-suspend, the <c>is_accepting_bookings</c> toggle, or a rating recompute. Re-derives
/// <b>every row for the nurse</b> (each variant × each active area), recomputing <c>is_searchable</c> and
/// refreshing the copied gender/rating fields. Pass the tracked profile (its just-changed flags/aggregates
/// are read from it); pass <paramref name="verificationStatus"/> when this same unit of work also changed
/// verification state, else the committed status is read.</summary>
Task ReindexNurseAsync(NurseProfile profile, VerificationStatus? verificationStatus, CancellationToken cancellationToken);
/// <summary>Service-area <b>add</b>. Inserts one row per non-deleted variant for the newly-covered area
/// (the area itself may not be committed yet — the city/district are taken from the write, not read
/// back), with <c>is_searchable</c> per the visibility predicate.</summary>
Task FanOutServiceAreaAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken);
/// <summary>Service-area <b>remove</b>. Soft-deletes exactly the nurse's rows for that area across all
/// variants — never collapses other areas.</summary>
Task RemoveServiceAreaRowsAsync(long nurseId, long cityId, long? districtId, CancellationToken cancellationToken);
/// <summary>Idempotent full rebuild from source (<c>nurse_profiles × variants × active areas</c>) — the
/// convergence/reconciliation path. Owns its own batched commits; the incrementally-maintained index and
/// a fresh rebuild must produce the same live rows.</summary>
Task<SearchIndexRebuildResult> RebuildAsync(CancellationToken cancellationToken);
}
@@ -1,13 +1,14 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
internal sealed class SetNurseAcceptingBookingsCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
internal sealed class SetNurseAcceptingBookingsCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork, ISearchIndexMaintainer searchIndex)
: IRequestHandler<SetNurseAcceptingBookingsCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(SetNurseAcceptingBookingsCommand request, CancellationToken cancellationToken)
@@ -23,6 +24,10 @@ internal sealed class SetNurseAcceptingBookingsCommandHandler(ICurrentUser curre
return OperationResult<bool>.NotFoundResult("No nurse profile exists yet. Create your profile first.");
profile.SetAcceptingBookings(request.Accepting);
// Pausing/resuming bookings flips every one of the nurse's index rows' is_searchable in the same
// transaction (verification status is unchanged here, so it is read from the committed record).
await searchIndex.ReindexNurseAsync(profile, null, cancellationToken);
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
@@ -0,0 +1,38 @@
#nullable enable
using Baya.Application.Contracts.Audit;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Search;
using Baya.Application.Models.Common;
using Baya.Application.Models.Search;
using Mediator;
namespace Baya.Application.Features.Search.Commands.RebuildSearchIndex;
internal sealed class RebuildSearchIndexCommandHandler(
ICurrentUser currentUser,
ISearchIndexMaintainer maintainer,
IAuditLogger auditLogger)
: IRequestHandler<RebuildSearchIndexCommand, OperationResult<SearchIndexRebuildResult>>
{
public async ValueTask<OperationResult<SearchIndexRebuildResult>> Handle(RebuildSearchIndexCommand request, CancellationToken cancellationToken)
{
if (currentUser.UserId is not { } adminId)
return OperationResult<SearchIndexRebuildResult>.UnauthorizedResult("Not authenticated.");
var result = await maintainer.RebuildAsync(cancellationToken);
await auditLogger.WriteAsync(
"nurse_search_index",
"rebuild",
"rebuild",
new Dictionary<string, object?>
{
["admin_id"] = adminId,
["nurses_processed"] = result.NursesProcessed,
["rows_written"] = result.RowsWritten
},
cancellationToken);
return OperationResult<SearchIndexRebuildResult>.SuccessResult(result);
}
}
@@ -0,0 +1,9 @@
using Baya.Application.Models.Common;
using Baya.Application.Models.Search;
using Mediator;
namespace Baya.Application.Features.Search.Commands.RebuildSearchIndex;
/// <summary>Admin/nightly full rebuild of <c>nurse_search_index</c> from source — the convergence path.
/// Idempotent: the rebuilt index must match the incrementally-maintained one.</summary>
public record RebuildSearchIndexCommand : IRequest<OperationResult<SearchIndexRebuildResult>>;
@@ -0,0 +1,31 @@
#nullable enable
using Baya.Application.Common;
using Baya.Application.Contracts.Search;
using Baya.Application.Models.Common;
using Baya.Application.Models.Search;
using Mediator;
namespace Baya.Application.Features.Search.Queries.SearchNurses;
internal sealed class SearchNursesQueryHandler(INurseSearch search)
: IRequestHandler<SearchNursesQuery, OperationResult<PagedResult<NurseSearchResultDto>>>
{
public async ValueTask<OperationResult<PagedResult<NurseSearchResultDto>>> Handle(SearchNursesQuery request, CancellationToken cancellationToken)
{
var (page, pageSize) = Pagination.Normalize(request.Page, request.PageSize);
var criteria = new NurseSearchCriteria(
request.ServiceCategoryId,
request.CityId,
request.DistrictId,
string.IsNullOrWhiteSpace(request.NurseGender) ? null : request.NurseGender,
request.MinPrice,
request.MaxPrice,
string.IsNullOrWhiteSpace(request.PriceUnit) ? null : request.PriceUnit,
page,
pageSize);
var result = await search.SearchAsync(criteria, cancellationToken);
return OperationResult<PagedResult<NurseSearchResultDto>>.SuccessResult(result);
}
}
@@ -0,0 +1,34 @@
using Baya.Domain.Entities.Catalog;
using FluentValidation;
namespace Baya.Application.Features.Search.Queries.SearchNurses;
public sealed class SearchNursesQueryValidator : AbstractValidator<SearchNursesQuery>
{
public SearchNursesQueryValidator()
{
RuleFor(x => x.ServiceCategoryId).GreaterThan(0);
RuleFor(x => x.CityId).GreaterThan(0);
RuleFor(x => x.DistrictId).GreaterThan(0).When(x => x.DistrictId.HasValue);
// Same-gender matching is a first-class facet — when present it must be an exact known value.
RuleFor(x => x.NurseGender)
.Must(g => g is "male" or "female")
.When(x => !string.IsNullOrWhiteSpace(x.NurseGender))
.WithMessage("nurse_gender must be 'male' or 'female'.");
RuleFor(x => x.MinPrice).GreaterThanOrEqualTo(0).When(x => x.MinPrice.HasValue);
RuleFor(x => x.MaxPrice).GreaterThanOrEqualTo(0).When(x => x.MaxPrice.HasValue);
RuleFor(x => x)
.Must(x => x.MinPrice <= x.MaxPrice)
.When(x => x.MinPrice.HasValue && x.MaxPrice.HasValue)
.WithMessage("min_price must be less than or equal to max_price.");
RuleFor(x => x.PriceUnit)
.Must(PriceUnits.IsValid)
.When(x => !string.IsNullOrWhiteSpace(x.PriceUnit))
.WithMessage("price_unit must be one of: per_hour, per_session, per_half_day, per_day, per_24h.");
RuleFor(x => x.PageSize).LessThanOrEqualTo(Baya.Application.Common.Pagination.MaxPageSize);
}
}
@@ -0,0 +1,24 @@
#nullable enable
using Baya.Application.Models.Common;
using Baya.Application.Models.Search;
using Mediator;
namespace Baya.Application.Features.Search.Queries.SearchNurses;
/// <summary>
/// The single family-facing discovery query. Category + city are required; district is optional (NULL =
/// whole-city geography is resolved by the backend). Same-gender matching is a first-class facet; price is
/// an IRR <c>long</c> range. Only <c>is_searchable = 1</c> rows are ever returned. Delegates to the
/// <see cref="Baya.Application.Contracts.Search.INurseSearch"/> seam so an Elasticsearch backend can drop in
/// later by configuration alone.
/// </summary>
public record SearchNursesQuery(
long ServiceCategoryId,
long CityId,
long? DistrictId = null,
string? NurseGender = null,
long? MinPrice = null,
long? MaxPrice = null,
string? PriceUnit = null,
int Page = 1,
int PageSize = 50) : IRequest<OperationResult<PagedResult<NurseSearchResultDto>>>;
@@ -1,6 +1,7 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Models.Common;
using Baya.Application.Models.Geography;
using Baya.Domain.Entities.Geography;
@@ -9,7 +10,7 @@ using Mediator;
namespace Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
internal sealed class AddNurseServiceAreaCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
internal sealed class AddNurseServiceAreaCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork, ISearchIndexMaintainer searchIndex)
: IRequestHandler<AddNurseServiceAreaCommand, OperationResult<NurseServiceAreaDto>>
{
public async ValueTask<OperationResult<NurseServiceAreaDto>> Handle(AddNurseServiceAreaCommand request, CancellationToken cancellationToken)
@@ -53,9 +54,11 @@ internal sealed class AddNurseServiceAreaCommandHandler(ICurrentUser currentUser
IsActive = true
};
// DEFERRED (b7): this is the write that later fans out nurse_search_index rows. Keep it the single
// trigger point — do not build the index here.
await unitOfWork.NurseServiceAreaRepository.AddAsync(area, cancellationToken);
// Fan the newly-covered area out into nurse_search_index: one row per active variant, in the same
// transaction. The area itself may still be uncommitted, so its city/district come from the request.
await searchIndex.FanOutServiceAreaAsync(nid, request.CityId, request.DistrictId, cancellationToken);
await unitOfWork.CommitAsync();
return OperationResult<NurseServiceAreaDto>.SuccessResult(new NurseServiceAreaDto(
@@ -1,6 +1,7 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.User;
using Mediator;
@@ -10,7 +11,8 @@ namespace Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea
internal sealed class RemoveNurseServiceAreaCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IDateTimeProvider clock)
IDateTimeProvider clock,
ISearchIndexMaintainer searchIndex)
: IRequestHandler<RemoveNurseServiceAreaCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(RemoveNurseServiceAreaCommand request, CancellationToken cancellationToken)
@@ -30,8 +32,11 @@ internal sealed class RemoveNurseServiceAreaCommandHandler(
if (area is null)
return OperationResult<bool>.NotFoundResult("Service area not found.");
// DEFERRED (b7): triggers nurse_search_index row removal — keep this the single trigger point.
area.DeletedAt = clock.UtcNow;
// Drop exactly this nurse×area's index rows across all variants, in the same transaction. Removing an
// area must never collapse or touch the nurse's other areas.
await searchIndex.RemoveServiceAreaRowsAsync(area.NurseId, area.CityId, area.DistrictId, cancellationToken);
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
@@ -3,6 +3,7 @@ using System.Globalization;
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Models.Catalog;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Catalog;
@@ -11,7 +12,7 @@ using Mediator;
namespace Baya.Application.Features.Variants.Commands.CreateVariant;
internal sealed class CreateVariantCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
internal sealed class CreateVariantCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork, ISearchIndexMaintainer searchIndex)
: IRequestHandler<CreateVariantCommand, OperationResult<VariantDto>>
{
public async ValueTask<OperationResult<VariantDto>> Handle(CreateVariantCommand request, CancellationToken cancellationToken)
@@ -91,9 +92,11 @@ internal sealed class CreateVariantCommandHandler(ICurrentUser currentUser, IUni
.ToList()
};
// DEFERRED (b7): this is the write that later fans a variant out into nurse_search_index. Keep it the
// single trigger point — do not build the index here.
await unitOfWork.NurseServiceVariantRepository.AddAsync(variant, cancellationToken);
// Fan this variant out into nurse_search_index across the nurse's service areas, in the same unit of
// work — the new variant's generated id is assigned to its index rows on the single commit below.
await searchIndex.ReindexVariantAsync(variant, cancellationToken);
await unitOfWork.CommitAsync();
return OperationResult<VariantDto>.SuccessResult(new VariantDto(
@@ -1,13 +1,14 @@
#nullable enable
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.User;
using Mediator;
namespace Baya.Application.Features.Variants.Commands.SetVariantActive;
internal sealed class SetVariantActiveCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
internal sealed class SetVariantActiveCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork, ISearchIndexMaintainer searchIndex)
: IRequestHandler<SetVariantActiveCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(SetVariantActiveCommand request, CancellationToken cancellationToken)
@@ -28,7 +29,9 @@ internal sealed class SetVariantActiveCommandHandler(ICurrentUser currentUser, I
variant.IsActive = request.IsActive;
// DEFERRED (b7): toggling active is the trigger point for the search-index add/remove.
// Deactivate flips this variant's index rows to is_searchable=0 (kept, not deleted); activate makes
// them searchable again — recomputed and staged in the same transaction as the toggle.
await searchIndex.ReindexVariantAsync(variant, cancellationToken);
await unitOfWork.CommitAsync();
return OperationResult<bool>.SuccessResult(true);
@@ -2,6 +2,7 @@
using System.Globalization;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Models.Catalog;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.User;
@@ -9,7 +10,7 @@ using Mediator;
namespace Baya.Application.Features.Variants.Commands.UpdateVariant;
internal sealed class UpdateVariantCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork)
internal sealed class UpdateVariantCommandHandler(ICurrentUser currentUser, IUnitOfWork unitOfWork, ISearchIndexMaintainer searchIndex)
: IRequestHandler<UpdateVariantCommand, OperationResult<VariantDto>>
{
public async ValueTask<OperationResult<VariantDto>> Handle(UpdateVariantCommand request, CancellationToken cancellationToken)
@@ -35,6 +36,8 @@ internal sealed class UpdateVariantCommandHandler(ICurrentUser currentUser, IUni
if (!string.IsNullOrWhiteSpace(request.DisplayName))
variant.DisplayName = request.DisplayName.Trim();
// Price/category changes must reach the search projection in the same transaction.
await searchIndex.ReindexVariantAsync(variant, cancellationToken);
await unitOfWork.CommitAsync();
// Re-project with resolved labels for the response (the option-set is unchanged).
@@ -3,6 +3,7 @@ using Baya.Application.Common;
using Baya.Application.Contracts.Audit;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.Verification;
@@ -16,7 +17,8 @@ internal sealed class AdminReviewStepCommandHandler(
ICredentialVerifier credentialVerifier,
IAuditLogger auditLogger,
ICacheService cache,
IDateTimeProvider dateTimeProvider)
IDateTimeProvider dateTimeProvider,
ISearchIndexMaintainer searchIndex)
: IRequestHandler<AdminReviewStepCommand, OperationResult<ReviewStepResult>>
{
public async ValueTask<OperationResult<ReviewStepResult>> Handle(AdminReviewStepCommand request, CancellationToken cancellationToken)
@@ -69,6 +71,10 @@ internal sealed class AdminReviewStepCommandHandler(
VerificationAggregator.Finalize(verification, profile, now);
// Any is_verified flip must reach the search projection in the same transaction — a newly-verified
// nurse's rows become searchable; a rejection reverses it.
await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken);
// The step decision, the recorded credential, and any is_verified flip land in one transaction.
await unitOfWork.CommitAsync();
@@ -3,6 +3,7 @@ using System.Text.Json;
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.User;
@@ -15,7 +16,8 @@ internal sealed class RunBankAccountVerificationCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IBankAccountOwnershipVerifier ownershipVerifier,
IDateTimeProvider dateTimeProvider)
IDateTimeProvider dateTimeProvider,
ISearchIndexMaintainer searchIndex)
: IRequestHandler<RunBankAccountVerificationCommand, OperationResult<RunStepResult>>
{
public async ValueTask<OperationResult<RunStepResult>> Handle(RunBankAccountVerificationCommand request, CancellationToken cancellationToken)
@@ -77,6 +79,9 @@ internal sealed class RunBankAccountVerificationCommandHandler(
return OperationResult<RunStepResult>.NotFoundResult("Nurse profile not found.");
VerificationAggregator.Finalize(verification, profile, now);
// An automated pass can flip is_verified — keep the search projection in step within this commit.
await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken);
await unitOfWork.CommitAsync();
return OperationResult<RunStepResult>.SuccessResult(new RunStepResult(step.Id, step.Status.ToCode(), step.FailureReason));
@@ -2,6 +2,7 @@
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
using Baya.Domain.Entities.User;
@@ -14,7 +15,8 @@ internal sealed class RunIdentityKycCommandHandler(
ICurrentUser currentUser,
IUnitOfWork unitOfWork,
IIdentityKycProvider identityKyc,
IDateTimeProvider dateTimeProvider)
IDateTimeProvider dateTimeProvider,
ISearchIndexMaintainer searchIndex)
: IRequestHandler<RunIdentityKycCommand, OperationResult<RunStepResult>>
{
public async ValueTask<OperationResult<RunStepResult>> Handle(RunIdentityKycCommand request, CancellationToken cancellationToken)
@@ -68,6 +70,9 @@ internal sealed class RunIdentityKycCommandHandler(
return OperationResult<RunStepResult>.NotFoundResult("Nurse profile not found.");
VerificationAggregator.Finalize(verification, profile, now);
// An automated pass can flip is_verified — keep the search projection in step within this commit.
await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken);
await unitOfWork.CommitAsync();
return OperationResult<RunStepResult>.SuccessResult(new RunStepResult(step.Id, step.Status.ToCode(), step.FailureReason));
@@ -2,6 +2,7 @@
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
@@ -17,7 +18,8 @@ internal sealed class RunShahkarMatchCommandHandler(
IUnitOfWork unitOfWork,
IShahkarVerifier shahkarVerifier,
ISupportAlertService supportAlerts,
IDateTimeProvider dateTimeProvider)
IDateTimeProvider dateTimeProvider,
ISearchIndexMaintainer searchIndex)
: IRequestHandler<RunShahkarMatchCommand, OperationResult<RunStepResult>>
{
public async ValueTask<OperationResult<RunStepResult>> Handle(RunShahkarMatchCommand request, CancellationToken cancellationToken)
@@ -71,6 +73,9 @@ internal sealed class RunShahkarMatchCommandHandler(
return OperationResult<RunStepResult>.NotFoundResult("Nurse profile not found.");
VerificationAggregator.Finalize(verification, profile, now);
// An automated pass can flip is_verified — keep the search projection in step within this commit.
await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken);
await unitOfWork.CommitAsync();
// Shared-SIM is a distinct, non-accusatory handled state — flag it for staff follow-up. Raised
@@ -2,6 +2,7 @@
using Baya.Application.Common;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Application.Models.Common;
using Baya.Application.Models.Verification;
@@ -16,7 +17,8 @@ internal sealed class ScanExpiringCredentialsCommandHandler(
ISupportAlertService supportAlerts,
INotificationDispatcher notifications,
ICacheService cache,
IDateTimeProvider dateTimeProvider)
IDateTimeProvider dateTimeProvider,
ISearchIndexMaintainer searchIndex)
: IRequestHandler<ScanExpiringCredentialsCommand, OperationResult<ScanExpiringResult>>
{
public async ValueTask<OperationResult<ScanExpiringResult>> Handle(ScanExpiringCredentialsCommand request, CancellationToken cancellationToken)
@@ -62,6 +64,9 @@ internal sealed class ScanExpiringCredentialsCommandHandler(
// A lapsed required credential must never silently keep a nurse verified — re-gate atomically.
VerificationAggregator.Finalize(verification, profile, now);
// The un-verify must reach search in the same commit so an expired nurse stops surfacing.
await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken);
await unitOfWork.CommitAsync();
revertedNurses++;
@@ -3,6 +3,7 @@ using Baya.Application.Common;
using Baya.Application.Contracts.Audit;
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Verification;
using Mediator;
@@ -14,7 +15,8 @@ internal sealed class AdminSuspendVerificationCommandHandler(
IUnitOfWork unitOfWork,
IAuditLogger auditLogger,
ICacheService cache,
IDateTimeProvider dateTimeProvider)
IDateTimeProvider dateTimeProvider,
ISearchIndexMaintainer searchIndex)
: IRequestHandler<AdminSuspendVerificationCommand, OperationResult<bool>>
{
public async ValueTask<OperationResult<bool>> Handle(AdminSuspendVerificationCommand request, CancellationToken cancellationToken)
@@ -41,6 +43,9 @@ internal sealed class AdminSuspendVerificationCommandHandler(
// Suspended status → the aggregator reverses is_verified in the same transaction.
VerificationAggregator.Finalize(verification, profile, now);
// A suspended nurse must vanish from search — flip all their rows to is_searchable=0 in this commit.
await searchIndex.ReindexNurseAsync(profile, verification.Status, cancellationToken);
await unitOfWork.CommitAsync();
await auditLogger.WriteAsync(
@@ -0,0 +1,18 @@
#nullable enable
namespace Baya.Application.Models.Search;
/// <summary>
/// Normalized inputs the <see cref="Baya.Application.Contracts.Search.INurseSearch"/> backend queries.
/// Category and city are required; district is optional (NULL-district = whole-city geography is resolved
/// inside the backend). Prices are IRR Rials as <c>long</c> — no float.
/// </summary>
public sealed record NurseSearchCriteria(
long ServiceCategoryId,
long CityId,
long? DistrictId,
string? NurseGender,
long? MinPrice,
long? MaxPrice,
string? PriceUnit,
int Page,
int PageSize);
@@ -0,0 +1,19 @@
namespace Baya.Application.Models.Search;
/// <summary>
/// One family-facing search hit — a bookable variant matched in a covered area. <c>Price</c> is IRR Rials
/// as a digit string (BIGINT on the wire, never a float); <c>DistrictId</c> == null means the nurse covers
/// the whole city.
/// </summary>
public record NurseSearchResultDto(
long VariantId,
long NurseId,
long ServiceCategoryId,
string Price,
string PriceUnit,
string NurseGender,
decimal AverageRating,
int TotalReviews,
int TotalCompletedBookings,
long CityId,
long? DistrictId);
@@ -0,0 +1,5 @@
namespace Baya.Application.Models.Search;
/// <summary>Outcome of a full <c>nurse_search_index</c> rebuild: how many nurse profiles were scanned and
/// how many live index rows the rebuild produced.</summary>
public record SearchIndexRebuildResult(int NursesProcessed, int RowsWritten);
@@ -0,0 +1,65 @@
using Baya.Domain.Common;
using Baya.Domain.Entities.Catalog;
namespace Baya.Domain.Entities.Search;
/// <summary>
/// The denormalized, maintained-on-write read model behind nurse discovery (b7): <b>one flat row per
/// (bookable variant × covered service area)</b>. It flattens facts that otherwise live across four
/// domains — the variant's category/price (catalog), the covered city/district (geography), the nurse's
/// gender + rating aggregates (identity), and the verification-derived bookability — so a family search is
/// a single indexed, paginated scan instead of a 4+ table join with a rating sort.
/// <para>
/// This is a <b>read-only projection</b>: it is written <i>only</i> by the search-index maintainer, which
/// re-derives every field from the source tables. Never let a search read mutate it, and never treat it as
/// the source of truth for anything.
/// </para>
/// <para>
/// <see cref="IsSearchable"/> is the single visibility gate — a row is returned to families only when it is
/// <c>true</c>, which holds <b>only</b> when the nurse is verified, not suspended, accepting bookings, and
/// the variant is active (see the maintainer). <see cref="DistrictId"/> == <c>null</c> is a meaningful
/// "whole city" coverage value, not missing data. <see cref="Price"/> is IRR Rials as an integer — no float.
/// </para>
/// </summary>
public class NurseSearchIndex : BaseEntity<long>
{
public long VariantId { get; set; }
/// <summary>Reference navigation so a row projected for a freshly-created variant is inserted in the same
/// graph — EF assigns the generated <c>variant_id</c> in one <c>SaveChanges</c>.</summary>
public NurseServiceVariant Variant { get; set; }
public long NurseId { get; set; }
public long ServiceCategoryId { get; set; }
/// <summary>IRR Rials, integer — copied from the variant. No float money path, ever.</summary>
public long Price { get; set; }
/// <summary>Closed code set (see <see cref="PriceUnits"/>) — copied from the variant.</summary>
public string PriceUnit { get; set; }
public long CityId { get; set; }
/// <summary>NULL = "whole city" — a deliberate coverage value, not missing data. A city search matches
/// both NULL-district rows and any district row in the city; a district search matches that district's
/// rows plus the NULL-district (whole-city) rows.</summary>
public long? DistrictId { get; set; }
/// <summary>Copied from <c>users.gender</c> via the nurse, for the first-class same-gender filter.</summary>
public string NurseGender { get; set; }
public decimal AverageRating { get; set; }
public int TotalReviews { get; set; }
public int TotalCompletedBookings { get; set; }
/// <summary>The single visibility gate: <c>true</c> only when nurse <c>is_verified=1</c> AND not
/// suspended AND <c>is_accepting_bookings=1</c> AND variant <c>is_active=1</c>. Recomputed on every
/// relevant source write — never trusted as a stale value.</summary>
public bool IsSearchable { get; set; }
/// <summary>Stamped from <see cref="IDateTimeProvider"/> on every upsert.</summary>
public DateTimeOffset UpdatedAt { get; set; }
public DateTimeOffset? DeletedAt { get; set; }
}