Files
baya-monorepo/server/src/Infrastructure/Baya.Infrastructure.Persistence/Configuration/SearchConfig/NurseSearchIndexConfig.cs
T
hamid 5839b3508f 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>
2026-07-05 17:24:26 +03:30

58 lines
2.8 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}