5839b3508f
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>
58 lines
2.8 KiB
C#
58 lines
2.8 KiB
C#
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);
|
||
}
|
||
}
|