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:
+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)
|
||||
|
||||
Reference in New Issue
Block a user