backend phase 5: service catalog & nurse pricing variants

Two-tier service model the marketplace is priced and searched on. Admin
catalog skeleton (categories + EAV option groups/values, addable as data
not migrations; NULL category = cross-category) and the nurse pricing layer
(nurse_service_variants — the atomic bookable unit: category + one value per
required dimension at the nurse's own IRR price and price unit).

- New `catalog` schema via one additive migration; Price BIGINT (no floats),
  on the wire as a string of digits; total = price + unit + session_count.
- Duplicate-listing guard: deterministic option_set_hash + filtered
  UNIQUE(nurse_id, service_category_id, option_set_hash) WHERE deleted_at IS
  NULL + friendly 409 pre-check. One value per dimension; required groups
  (incl. cross-category) enforced; deactivate, never delete.
- Public catalog browse cached behind a CatalogCache generation token,
  invalidated on any admin write. IVariantSnapshotSerializer shipped for b8.
- Contract (catalog.md) + handoff + report published; swagger refreshed.
  122 tests green; zero new build warnings.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hamid
2026-07-02 22:21:53 +03:30
parent 4b4243c451
commit f77a23cb25
84 changed files with 8229 additions and 4 deletions
@@ -0,0 +1,36 @@
namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig;
/// <summary>
/// The five MVP service categories, seeded via <c>HasData</c> so they land with the migration on a fresh
/// DB (the b1 seeding path) — a nurse can build a variant immediately. Ids are fixed and deterministic
/// (1…5, sort_order = id) so re-running is idempotent and the model snapshot stays stable. Option
/// groups/values are <b>not</b> seeded: those are admin-authored data per category (EAV is load-bearing).
/// </summary>
internal static class CatalogSeed
{
// (id, name_fa, name_en). Companionship ships only as a seeded category (data), not a pricing path.
private static readonly (long Id, string NameFa, string NameEn)[] CategoryRows =
[
(1, "مراقبت از سالمند", "Elderly Care"),
(2, "مراقبت پس از جراحی", "Post-Surgery Recovery"),
(3, "مراقبت از نوزاد", "Infant Care"),
(4, "مدیریت بیماری مزمن", "Chronic Illness Management"),
(5, "همراهی و مراقبت روزمره", "Companionship"),
];
public static object[] Categories()
{
var ts = SeedConstants.Timestamp;
return CategoryRows
.Select(c => (object)new
{
c.Id,
c.NameFa,
c.NameEn,
SortOrder = (int)c.Id,
IsActive = true,
CreatedAt = ts
})
.ToArray();
}
}
@@ -0,0 +1,45 @@
using Baya.Domain.Entities.Catalog;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig;
internal sealed class NurseServiceVariantConfig : IEntityTypeConfiguration<NurseServiceVariant>
{
public void Configure(EntityTypeBuilder<NurseServiceVariant> builder)
{
builder.ToTable("NurseServiceVariants", "catalog");
// Price is IRR Rials as BIGINT (long → bigint). There is no float/decimal money path, ever.
builder.Property(v => v.PriceUnit).HasMaxLength(20).IsRequired();
builder.Property(v => v.DisplayName).HasMaxLength(300).IsRequired();
builder.Property(v => v.OptionSetHash).HasMaxLength(64).IsRequired();
builder.Property(v => v.IsActive).HasDefaultValue(true);
// The nurse's offerings list + the b7 index projection read on (nurse_id, is_active).
builder.HasIndex(v => new { v.NurseId, v.IsActive });
// Leading column is nurse_id on the unique index, so a standalone category index is still useful
// for "all variants in a category" (b7 category browse).
builder.HasIndex(v => v.ServiceCategoryId);
// Duplicate-listing DB backstop: a multi-row option-set can't be a plain composite unique, so it is
// reduced to a deterministic option_set_hash and made race-safe here. Filtered to exclude
// soft-deleted rows so a deactivated+deleted listing can be re-created.
builder.HasIndex(v => new { v.NurseId, v.ServiceCategoryId, v.OptionSetHash })
.IsUnique()
.HasFilter("[DeletedAt] IS NULL")
.HasDatabaseName("UX_NurseServiceVariants_Nurse_Category_OptionSet");
builder.HasOne(v => v.Nurse)
.WithMany()
.HasForeignKey(v => v.NurseId)
.IsRequired();
builder.HasOne(v => v.ServiceCategory)
.WithMany(c => c.Variants)
.HasForeignKey(v => v.ServiceCategoryId)
.IsRequired();
builder.HasQueryFilter(v => v.DeletedAt == null);
}
}
@@ -0,0 +1,35 @@
using Baya.Domain.Entities.Catalog;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig;
internal sealed class NurseServiceVariantOptionConfig : IEntityTypeConfiguration<NurseServiceVariantOption>
{
public void Configure(EntityTypeBuilder<NurseServiceVariantOption> builder)
{
builder.ToTable("NurseServiceVariantOptions", "catalog");
// One value per dimension per variant. The unique index is the authoritative backstop; the handler
// validates the same rule for a clean message. Its leading column is variant_id, so it also serves
// "load a variant's full option set" — no separate variant_id index needed.
builder.HasIndex(o => new { o.VariantId, o.OptionGroupId })
.IsUnique()
.HasDatabaseName("UX_NurseServiceVariantOptions_Variant_Group");
builder.HasOne(o => o.Variant)
.WithMany(v => v.Options)
.HasForeignKey(o => o.VariantId)
.IsRequired();
builder.HasOne(o => o.OptionGroup)
.WithMany()
.HasForeignKey(o => o.OptionGroupId)
.IsRequired();
builder.HasOne(o => o.OptionValue)
.WithMany()
.HasForeignKey(o => o.OptionValueId)
.IsRequired();
}
}
@@ -0,0 +1,28 @@
using Baya.Domain.Entities.Catalog;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig;
internal sealed class ServiceCategoryConfig : IEntityTypeConfiguration<ServiceCategory>
{
public void Configure(EntityTypeBuilder<ServiceCategory> builder)
{
builder.ToTable("ServiceCategories", "catalog");
builder.Property(c => c.NameFa).HasMaxLength(150).IsRequired();
builder.Property(c => c.NameEn).HasMaxLength(150).IsRequired();
builder.Property(c => c.DescriptionFa).HasMaxLength(1000);
builder.Property(c => c.DescriptionEn).HasMaxLength(1000);
builder.Property(c => c.IconKey).HasMaxLength(100);
builder.Property(c => c.SortOrder).HasDefaultValue(0);
builder.Property(c => c.IsActive).HasDefaultValue(true);
// Public ordered browse: active categories in sort order.
builder.HasIndex(c => new { c.IsActive, c.SortOrder });
builder.HasQueryFilter(c => c.DeletedAt == null);
builder.HasData(CatalogSeed.Categories());
}
}
@@ -0,0 +1,30 @@
using Baya.Domain.Entities.Catalog;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig;
internal sealed class ServiceOptionGroupConfig : IEntityTypeConfiguration<ServiceOptionGroup>
{
public void Configure(EntityTypeBuilder<ServiceOptionGroup> builder)
{
builder.ToTable("ServiceOptionGroups", "catalog");
builder.Property(g => g.NameFa).HasMaxLength(150).IsRequired();
builder.Property(g => g.NameEn).HasMaxLength(150).IsRequired();
builder.Property(g => g.IsRequired).HasDefaultValue(false);
builder.Property(g => g.SortOrder).HasDefaultValue(0);
builder.Property(g => g.IsActive).HasDefaultValue(true);
// (service_category_id, sort_order) for the applicable-groups read. The nullable FK is deliberate —
// a NULL category is the cross-category case and must not be broken by a required relationship.
builder.HasIndex(g => new { g.ServiceCategoryId, g.SortOrder });
builder.HasOne(g => g.ServiceCategory)
.WithMany(c => c.OptionGroups)
.HasForeignKey(g => g.ServiceCategoryId)
.IsRequired(false);
builder.HasQueryFilter(g => g.DeletedAt == null);
}
}
@@ -0,0 +1,27 @@
using Baya.Domain.Entities.Catalog;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.CatalogConfig;
internal sealed class ServiceOptionValueConfig : IEntityTypeConfiguration<ServiceOptionValue>
{
public void Configure(EntityTypeBuilder<ServiceOptionValue> builder)
{
builder.ToTable("ServiceOptionValues", "catalog");
builder.Property(v => v.NameFa).HasMaxLength(150).IsRequired();
builder.Property(v => v.NameEn).HasMaxLength(150).IsRequired();
builder.Property(v => v.SortOrder).HasDefaultValue(0);
builder.Property(v => v.IsActive).HasDefaultValue(true);
builder.HasIndex(v => new { v.OptionGroupId, v.SortOrder });
builder.HasOne(v => v.OptionGroup)
.WithMany(g => g.Values)
.HasForeignKey(v => v.OptionGroupId)
.IsRequired();
builder.HasQueryFilter(v => v.DeletedAt == null);
}
}
@@ -0,0 +1,280 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class ServiceCatalogAndNurseVariants : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.EnsureSchema(
name: "catalog");
migrationBuilder.CreateTable(
name: "ServiceCategories",
schema: "catalog",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
NameFa = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
NameEn = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
DescriptionFa = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
DescriptionEn = table.Column<string>(type: "nvarchar(1000)", maxLength: 1000, nullable: true),
IconKey = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
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_ServiceCategories", x => x.Id);
});
migrationBuilder.CreateTable(
name: "NurseServiceVariants",
schema: "catalog",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
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),
SessionCount = table.Column<int>(type: "int", nullable: true),
DisplayName = table.Column<string>(type: "nvarchar(300)", maxLength: 300, nullable: false),
OptionSetHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
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_NurseServiceVariants", x => x.Id);
table.ForeignKey(
name: "FK_NurseServiceVariants_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseServiceVariants_ServiceCategories_ServiceCategoryId",
column: x => x.ServiceCategoryId,
principalSchema: "catalog",
principalTable: "ServiceCategories",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "ServiceOptionGroups",
schema: "catalog",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
ServiceCategoryId = table.Column<long>(type: "bigint", nullable: true),
NameFa = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
NameEn = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
IsRequired = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
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_ServiceOptionGroups", x => x.Id);
table.ForeignKey(
name: "FK_ServiceOptionGroups_ServiceCategories_ServiceCategoryId",
column: x => x.ServiceCategoryId,
principalSchema: "catalog",
principalTable: "ServiceCategories",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "ServiceOptionValues",
schema: "catalog",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
OptionGroupId = table.Column<long>(type: "bigint", nullable: false),
NameFa = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
NameEn = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: false),
SortOrder = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: true),
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_ServiceOptionValues", x => x.Id);
table.ForeignKey(
name: "FK_ServiceOptionValues_ServiceOptionGroups_OptionGroupId",
column: x => x.OptionGroupId,
principalSchema: "catalog",
principalTable: "ServiceOptionGroups",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "NurseServiceVariantOptions",
schema: "catalog",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
VariantId = table.Column<long>(type: "bigint", nullable: false),
OptionGroupId = table.Column<long>(type: "bigint", nullable: false),
OptionValueId = table.Column<long>(type: "bigint", nullable: false),
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_NurseServiceVariantOptions", x => x.Id);
table.ForeignKey(
name: "FK_NurseServiceVariantOptions_NurseServiceVariants_VariantId",
column: x => x.VariantId,
principalSchema: "catalog",
principalTable: "NurseServiceVariants",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseServiceVariantOptions_ServiceOptionGroups_OptionGroupId",
column: x => x.OptionGroupId,
principalSchema: "catalog",
principalTable: "ServiceOptionGroups",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseServiceVariantOptions_ServiceOptionValues_OptionValueId",
column: x => x.OptionValueId,
principalSchema: "catalog",
principalTable: "ServiceOptionValues",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.InsertData(
schema: "catalog",
table: "ServiceCategories",
columns: new[] { "Id", "CreatedAt", "CreatedById", "DeletedAt", "DescriptionEn", "DescriptionFa", "IconKey", "IsActive", "ModifiedAt", "ModifiedById", "NameEn", "NameFa", "SortOrder" },
values: new object[,]
{
{ 1L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, null, null, null, true, null, null, "Elderly Care", "مراقبت از سالمند", 1 },
{ 2L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, null, null, null, true, null, null, "Post-Surgery Recovery", "مراقبت پس از جراحی", 2 },
{ 3L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, null, null, null, true, null, null, "Infant Care", "مراقبت از نوزاد", 3 },
{ 4L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, null, null, null, true, null, null, "Chronic Illness Management", "مدیریت بیماری مزمن", 4 },
{ 5L, new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)), null, null, null, null, null, true, null, null, "Companionship", "همراهی و مراقبت روزمره", 5 }
});
migrationBuilder.CreateIndex(
name: "IX_NurseServiceVariantOptions_OptionGroupId",
schema: "catalog",
table: "NurseServiceVariantOptions",
column: "OptionGroupId");
migrationBuilder.CreateIndex(
name: "IX_NurseServiceVariantOptions_OptionValueId",
schema: "catalog",
table: "NurseServiceVariantOptions",
column: "OptionValueId");
migrationBuilder.CreateIndex(
name: "UX_NurseServiceVariantOptions_Variant_Group",
schema: "catalog",
table: "NurseServiceVariantOptions",
columns: new[] { "VariantId", "OptionGroupId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NurseServiceVariants_NurseId_IsActive",
schema: "catalog",
table: "NurseServiceVariants",
columns: new[] { "NurseId", "IsActive" });
migrationBuilder.CreateIndex(
name: "IX_NurseServiceVariants_ServiceCategoryId",
schema: "catalog",
table: "NurseServiceVariants",
column: "ServiceCategoryId");
migrationBuilder.CreateIndex(
name: "UX_NurseServiceVariants_Nurse_Category_OptionSet",
schema: "catalog",
table: "NurseServiceVariants",
columns: new[] { "NurseId", "ServiceCategoryId", "OptionSetHash" },
unique: true,
filter: "[DeletedAt] IS NULL");
migrationBuilder.CreateIndex(
name: "IX_ServiceCategories_IsActive_SortOrder",
schema: "catalog",
table: "ServiceCategories",
columns: new[] { "IsActive", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_ServiceOptionGroups_ServiceCategoryId_SortOrder",
schema: "catalog",
table: "ServiceOptionGroups",
columns: new[] { "ServiceCategoryId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_ServiceOptionValues_OptionGroupId_SortOrder",
schema: "catalog",
table: "ServiceOptionValues",
columns: new[] { "OptionGroupId", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NurseServiceVariantOptions",
schema: "catalog");
migrationBuilder.DropTable(
name: "NurseServiceVariants",
schema: "catalog");
migrationBuilder.DropTable(
name: "ServiceOptionValues",
schema: "catalog");
migrationBuilder.DropTable(
name: "ServiceOptionGroups",
schema: "catalog");
migrationBuilder.DropTable(
name: "ServiceCategories",
schema: "catalog");
}
}
}
@@ -98,6 +98,337 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.ToTable("AuditLogs", "ops");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("nvarchar(300)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<string>("OptionSetHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
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?>("SessionCount")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("ServiceCategoryId");
b.HasIndex("NurseId", "IsActive");
b.HasIndex("NurseId", "ServiceCategoryId", "OptionSetHash")
.IsUnique()
.HasDatabaseName("UX_NurseServiceVariants_Nurse_Category_OptionSet")
.HasFilter("[DeletedAt] IS NULL");
b.ToTable("NurseServiceVariants", "catalog");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("OptionGroupId")
.HasColumnType("bigint");
b.Property<long>("OptionValueId")
.HasColumnType("bigint");
b.Property<long>("VariantId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("OptionGroupId");
b.HasIndex("OptionValueId");
b.HasIndex("VariantId", "OptionGroupId")
.IsUnique()
.HasDatabaseName("UX_NurseServiceVariantOptions_Variant_Group");
b.ToTable("NurseServiceVariantOptions", "catalog");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("DescriptionEn")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("DescriptionFa")
.HasMaxLength(1000)
.HasColumnType("nvarchar(1000)");
b.Property<string>("IconKey")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("NameEn")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<string>("NameFa")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.HasKey("Id");
b.HasIndex("IsActive", "SortOrder");
b.ToTable("ServiceCategories", "catalog");
b.HasData(
new
{
Id = 1L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
IsActive = true,
NameEn = "Elderly Care",
NameFa = "مراقبت از سالمند",
SortOrder = 1
},
new
{
Id = 2L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
IsActive = true,
NameEn = "Post-Surgery Recovery",
NameFa = "مراقبت پس از جراحی",
SortOrder = 2
},
new
{
Id = 3L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
IsActive = true,
NameEn = "Infant Care",
NameFa = "مراقبت از نوزاد",
SortOrder = 3
},
new
{
Id = 4L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
IsActive = true,
NameEn = "Chronic Illness Management",
NameFa = "مدیریت بیماری مزمن",
SortOrder = 4
},
new
{
Id = 5L,
CreatedAt = new DateTimeOffset(new DateTime(2026, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)),
IsActive = true,
NameEn = "Companionship",
NameFa = "همراهی و مراقبت روزمره",
SortOrder = 5
});
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<bool>("IsRequired")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("NameEn")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<string>("NameFa")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<long?>("ServiceCategoryId")
.HasColumnType("bigint");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.HasKey("Id");
b.HasIndex("ServiceCategoryId", "SortOrder");
b.ToTable("ServiceOptionGroups", "catalog");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<string>("NameEn")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<string>("NameFa")
.IsRequired()
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<long>("OptionGroupId")
.HasColumnType("bigint");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.HasKey("Id");
b.HasIndex("OptionGroupId", "SortOrder");
b.ToTable("ServiceOptionValues", "catalog");
});
modelBuilder.Entity("Baya.Domain.Entities.Configuration.PlatformConfig", b =>
{
b.Property<long>("Id")
@@ -2243,6 +2574,72 @@ namespace Baya.Infrastructure.Persistence.Migrations
.HasForeignKey("ActorUserId");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse")
.WithMany()
.HasForeignKey("NurseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory")
.WithMany("Variants")
.HasForeignKey("ServiceCategoryId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Nurse");
b.Navigation("ServiceCategory");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariantOption", b =>
{
b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup")
.WithMany()
.HasForeignKey("OptionGroupId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionValue", "OptionValue")
.WithMany()
.HasForeignKey("OptionValueId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.Catalog.NurseServiceVariant", "Variant")
.WithMany("Options")
.HasForeignKey("VariantId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("OptionGroup");
b.Navigation("OptionValue");
b.Navigation("Variant");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b =>
{
b.HasOne("Baya.Domain.Entities.Catalog.ServiceCategory", "ServiceCategory")
.WithMany("OptionGroups")
.HasForeignKey("ServiceCategoryId");
b.Navigation("ServiceCategory");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionValue", b =>
{
b.HasOne("Baya.Domain.Entities.Catalog.ServiceOptionGroup", "OptionGroup")
.WithMany("Values")
.HasForeignKey("OptionGroupId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("OptionGroup");
});
modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b =>
{
b.HasOne("Baya.Domain.Entities.Geography.Province", "Province")
@@ -2466,6 +2863,23 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("User");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.NurseServiceVariant", b =>
{
b.Navigation("Options");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceCategory", b =>
{
b.Navigation("OptionGroups");
b.Navigation("Variants");
});
modelBuilder.Entity("Baya.Domain.Entities.Catalog.ServiceOptionGroup", b =>
{
b.Navigation("Values");
});
modelBuilder.Entity("Baya.Domain.Entities.Geography.City", b =>
{
b.Navigation("Districts");
@@ -0,0 +1,99 @@
#nullable enable
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Catalog;
using Baya.Domain.Entities.Catalog;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class CatalogRepository : ICatalogRepository
{
private readonly ApplicationDbContext _db;
public CatalogRepository(ApplicationDbContext db) => _db = db;
public async Task<IReadOnlyList<ServiceCategoryDto>> ListActiveCategoriesAsync(CancellationToken cancellationToken)
=> await _db.Set<ServiceCategory>()
.AsNoTracking()
.Where(c => c.IsActive)
.OrderBy(c => c.SortOrder)
.ThenBy(c => c.Id)
.Select(c => new ServiceCategoryDto(
c.Id, c.NameFa, c.NameEn, c.DescriptionFa, c.DescriptionEn, c.IconKey, c.SortOrder, c.IsActive))
.ToListAsync(cancellationToken);
public async Task<IReadOnlyList<OptionGroupDto>> GetApplicableGroupsAsync(long categoryId, CancellationToken cancellationToken)
=> await _db.Set<ServiceOptionGroup>()
.AsNoTracking()
// The category's own active groups PLUS every cross-category (NULL) active group.
.Where(g => g.IsActive && (g.ServiceCategoryId == categoryId || g.ServiceCategoryId == null))
.OrderBy(g => g.SortOrder)
.ThenBy(g => g.Id)
.Select(g => new OptionGroupDto(
g.Id,
g.ServiceCategoryId,
g.NameFa,
g.NameEn,
g.IsRequired,
g.SortOrder,
g.IsActive,
g.Values
.Where(v => v.IsActive)
.OrderBy(v => v.SortOrder)
.ThenBy(v => v.Id)
.Select(v => new OptionValueDto(v.Id, v.NameFa, v.NameEn, v.SortOrder, v.IsActive))
.ToList()))
.ToListAsync(cancellationToken);
public Task<ServiceCategoryDto?> GetActiveCategoryAsync(long id, CancellationToken cancellationToken)
=> _db.Set<ServiceCategory>()
.AsNoTracking()
.Where(c => c.Id == id && c.IsActive)
.Select(c => new ServiceCategoryDto(
c.Id, c.NameFa, c.NameEn, c.DescriptionFa, c.DescriptionEn, c.IconKey, c.SortOrder, c.IsActive))
.FirstOrDefaultAsync(cancellationToken);
public Task<OptionGroupDto?> GetGroupDtoAsync(long id, CancellationToken cancellationToken)
=> _db.Set<ServiceOptionGroup>()
.AsNoTracking()
.Where(g => g.Id == id)
.Select(g => new OptionGroupDto(
g.Id,
g.ServiceCategoryId,
g.NameFa,
g.NameEn,
g.IsRequired,
g.SortOrder,
g.IsActive,
g.Values
.Where(v => v.IsActive)
.OrderBy(v => v.SortOrder)
.ThenBy(v => v.Id)
.Select(v => new OptionValueDto(v.Id, v.NameFa, v.NameEn, v.SortOrder, v.IsActive))
.ToList()))
.FirstOrDefaultAsync(cancellationToken);
public Task<ServiceCategory?> GetCategoryAsync(long id, CancellationToken cancellationToken)
=> _db.Set<ServiceCategory>().FirstOrDefaultAsync(c => c.Id == id, cancellationToken);
public Task<ServiceOptionGroup?> GetGroupAsync(long id, CancellationToken cancellationToken)
=> _db.Set<ServiceOptionGroup>().FirstOrDefaultAsync(g => g.Id == id, cancellationToken);
public Task<ServiceOptionValue?> GetValueAsync(long id, CancellationToken cancellationToken)
=> _db.Set<ServiceOptionValue>().FirstOrDefaultAsync(v => v.Id == id, cancellationToken);
public Task<bool> CategoryExistsAsync(long id, CancellationToken cancellationToken)
=> _db.Set<ServiceCategory>().AsNoTracking().AnyAsync(c => c.Id == id, cancellationToken);
public Task<bool> GroupExistsAsync(long id, CancellationToken cancellationToken)
=> _db.Set<ServiceOptionGroup>().AsNoTracking().AnyAsync(g => g.Id == id, cancellationToken);
public async Task AddCategoryAsync(ServiceCategory category, CancellationToken cancellationToken)
=> await _db.Set<ServiceCategory>().AddAsync(category, cancellationToken);
public async Task AddGroupAsync(ServiceOptionGroup group, CancellationToken cancellationToken)
=> await _db.Set<ServiceOptionGroup>().AddAsync(group, cancellationToken);
public async Task AddValueAsync(ServiceOptionValue value, CancellationToken cancellationToken)
=> await _db.Set<ServiceOptionValue>().AddAsync(value, cancellationToken);
}
@@ -16,6 +16,8 @@ public class UnitOfWork : IUnitOfWork
public IGeoRepository GeoRepository { get; }
public INurseServiceAreaRepository NurseServiceAreaRepository { get; }
public ICustomerAddressRepository CustomerAddressRepository { get; }
public ICatalogRepository CatalogRepository { get; }
public INurseServiceVariantRepository NurseServiceVariantRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -30,6 +32,8 @@ public class UnitOfWork : IUnitOfWork
GeoRepository = new GeoRepository(_db);
NurseServiceAreaRepository = new NurseServiceAreaRepository(_db);
CustomerAddressRepository = new CustomerAddressRepository(_db);
CatalogRepository = new CatalogRepository(_db);
NurseServiceVariantRepository = new NurseServiceVariantRepository(_db);
}
public Task CommitAsync()
@@ -0,0 +1,125 @@
#nullable enable
using System.Globalization;
using System.Linq.Expressions;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Catalog;
using Baya.Application.Models.Common;
using Baya.Domain.Entities.Catalog;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class NurseServiceVariantRepository : BaseAsyncRepository<NurseServiceVariant>, INurseServiceVariantRepository
{
public NurseServiceVariantRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(NurseServiceVariant variant, CancellationToken cancellationToken)
=> base.AddAsync(variant);
public Task<NurseServiceVariant?> GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(v => v.Id == id && v.NurseId == nurseId, cancellationToken);
public Task<bool> DuplicateHashExistsAsync(long nurseId, long serviceCategoryId, string optionSetHash, long? excludeVariantId, CancellationToken cancellationToken)
=> TableNoTracking.AnyAsync(
v => v.NurseId == nurseId
&& v.ServiceCategoryId == serviceCategoryId
&& v.OptionSetHash == optionSetHash
&& (excludeVariantId == null || v.Id != excludeVariantId),
cancellationToken);
public async Task<PagedResult<VariantDto>> ListMineAsync(long nurseId, int page, int pageSize, CancellationToken cancellationToken)
{
var query = TableNoTracking.Where(v => v.NurseId == nurseId);
var total = await query.CountAsync(cancellationToken);
var rows = await query
// Active offerings first, then newest — the deactivated ones stay visibly distinct at the tail.
.OrderByDescending(v => v.IsActive)
.ThenByDescending(v => v.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(Projection)
.ToListAsync(cancellationToken);
return new PagedResult<VariantDto>(rows.Select(Map).ToList(), total, page, pageSize);
}
public async Task<VariantDto?> GetOwnedProjectedAsync(long id, long nurseId, CancellationToken cancellationToken)
{
var row = await TableNoTracking
.Where(v => v.Id == id && v.NurseId == nurseId)
.Select(Projection)
.FirstOrDefaultAsync(cancellationToken);
return row is null ? null : Map(row);
}
public async Task<VariantDto?> GetProjectedAsync(long id, CancellationToken cancellationToken)
{
var row = await TableNoTracking
.Where(v => v.Id == id)
.Select(Projection)
.FirstOrDefaultAsync(cancellationToken);
return row is null ? null : Map(row);
}
public async Task<VariantDto?> GetPublicProjectedAsync(long id, CancellationToken cancellationToken)
{
var row = await TableNoTracking
.Where(v => v.Id == id && v.IsActive)
.Select(Projection)
.FirstOrDefaultAsync(cancellationToken);
return row is null ? null : Map(row);
}
// Shared DB projection: keeps Price as the raw long (translatable) and resolves category/option labels.
// Price is formatted to a digit string in memory (see Map) so no long.ToString() SQL translation is
// required, and the option-set is a single-level collection projection (SQLite-safe).
private static readonly Expression<Func<NurseServiceVariant, VariantRow>> Projection = v => new VariantRow(
v.Id,
v.ServiceCategoryId,
v.ServiceCategory.NameFa,
v.ServiceCategory.NameEn,
v.Price,
v.PriceUnit,
v.SessionCount,
v.DisplayName,
v.IsActive,
v.Options
.OrderBy(o => o.OptionGroup.SortOrder)
.ThenBy(o => o.OptionGroupId)
.Select(o => new VariantOptionDto(
o.OptionGroupId,
o.OptionGroup.NameFa,
o.OptionGroup.NameEn,
o.OptionValueId,
o.OptionValue.NameFa,
o.OptionValue.NameEn))
.ToList());
private static VariantDto Map(VariantRow r) => new(
r.Id,
r.ServiceCategoryId,
r.CategoryNameFa,
r.CategoryNameEn,
r.Price.ToString(CultureInfo.InvariantCulture),
r.PriceUnit,
r.SessionCount,
r.DisplayName,
r.IsActive,
r.Options);
private sealed record VariantRow(
long Id,
long ServiceCategoryId,
string CategoryNameFa,
string CategoryNameEn,
long Price,
string PriceUnit,
int? SessionCount,
string DisplayName,
bool IsActive,
List<VariantOptionDto> Options);
}