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