backend phase 7: search & matching (nurse_search_index)

Add the discovery layer: the denormalized nurse_search_index read model
(one row per bookable variant x covered service area), maintained inline
inside each source write's transaction, plus the single public search
query behind the INurseSearch seam.

- Entity + EF config + migration (search schema): covering search index,
  filtered-unique (variant_id, city_id, district_id) pair with NULL
  district participating, nurse_id index, soft-delete.
- ISearchIndexMaintainer (write seam) + SearchIndexMaintainer: reindex
  variant / nurse / fan-out / remove-area / full rebuild, staged in the
  owning source write's unit of work; wired into the b3/b4/b5/b6 handlers.
- INurseSearch (read seam) + SqlNurseSearch (real MVP backend): reads only
  is_searchable=1, category/city/district(NULL-aware)/gender/price filters,
  rating sort, pagination. Elasticsearch deferred (config Search:Backend).
- SearchNursesQuery (+ validator) and RebuildSearchIndexCommand; public
  SearchController (GET search/nurses) + admin AdminSearchController
  (POST admin_search/rebuild_index).
- Tests: 9 DB-backed maintainer/search + 4 WebApplicationFactory; updated
  affected b3/b4/b5/b6 handler tests. Build clean, 167 tests green.
- Docs: server CLAUDE.md project map, contract search.md, swagger refresh,
  handoff, report, mocks-registry rows, STATUS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-05 17:24:26 +03:30
parent e2b22df2d6
commit 5839b3508f
47 changed files with 5743 additions and 41 deletions
@@ -0,0 +1,100 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class NurseSearchIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "search");
migrationBuilder.CreateTable(
name: "NurseSearchIndices",
schema: "search",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
VariantId = table.Column<long>(type: "bigint", nullable: false),
NurseId = table.Column<long>(type: "bigint", nullable: false),
ServiceCategoryId = table.Column<long>(type: "bigint", nullable: false),
Price = table.Column<long>(type: "bigint", nullable: false),
PriceUnit = table.Column<string>(type: "nvarchar(20)", maxLength: 20, nullable: false),
CityId = table.Column<long>(type: "bigint", nullable: false),
DistrictId = table.Column<long>(type: "bigint", nullable: true),
NurseGender = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
AverageRating = table.Column<decimal>(type: "decimal(3,2)", precision: 3, scale: 2, nullable: false),
TotalReviews = table.Column<int>(type: "int", nullable: false),
TotalCompletedBookings = table.Column<int>(type: "int", nullable: false),
IsSearchable = table.Column<bool>(type: "bit", nullable: false),
UpdatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
DeletedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: false),
ModifiedAt = table.Column<DateTimeOffset>(type: "datetimeoffset", nullable: true),
CreatedById = table.Column<int>(type: "int", nullable: true),
ModifiedById = table.Column<int>(type: "int", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_NurseSearchIndices", x => x.Id);
table.ForeignKey(
name: "FK_NurseSearchIndices_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseSearchIndices_NurseServiceVariants_VariantId",
column: x => x.VariantId,
principalSchema: "catalog",
principalTable: "NurseServiceVariants",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateIndex(
name: "IX_NurseSearchIndex_Search",
schema: "search",
table: "NurseSearchIndices",
columns: new[] { "IsSearchable", "ServiceCategoryId", "CityId", "DistrictId" })
.Annotation("SqlServer:Include", new[] { "Price", "NurseGender", "AverageRating", "TotalReviews", "NurseId", "VariantId" });
migrationBuilder.CreateIndex(
name: "IX_NurseSearchIndices_NurseId",
schema: "search",
table: "NurseSearchIndices",
column: "NurseId");
migrationBuilder.CreateIndex(
name: "UX_NurseSearchIndex_Variant_City_District",
schema: "search",
table: "NurseSearchIndices",
columns: new[] { "VariantId", "CityId", "DistrictId" },
unique: true,
filter: "[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL");
migrationBuilder.CreateIndex(
name: "UX_NurseSearchIndex_Variant_City_WholeCity",
schema: "search",
table: "NurseSearchIndices",
columns: new[] { "VariantId", "CityId" },
unique: true,
filter: "[DistrictId] IS NULL AND [DeletedAt] IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NurseSearchIndices",
schema: "search");
}
}
}
@@ -2138,6 +2138,94 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("Notifications", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("AverageRating")
.HasPrecision(3, 2)
.HasColumnType("decimal(3,2)");
b.Property<long>("CityId")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<long?>("DistrictId")
.HasColumnType("bigint");
b.Property<bool>("IsSearchable")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("NurseGender")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<long>("Price")
.HasColumnType("bigint");
b.Property<string>("PriceUnit")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("nvarchar(20)");
b.Property<long>("ServiceCategoryId")
.HasColumnType("bigint");
b.Property<int>("TotalCompletedBookings")
.HasColumnType("int");
b.Property<int>("TotalReviews")
.HasColumnType("int");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("datetimeoffset");
b.Property<long>("VariantId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("NurseId");
b.HasIndex("VariantId", "CityId")
.IsUnique()
.HasDatabaseName("UX_NurseSearchIndex_Variant_City_WholeCity")
.HasFilter("[DistrictId] IS NULL AND [DeletedAt] IS NULL");
b.HasIndex("VariantId", "CityId", "DistrictId")
.IsUnique()
.HasDatabaseName("UX_NurseSearchIndex_Variant_City_District")
.HasFilter("[DistrictId] IS NOT NULL AND [DeletedAt] IS NULL");
b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId")
.HasDatabaseName("IX_NurseSearchIndex_Search");
SqlServerIndexBuilderExtensions.IncludeProperties(b.HasIndex("IsSearchable", "ServiceCategoryId", "CityId", "DistrictId"), new[] { "Price", "NurseGender", "AverageRating", "TotalReviews", "NurseId", "VariantId" });
b.ToTable("NurseSearchIndices", "search");
});
modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b =>
{
b.Property<long>("Id")
@@ -3181,6 +3269,23 @@ namespace Baya.Infrastructure.Persistence.Migrations
.IsRequired();
});
modelBuilder.Entity("Baya.Domain.Entities.Search.NurseSearchIndex", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", null)
.WithMany()
.HasForeignKey("NurseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant")
.WithMany()
.HasForeignKey("VariantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Variant");
});
modelBuilder.Entity("Baya.Domain.Entities.SupportAlerts.SupportAlert", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)