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
+35 -4
View File
@@ -81,15 +81,15 @@ projects/assemblies, Clean-Architecture layers, and cross-layer dependencies.
```
src/
├── Core/
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
│ ├── Baya.Domain Entities (User, Role, UserSession, RoleNames…, Identity/ (NurseProfile, CustomerProfile, Patient, NurseBankAccount, CustomerAddress), Geography/ (Province, City, District, NurseServiceArea), Catalog/ (ServiceCategory, ServiceOptionGroup, ServiceOptionValue, NurseServiceVariant, NurseServiceVariantOption, PriceUnits), Verification/ (NurseVerification, VerificationStepType, VerificationStep, VerificationDocument, NurseCredential + VerificationStatus/VerificationStepStatus enums), Search/ (NurseSearchIndex — the denormalized search projection), + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts), BaseEntity, IEntity, ITimeModification, IAuditableEntity, IAuditable (audit-row marker)
│ └── Baya.Application Features/ (Commands & Queries; Identity area = auth + profiles/patients/nurse-bank-accounts; Geography/ServiceAreas/Addresses areas = geo hierarchy + nurse service areas + customer addresses; Catalog/Variants areas = admin catalog skeleton + nurse pricing variants; Verification area = the b6 nurse-verification pipeline (submit/status/uploads/automated runs + admin review/suspend/scan + public trust badge); Search area = the b7 discovery query + admin index-rebuild; + Configuration/Audit/Analytics/Holidays/Notifications/SupportAlerts areas), Contracts/ (incl. Contracts/Common cross-cutting seams incl. IBankAccountOwnershipVerifier + IGeocoder + IVariantSnapshotSerializer + IShahkarVerifier + IIdentityKycProvider + ICredentialVerifier + the platform-signal facade contracts + Contracts/Search (INurseSearch read seam + ISearchIndexMaintainer write seam) + Contracts/Persistence per-domain repositories on IUnitOfWork incl. IVerificationRepository), Models/, pipeline behaviors (Common/ — validators auto-registered from this assembly; VerificationAggregator + IdentityNameMatch helpers)
├── Infrastructure/
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service)
│ ├── Baya.Infrastructure.Persistence ApplicationDbContext (+ encrypted-PII value converters & phone-hash sync), ValueConversion/, Repositories/, Configuration/ (per-area EF config incl. SearchConfig/), Migrations/, Interceptors/ (AuditFieldInterceptor — audit-fields + audit-log rows), Services/ (DB-backed platform-signal facades + notification-retention hosted service + Search/ = SearchIndexMaintainer + SqlNurseSearch)
│ ├── Baya.Infrastructure.Identity Jwt/, Identity/ (Managers, Stores, PermissionManager, Seed, CurrentUser/)
│ ├── Baya.Infrastructure.CrossCutting Serilog wiring + Seams/ (mock impls of the cross-cutting seams incl. LoggingSmsSender + MockBankAccountOwnershipVerifier + MockShahkarVerifier + MockIdentityKycProvider + MockCredentialVerifier) + AddCrossCuttingSeams
│ └── Baya.Infrastructure.Monitoring HealthChecks, OpenTelemetry, prometheus-net
├── API/
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge)), appsettings*.json
│ ├── Baya.Web.Api Program.cs, Controllers/V1/ (Ping + Auth/Me phone-OTP surface + admin PlatformConfig/Holidays/Audit/SupportAlerts + current-user Notifications + public Geo + admin AdminGeo + nurse NurseServiceAreas + customer CustomerAddresses + public Catalog + admin AdminCatalog + nurse NurseVariants + nurse NurseVerification + admin AdminVerificationStepTypes/AdminVerifications + public Nurses (trust badge) + public Search + admin AdminSearch), appsettings*.json
│ ├── Baya.WebFramework BaseController (incl. 401/403 OperationResult mapping), Filters/, Middlewares/, Swagger/, Routing/, ServiceConfiguration/ (rate limiting)
│ └── Plugins/Baya.Web.Plugins.Grpc gRPC services + .proto models (User only)
├── Shared/Baya.SharedKernel Extensions + validation base
@@ -195,6 +195,37 @@ seed in `Persistence/Configuration/CatalogConfig/`; per-domain repos (`ICatalogR
canonical `variant_snapshot_json` and is **consumed by b8** (which owns the `booking_requests` column);
this phase ships and unit-tests it but persists nothing. `nurse_search_index` is **b7's** (not built here).
**Search & matching (backend-phase-7).** A new **`search` schema** holds the single denormalized read model
`NurseSearchIndex` (table `NurseSearchIndices`) — **one flat row per (bookable variant × covered service
area)** (fan-out), copying the variant's category/price/unit, the covered `city_id`/`district_id`
(`district_id = NULL` = whole city), the nurse's `nurse_gender` + rating aggregates, and the single
`is_searchable` visibility gate. It is a **read-only projection**, written only by the maintainer that
re-derives it from source. Features under `Baya.Application/Features/Search/{Queries|Commands}/`; config in
`Persistence/Configuration/SearchConfig/`; the maintainer + SQL search in `Persistence/Services/Search/`.
Two seams live in `Application/Contracts/Search/`, registered by `AddPersistenceServices` (config key
`Search:Backend`, default `sql`):
- **`INurseSearch`** (read) — impl `SqlNurseSearch` reads **only `is_searchable = 1`** rows, applies the
category/city/district/gender/price filters + rating sort + pagination. The real MVP backend; a later
`ElasticNurseSearch` is a config-selected drop-in and callers depend only on the interface.
- **`ISearchIndexMaintainer`** (write, the "ISearchIndexWriter" shape) — `SearchIndexMaintainer` keeps the
index consistent **inline, inside the source write's own unit of work** (single `CommitAsync`), invoked
from the b3/b4/b5/b6 handlers that own each source row: `ReindexVariantAsync` (variant create/edit/toggle),
`ReindexNurseAsync` (verification flip / suspend / accepting-toggle / rating recompute),
`FanOutServiceAreaAsync` + `RemoveServiceAreaRowsAsync` (area add/remove), and `RebuildAsync` (idempotent
full rebuild — the admin `POST admin_search/rebuild_index` job). It shares the request-scoped
`ApplicationDbContext`, so it only *stages* changes; the handler's commit flushes source + projection
atomically. It reads the facts a trigger does **not** change from the DB and takes the facts it **does**
change as tracked arguments, so it never reads a stale pre-commit value. Load-bearing rules:
- **`is_searchable = 1` only when** nurse `is_verified = 1` AND `nurse_verifications.status != 'suspended'`
AND `is_accepting_bookings = 1` AND variant `is_active = 1` — recomputed on every relevant source write.
An unverified/paused/suspended/deactivated nurse or variant must **never** surface.
- **`district_id = NULL` = whole city**, both directions: a city search matches every row in the city; a
district search matches that district's rows **plus** the NULL-district (whole-city) rows. Uniqueness
(`UNIQUE(variant_id, city_id, district_id) WHERE deleted_at IS NULL`) uses the filtered-index pair (the
`nurse_service_areas` trick) so NULL participates on SQL Server; the maintainer resurrects a soft-deleted
row on re-upsert so each (variant × area) has exactly one live row.
- **Incremental maintenance and full rebuild must converge** — the index is fully re-derivable from source.
**Keeping the Project map current.** When a change touches the architecture — adds, removes, or
renames a project/assembly, a Clean-Architecture layer, or a major folder, or changes a cross-layer
dependency — you **must** update this Project map (and the dependency rule above, if affected) in the
@@ -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));
}
@@ -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; }
}
@@ -0,0 +1,57 @@
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.Search;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.SearchConfig;
internal sealed class NurseSearchIndexConfig : IEntityTypeConfiguration<NurseSearchIndex>
{
public void Configure(EntityTypeBuilder<NurseSearchIndex> builder)
{
builder.ToTable("NurseSearchIndex", "search");
// Price is IRR Rials as BIGINT (long → bigint) — copied from the variant. No float money path.
builder.Property(x => x.PriceUnit).HasMaxLength(20).IsRequired();
builder.Property(x => x.NurseGender).HasMaxLength(10);
builder.Property(x => x.AverageRating).HasPrecision(3, 2);
// The hot search path: filter on (is_searchable, category, city, district) then rating-sort + page.
// INCLUDE the columns the projection reads so the filtered, sorted page is served straight from the
// index with no key lookups (SQL Server; the INCLUDE annotation is ignored by other providers).
builder.HasIndex(x => new { x.IsSearchable, x.ServiceCategoryId, x.CityId, x.DistrictId })
.IncludeProperties(x => new { x.Price, x.NurseGender, x.AverageRating, x.TotalReviews, x.NurseId, x.VariantId })
.HasDatabaseName("IX_NurseSearchIndex_Search");
// Exactly one live row per (variant × area) — the upsert target and anti-duplication backstop.
// SQL Server treats NULLs as distinct, so a plain UNIQUE(variant, city, district) would wrongly allow
// two "whole city" (NULL district) rows. Split into a filtered pair exactly like nurse_service_areas:
// one enforces at most one whole-city row, the other enforces uniqueness of city+district rows. Both
// exclude soft-deleted rows so a removed-then-recovered area re-inserts cleanly.
builder.HasIndex(x => new { x.VariantId, x.CityId })
.IsUnique()
.HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL")
.HasDatabaseName("UX_NurseSearchIndex_Variant_City_WholeCity");
builder.HasIndex(x => new { x.VariantId, x.CityId, x.DistrictId })
.IsUnique()
.HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL")
.HasDatabaseName("UX_NurseSearchIndex_Variant_City_District");
// A nurse-scoped rebuild / suspend / remove touches every row for one nurse — keep it cheap.
builder.HasIndex(x => x.NurseId);
builder.HasOne(x => x.Variant)
.WithMany()
.HasForeignKey(x => x.VariantId)
.IsRequired();
builder.HasOne<NurseProfile>()
.WithMany()
.HasForeignKey(x => x.NurseId)
.IsRequired();
builder.HasQueryFilter(x => x.DeletedAt == null);
}
}
@@ -0,0 +1,100 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class NurseSearchIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "search");
migrationBuilder.CreateTable(
name: "NurseSearchIndices",
schema: "search",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
VariantId = table.Column<long>(type: "bigint", nullable: false),
NurseId = table.Column<long>(type: "bigint", nullable: false),
ServiceCategoryId = table.Column<long>(type: "bigint", nullable: false),
Price = table.Column<long>(type: "bigint", nullable: false),
PriceUnit = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
CityId = table.Column<long>(type: "bigint", nullable: false),
DistrictId = table.Column<long>(type: "bigint", nullable: true),
NurseGender = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
AverageRating = table.Column<decimal>(type: "decimal(3,2)", precision: 3, scale: 2, nullable: false),
TotalReviews = table.Column<int>(type: "int", nullable: false),
TotalCompletedBookings = table.Column<int>(type: "int", nullable: false),
IsSearchable = table.Column<bool>(type: "bit", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NurseSearchIndices", x => x.Id);
table.ForeignKey(
name: "FK_NurseSearchIndices_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseSearchIndices_NurseServiceVariants_VariantId",
column: x => x.VariantId,
principalSchema: "catalog",
principalTable: "NurseServiceVariants",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_NurseSearchIndex_Search",
schema: "search",
table: "NurseSearchIndices",
columns: new[] { "IsSearchable", "ServiceCategoryId", "CityId", "DistrictId" })
.Annotation("SqlServer:Include", new[] { "Price", "NurseGender", "AverageRating", "TotalReviews", "NurseId", "VariantId" });
migrationBuilder.CreateIndex(
name: "IX_NurseSearchIndices_NurseId",
schema: "search",
table: "NurseSearchIndices",
column: "NurseId");
migrationBuilder.CreateIndex(
name: "UX_NurseSearchIndex_Variant_City_District",
schema: "search",
table: "NurseSearchIndices",
columns: new[] { "VariantId", "CityId", "DistrictId" },
unique: true,
filter: "[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL");
migrationBuilder.CreateIndex(
name: "UX_NurseSearchIndex_Variant_City_WholeCity",
schema: "search",
table: "NurseSearchIndices",
columns: new[] { "VariantId", "CityId" },
unique: true,
filter: "[DistrictId] IS NULL AND [DeletedAt] IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NurseSearchIndices",
schema: "search");
}
}
}
@@ -2138,6 +2138,94 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("Notifications", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("AverageRating")
.HasPrecision(3, 2)
.HasColumnType("decimal(3,2)");
b.Property<long>("CityId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<long?>("DistrictId")
.HasColumnType("bigint");
b.Property<bool>("IsSearchable")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("NurseGender")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<long>("Price")
.HasColumnType("bigint");
b.Property<string>("PriceUnit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<long>("ServiceCategoryId")
.HasColumnType("bigint");
b.Property<int>("TotalCompletedBookings")
.HasColumnType("int");
b.Property<int>("TotalReviews")
.HasColumnType("int");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<long>("VariantId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("NurseId");
b.HasIndex("VariantId", "CityId")
.IsUnique()
.HasDatabaseName("UX_NurseSearchIndex_Variant_City_WholeCity")
.HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL");
b.HasIndex("VariantId", "CityId", "DistrictId")
.IsUnique()
.HasDatabaseName("UX_NurseSearchIndex_Variant_City_District")
.HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL");
b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId")
.HasDatabaseName("IX_NurseSearchIndex_Search");
SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId"), new[] { "Price", "NurseGender", "AverageRating", "TotalReviews", "NurseId", "VariantId" });
b.ToTable("NurseSearchIndices", "search");
});
modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b =>
{
b.Property<long>("Id")
@@ -3181,6 +3269,23 @@ namespace Baya.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
.WithMany()
.HasForeignKey("NurseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant")
.WithMany()
.HasForeignKey("VariantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Variant");
});
modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
@@ -5,6 +5,7 @@ using Baya.Application.Contracts.Configuration;
using Baya.Application.Contracts.Holidays;
using Baya.Application.Contracts.Notifications;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Contracts.SupportAlerts;
using Baya.Infrastructure.Persistence.Interceptors;
using Baya.Infrastructure.Persistence.Repositories.Common;
@@ -13,6 +14,7 @@ using Baya.Infrastructure.Persistence.Services.Audit;
using Baya.Infrastructure.Persistence.Services.Configuration;
using Baya.Infrastructure.Persistence.Services.Holidays;
using Baya.Infrastructure.Persistence.Services.Notifications;
using Baya.Infrastructure.Persistence.Services.Search;
using Baya.Infrastructure.Persistence.Services.SupportAlerts;
using Microsoft.AspNetCore.Builder;
using Microsoft.EntityFrameworkCore;
@@ -51,6 +53,18 @@ public static class ServiceCollectionExtensions
// Retention job seam (mock = in-process interval runner; real Hangfire/Quartz deferred).
services.AddHostedService<NotificationRetentionHostedService>();
// Search (backend-phase-7). The index maintainer keeps nurse_search_index consistent inline inside
// each source write's unit of work. The INurseSearch backend is config-selected — SQL is the real
// MVP backend; a later ElasticNurseSearch drops in here with no caller change.
services.AddScoped<ISearchIndexMaintainer, SearchIndexMaintainer>();
var searchBackend = configuration["Search:Backend"];
if (string.IsNullOrWhiteSpace(searchBackend) || searchBackend.Equals("sql", StringComparison.OrdinalIgnoreCase))
services.AddScoped<INurseSearch, SqlNurseSearch>();
else
throw new NotSupportedException(
$"Search backend '{searchBackend}' is not available — only 'sql' is implemented (Elasticsearch is deferred).");
return services;
}
@@ -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);
}
@@ -0,0 +1,53 @@
using System.Net;
namespace Baya.Test.Api;
public class SearchApiTests(BayaApiFactory factory) : IClassFixture<BayaApiFactory>
{
private const long TehranCityId = 101;
[Fact]
public async Task Search_Public_ReturnsPagedEnvelope()
{
var client = factory.CreateClient();
// Public (no auth). Nothing seeded matches, so the search returns an empty, well-formed page.
var response = await client.GetAsync($"/api/v1/search/nurses?service_category_id=1&city_id={TehranCityId}");
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var data = await AuthTestClient.ReadDataAsync(response);
Assert.Equal(0, data.GetProperty("total").GetInt32());
Assert.Equal(0, data.GetProperty("items").GetArrayLength());
}
[Fact]
public async Task Search_MissingRequiredCategory_Returns400()
{
var client = factory.CreateClient();
var response = await client.GetAsync($"/api/v1/search/nurses?service_category_id=0&city_id={TehranCityId}");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Search_InvalidGender_Returns400()
{
var client = factory.CreateClient();
var response = await client.GetAsync(
$"/api/v1/search/nurses?service_category_id=1&city_id={TehranCityId}&nurse_gender=other");
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task RebuildIndex_Unauthenticated_Returns401()
{
var client = factory.CreateClient();
var response = await client.PostAsync("/api/v1/admin_search/rebuild_index", null);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
}
@@ -1,5 +1,6 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Features.Variants.Commands.CreateVariant;
using Baya.Application.Models.Catalog;
using Baya.Domain.Entities.Catalog;
@@ -16,6 +17,7 @@ public class CreateVariantHandlerTests
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
private readonly ICatalogRepository _catalog = Substitute.For<ICatalogRepository>();
private readonly INurseServiceVariantRepository _variants = Substitute.For<INurseServiceVariantRepository>();
private readonly ISearchIndexMaintainer _searchIndex = Substitute.For<ISearchIndexMaintainer>();
private const long CategoryId = 1;
private const long ShiftGroupId = 10;
@@ -50,7 +52,7 @@ public class CreateVariantHandlerTests
.Returns(false);
}
private CreateVariantCommandHandler Handler() => new(_currentUser, _unitOfWork);
private CreateVariantCommandHandler Handler() => new(_currentUser, _unitOfWork, _searchIndex);
private static CreateVariantCommand Command(IReadOnlyList<VariantOptionSelection> options, string? displayName = null)
=> new(CategoryId, options, "8000000", "per_24h", null, displayName);
@@ -1,5 +1,6 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Features.ServiceAreas.Commands.AddNurseServiceArea;
using Baya.Application.Features.ServiceAreas.Commands.RemoveNurseServiceArea;
using Baya.Domain.Entities.Geography;
@@ -16,6 +17,7 @@ public class NurseServiceAreaHandlersTests
private readonly INurseProfileRepository _nurses = Substitute.For<INurseProfileRepository>();
private readonly IGeoRepository _geo = Substitute.For<IGeoRepository>();
private readonly INurseServiceAreaRepository _areas = Substitute.For<INurseServiceAreaRepository>();
private readonly ISearchIndexMaintainer _searchIndex = Substitute.For<ISearchIndexMaintainer>();
public NurseServiceAreaHandlersTests()
{
@@ -34,7 +36,7 @@ public class NurseServiceAreaHandlersTests
public async Task Add_WholeCity_PersistsWholeCityRow()
{
_areas.DuplicateExistsAsync(42L, 101L, null, Arg.Any<CancellationToken>()).Returns(false);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex);
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None);
@@ -50,7 +52,7 @@ public class NurseServiceAreaHandlersTests
public async Task Add_DuplicateWholeCity_ReturnsConflictNotPersisted()
{
_areas.DuplicateExistsAsync(42L, 101L, null, Arg.Any<CancellationToken>()).Returns(true);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex);
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None);
@@ -66,7 +68,7 @@ public class NurseServiceAreaHandlersTests
_geo.GetDistrictAsync(5L, Arg.Any<CancellationToken>())
.Returns(new District { NameFa = "منطقه ۱", NameEn = "District 1" });
_areas.DuplicateExistsAsync(42L, 101L, 5L, Arg.Any<CancellationToken>()).Returns(true);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex);
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, 5L), CancellationToken.None);
@@ -77,7 +79,7 @@ public class NurseServiceAreaHandlersTests
public async Task Add_DistrictNotInCity_Fails()
{
_geo.IsDistrictInActiveCityAsync(999L, 101L, Arg.Any<CancellationToken>()).Returns(false);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex);
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, 999L), CancellationToken.None);
@@ -89,7 +91,7 @@ public class NurseServiceAreaHandlersTests
public async Task Add_NonNurse_IsForbidden()
{
_currentUser.Roles.Returns([RoleNames.Customer]);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork);
var handler = new AddNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, _searchIndex);
var result = await handler.Handle(new AddNurseServiceAreaCommand(101L, null), CancellationToken.None);
@@ -100,7 +102,7 @@ public class NurseServiceAreaHandlersTests
public async Task Remove_OtherNursesArea_IsNotFound()
{
_areas.GetOwnedAsync(99L, 42L, Arg.Any<CancellationToken>()).ReturnsNull();
var handler = new RemoveNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, Substitute.For<IDateTimeProvider>());
var handler = new RemoveNurseServiceAreaCommandHandler(_currentUser, _unitOfWork, Substitute.For<IDateTimeProvider>(), _searchIndex);
var result = await handler.Handle(new RemoveNurseServiceAreaCommand(99L), CancellationToken.None);
@@ -1,5 +1,6 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Contracts.Search;
using Baya.Application.Features.Identity.Commands.SetNurseAcceptingBookings;
using Baya.Application.Features.Identity.Commands.UpsertNurseProfile;
using Baya.Application.Models.Identity;
@@ -15,6 +16,7 @@ public class NurseProfileHandlersTests
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
private readonly INurseProfileRepository _repo = Substitute.For<INurseProfileRepository>();
private readonly ISearchIndexMaintainer _searchIndex = Substitute.For<ISearchIndexMaintainer>();
public NurseProfileHandlersTests()
{
@@ -58,7 +60,7 @@ public class NurseProfileHandlersTests
public async Task SetAcceptingBookings_NoProfile_IsNotFound()
{
_repo.GetByUserIdAsync(7, Arg.Any<CancellationToken>()).ReturnsNull();
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork);
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork, _searchIndex);
var result = await handler.Handle(new SetNurseAcceptingBookingsCommand(true), CancellationToken.None);
@@ -71,7 +73,7 @@ public class NurseProfileHandlersTests
{
var profile = new NurseProfile { UserId = 7 };
_repo.GetByUserIdAsync(7, Arg.Any<CancellationToken>()).Returns(profile);
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork);
var handler = new SetNurseAcceptingBookingsCommandHandler(_currentUser, _unitOfWork, _searchIndex);
var result = await handler.Handle(new SetNurseAcceptingBookingsCommand(true), CancellationToken.None);
@@ -0,0 +1,170 @@
using Baya.Application.Contracts.Common;
using Baya.Application.Contracts.Search;
using Baya.Domain.Entities.Catalog;
using Baya.Domain.Entities.Geography;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Baya.Domain.Entities.Verification;
using Baya.Infrastructure.Persistence;
using Baya.Infrastructure.Persistence.Services.Search;
using Baya.Tests.Setup.Setups;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
namespace Baya.Test.Foundation.Search;
/// <summary>
/// A self-contained SQLite host for the search-index maintainer + SqlNurseSearch, exercising the real EF
/// model (schema, filtered indexes, query filters) end-to-end. Seeds a shared province/city/two districts
/// and one active service category; <see cref="SeedNurse"/> builds a full nurse (user + profile +
/// verification + variant + area) so a test can drive the maintainer and assert what search returns.
/// </summary>
public sealed class SearchIndexTestHost : IDisposable
{
public static readonly DateTimeOffset Now = new(2026, 7, 5, 12, 0, 0, TimeSpan.Zero);
private readonly SqliteConnection _connection;
public ApplicationDbContext Db { get; }
public ISearchIndexMaintainer Maintainer { get; }
public INurseSearch Search { get; }
public long CategoryId { get; }
public long OtherCategoryId { get; }
public long CityId { get; }
public long District3Id { get; }
public long District7Id { get; }
private int _phoneSeed = 90000000;
public SearchIndexTestHost()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var options = new DbContextOptionsBuilder<ApplicationDbContext>()
.UseSqlite(_connection)
.Options;
Db = new ApplicationDbContext(options, TestFieldEncryptor.Instance);
Db.Database.EnsureCreated();
var clock = Substitute.For<IDateTimeProvider>();
clock.UtcNow.Returns(Now);
Maintainer = new SearchIndexMaintainer(Db, clock);
Search = new SqlNurseSearch(Db);
var province = new Province { NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<Province>().Add(province);
Db.SaveChanges();
var city = new City { ProvinceId = province.Id, NameFa = "تهران", NameEn = "Tehran", SortOrder = 1, IsActive = true };
Db.Set<City>().Add(city);
Db.SaveChanges();
CityId = city.Id;
var d3 = new District { CityId = city.Id, NameFa = "منطقه ۳", NameEn = "District 3", SortOrder = 3, IsActive = true };
var d7 = new District { CityId = city.Id, NameFa = "منطقه ۷", NameEn = "District 7", SortOrder = 7, IsActive = true };
Db.Set<District>().AddRange(d3, d7);
Db.SaveChanges();
District3Id = d3.Id;
District7Id = d7.Id;
var category = new ServiceCategory { NameFa = "سالمند", NameEn = "Elderly", SortOrder = 1, IsActive = true };
var other = new ServiceCategory { NameFa = "نوزاد", NameEn = "Infant", SortOrder = 2, IsActive = true };
Db.Set<ServiceCategory>().AddRange(category, other);
Db.SaveChanges();
CategoryId = category.Id;
OtherCategoryId = other.Id;
}
public sealed record SeededNurse(long NurseId, NurseProfile Profile, NurseVerification Verification, NurseServiceVariant Variant, NurseServiceArea Area);
/// <summary>Seeds one nurse with a single active variant + a single service area, in a chosen bookability
/// state, and does NOT project it yet (the caller drives the maintainer).</summary>
public SeededNurse SeedNurse(
string gender,
bool verified,
bool accepting,
VerificationStatus status,
long price,
long? districtId,
decimal averageRating = 0m,
int totalReviews = 0,
long? categoryId = null,
bool variantActive = true,
string priceUnit = "per_day")
{
var user = new User
{
UserName = $"nurse{_phoneSeed}",
PhoneNumber = $"0912{_phoneSeed++}",
Gender = gender,
IsActive = true
};
Db.Users.Add(user);
Db.SaveChanges();
var profile = new NurseProfile { UserId = user.Id };
if (verified)
profile.MarkVerified();
profile.SetAcceptingBookings(accepting);
Db.Set<NurseProfile>().Add(profile);
SetAggregates(profile, averageRating, totalReviews);
Db.SaveChanges();
var verification = new NurseVerification { NurseId = profile.Id, Status = status };
Db.Set<NurseVerification>().Add(verification);
Db.SaveChanges();
var variant = new NurseServiceVariant
{
NurseId = profile.Id,
ServiceCategoryId = categoryId ?? CategoryId,
Price = price,
PriceUnit = priceUnit,
SessionCount = null,
DisplayName = "variant",
OptionSetHash = $"hash-{profile.Id}",
IsActive = variantActive
};
Db.Set<NurseServiceVariant>().Add(variant);
Db.SaveChanges();
var area = new NurseServiceArea { NurseId = profile.Id, CityId = CityId, DistrictId = districtId, IsActive = true };
Db.Set<NurseServiceArea>().Add(area);
Db.SaveChanges();
return new SeededNurse(profile.Id, profile, verification, variant, area);
}
// The aggregate setters on NurseProfile are private (recomputed by b9/b14). Tests seed them via EF's
// backing fields so a nurse can carry a rating without the (not-yet-built) review pipeline.
private void SetAggregates(NurseProfile profile, decimal averageRating, int totalReviews)
{
var entry = Db.Entry(profile);
entry.Property(nameof(NurseProfile.AverageRating)).CurrentValue = averageRating;
entry.Property(nameof(NurseProfile.TotalReviews)).CurrentValue = totalReviews;
}
/// <summary>Adds a real service-area row (as the b4 handler would) so a later fan-out and a full rebuild
/// derive from the same source.</summary>
public NurseServiceArea AddArea(long nurseId, long? districtId)
{
var area = new NurseServiceArea { NurseId = nurseId, CityId = CityId, DistrictId = districtId, IsActive = true };
Db.Set<NurseServiceArea>().Add(area);
Db.SaveChanges();
return area;
}
public int LiveRowCount() => Db.Set<Domain.Entities.Search.NurseSearchIndex>().Count();
public int SearchableRowCount() =>
Db.Set<Domain.Entities.Search.NurseSearchIndex>().Count(r => r.IsSearchable);
public void Dispose()
{
Db.Dispose();
_connection.Dispose();
}
}
@@ -0,0 +1,219 @@
using Baya.Application.Models.Search;
using Baya.Domain.Entities.Verification;
namespace Baya.Test.Foundation.Search;
/// <summary>
/// End-to-end coverage of the search-index maintainer + SqlNurseSearch over a real EF/SQLite model: the
/// is_searchable predicate, NULL-district geography, gender/price filters, rating sort, the verification
/// flip, service-area fan-out/remove, variant deactivate, and incremental↔rebuild convergence.
/// </summary>
public sealed class SearchIndexTests
{
private const string Female = "female";
private const string Male = "male";
private static NurseSearchCriteria Criteria(
long categoryId, long cityId, long? districtId = null, string? gender = null,
long? minPrice = null, long? maxPrice = null, string? priceUnit = null, int page = 1, int pageSize = 50)
=> new(categoryId, cityId, districtId, gender, minPrice, maxPrice, priceUnit, page, pageSize);
private static void Project(SearchIndexTestHost host, SearchIndexTestHost.SeededNurse nurse)
{
host.Maintainer.ReindexNurseAsync(nurse.Profile, nurse.Verification.Status, default).GetAwaiter().GetResult();
host.Db.SaveChanges();
}
[Fact]
public void IsSearchable_TrueOnlyWhenVerifiedAcceptingNotSuspendedAndVariantActive()
{
using var host = new SearchIndexTestHost();
var good = host.SeedNurse(Female, verified: true, accepting: true, VerificationStatus.Approved, 1000, host.District3Id);
var unverified = host.SeedNurse(Female, verified: false, accepting: true, VerificationStatus.Pending, 1000, host.District3Id);
var notAccepting = host.SeedNurse(Female, verified: true, accepting: false, VerificationStatus.Approved, 1000, host.District3Id);
var suspended = host.SeedNurse(Female, verified: true, accepting: true, VerificationStatus.Suspended, 1000, host.District3Id);
var inactiveVariant = host.SeedNurse(Female, verified: true, accepting: true, VerificationStatus.Approved, 1000, host.District3Id, variantActive: false);
foreach (var n in new[] { good, unverified, notAccepting, suspended, inactiveVariant })
Project(host, n);
Assert.True(IsSearchable(host, good.NurseId));
Assert.False(IsSearchable(host, unverified.NurseId));
Assert.False(IsSearchable(host, notAccepting.NurseId));
Assert.False(IsSearchable(host, suspended.NurseId));
Assert.False(IsSearchable(host, inactiveVariant.NurseId));
// Every nurse with a variant + area has an index row, but only the fully-bookable one is searchable.
Assert.Equal(5, host.LiveRowCount());
Assert.Equal(1, host.SearchableRowCount());
}
[Fact]
public void Geography_WholeCityAndDistrictMatchingIsExact()
{
using var host = new SearchIndexTestHost();
var district3 = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id);
var wholeCity = host.SeedNurse(Male, true, true, VerificationStatus.Approved, 1000, districtId: null);
Project(host, district3);
Project(host, wholeCity);
// District-3 search: the district-3 nurse AND the whole-city (NULL) nurse.
var d3 = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District3Id), default).Result;
Assert.Equal(new[] { district3.NurseId, wholeCity.NurseId }.OrderBy(x => x), d3.Items.Select(i => i.NurseId).OrderBy(x => x));
// A different district in the same city: only the whole-city nurse.
var d7 = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District7Id), default).Result;
Assert.Equal(new[] { wholeCity.NurseId }, d7.Items.Select(i => i.NurseId).ToArray());
// City-only search (no district): both.
var city = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result;
Assert.Equal(2, city.Total);
}
[Fact]
public void SameGenderFilterNarrowsResults()
{
using var host = new SearchIndexTestHost();
var female = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id);
var male = host.SeedNurse(Male, true, true, VerificationStatus.Approved, 1000, host.District3Id);
Project(host, female);
Project(host, male);
var females = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, gender: Female), default).Result;
Assert.Equal(new[] { female.NurseId }, females.Items.Select(i => i.NurseId).ToArray());
var males = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, gender: Male), default).Result;
Assert.Equal(new[] { male.NurseId }, males.Items.Select(i => i.NurseId).ToArray());
}
[Fact]
public void PriceRangeFiltersOnCopiedIrrPrice()
{
using var host = new SearchIndexTestHost();
var cheap = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 500_000, host.District3Id);
var pricey = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 5_000_000, host.District3Id);
Project(host, cheap);
Project(host, pricey);
var midBand = host.Search.SearchAsync(
Criteria(host.CategoryId, host.CityId, minPrice: 400_000, maxPrice: 1_000_000), default).Result;
Assert.Equal(new[] { cheap.NurseId }, midBand.Items.Select(i => i.NurseId).ToArray());
Assert.Equal("500000", midBand.Items[0].Price);
}
[Fact]
public void ResultsSortByRatingDescending()
{
using var host = new SearchIndexTestHost();
var low = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id, averageRating: 3.1m, totalReviews: 4);
var high = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id, averageRating: 4.8m, totalReviews: 9);
Project(host, low);
Project(host, high);
var page = host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result;
Assert.Equal(new[] { high.NurseId, low.NurseId }, page.Items.Select(i => i.NurseId).ToArray());
}
[Fact]
public void SuspendingANurseRemovesThemFromSearch()
{
using var host = new SearchIndexTestHost();
var nurse = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id);
Project(host, nurse);
Assert.Single(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result.Items);
// Flip to suspended + unverified (the b6 suspend path) and reindex in place.
nurse.Profile.MarkUnverified();
nurse.Verification.Status = VerificationStatus.Suspended;
Project(host, nurse);
Assert.Empty(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result.Items);
Assert.False(IsSearchable(host, nurse.NurseId));
// Reinstating makes them searchable again — the row is resurrected, not duplicated.
nurse.Profile.MarkVerified();
nurse.Verification.Status = VerificationStatus.Approved;
Project(host, nurse);
Assert.Single(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result.Items);
Assert.Equal(1, host.LiveRowCount());
}
[Fact]
public void FanOutAddsAreaRows_RemoveDropsExactlyThoseRows()
{
using var host = new SearchIndexTestHost();
var nurse = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id);
Project(host, nurse);
Assert.Equal(1, host.LiveRowCount());
// Add a second area (district 7) and fan out.
host.Maintainer.FanOutServiceAreaAsync(nurse.NurseId, host.CityId, host.District7Id, default).GetAwaiter().GetResult();
host.Db.SaveChanges();
Assert.Equal(2, host.SearchableRowCount());
Assert.Single(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District7Id), default).Result.Items);
// Remove the district-7 area: only its rows drop; district 3 stays.
host.Maintainer.RemoveServiceAreaRowsAsync(nurse.NurseId, host.CityId, host.District7Id, default).GetAwaiter().GetResult();
host.Db.SaveChanges();
Assert.Empty(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District7Id), default).Result.Items);
Assert.Single(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId, host.District3Id), default).Result.Items);
}
[Fact]
public void DeactivatingAVariantKeepsRowsButHidesThem()
{
using var host = new SearchIndexTestHost();
var nurse = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id);
Project(host, nurse);
Assert.Equal(1, host.SearchableRowCount());
nurse.Variant.IsActive = false;
host.Maintainer.ReindexVariantAsync(nurse.Variant, default).GetAwaiter().GetResult();
host.Db.SaveChanges();
Assert.Equal(1, host.LiveRowCount()); // row kept
Assert.Equal(0, host.SearchableRowCount()); // but not searchable
Assert.Empty(host.Search.SearchAsync(Criteria(host.CategoryId, host.CityId), default).Result.Items);
}
[Fact]
public void IncrementalMaintenanceConvergesWithFullRebuild()
{
using var host = new SearchIndexTestHost();
var a = host.SeedNurse(Female, true, true, VerificationStatus.Approved, 1000, host.District3Id, averageRating: 4.5m);
var b = host.SeedNurse(Male, true, true, VerificationStatus.Approved, 2000, districtId: null);
var c = host.SeedNurse(Female, false, true, VerificationStatus.Pending, 3000, host.District7Id);
Project(host, a);
Project(host, b);
Project(host, c);
// Add a second area to B incrementally — the real service-area row plus the fan-out, as b4 does.
host.AddArea(b.NurseId, host.District3Id);
host.Maintainer.FanOutServiceAreaAsync(b.NurseId, host.CityId, host.District3Id, default).GetAwaiter().GetResult();
host.Db.SaveChanges();
var incrementalLive = host.LiveRowCount();
var incrementalSearchable = host.SearchableRowCount();
// A full rebuild from source must reproduce the same live/searchable row set (convergence).
var result = host.Maintainer.RebuildAsync(default).GetAwaiter().GetResult();
Assert.Equal(incrementalLive, host.LiveRowCount());
Assert.Equal(incrementalSearchable, host.SearchableRowCount());
Assert.Equal(3, result.NursesProcessed);
Assert.Equal(incrementalLive, result.RowsWritten);
// No duplicate (variant × area) rows after rebuild.
var duplicates = host.Db.Set<Domain.Entities.Search.NurseSearchIndex>()
.GroupBy(r => new { r.VariantId, r.CityId, r.DistrictId })
.Any(g => g.Count() > 1);
Assert.False(duplicates);
}
private static bool IsSearchable(SearchIndexTestHost host, long nurseId)
=> host.Db.Set<Domain.Entities.Search.NurseSearchIndex>().Any(r => r.NurseId == nurseId && r.IsSearchable);
}
@@ -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);