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