backend phase 3: identity profiles, patients & nurse bank accounts

Add the role-attached identity layer on top of the b2 auth spine: nurse
seller profiles (guarded is_verified, read-only aggregates), thin customer
payer profiles, first-class patients (tenancy-scoped), and nurse payout bank
accounts hardened with an iban_hash uniqueness guard and an automated استعلام
شبا IBAN-ownership inquiry.

- Four usr tables via one migration (1:1 uniques, UNIQUE(iban_hash), filtered
  UNIQUE(nurse_id) WHERE is_primary=1, guarded is_verified, encrypted PII,
  soft-delete on nurse_profiles)
- 15 CQRS slices + 4 role-scoped controllers; reads projected + paginated,
  IBAN masked (last-4); ownership-inquiry endpoints rate-limited
- New IBankAccountOwnershipVerifier seam (mock deterministic شبا match) +
  per-domain repositories on IUnitOfWork + encrypted-PII value converters
- Activate FluentValidation repo-wide (validators were never registered)
- Handler unit tests + WebApplicationFactory integration tests (76 pass);
  contract identity-profiles.md + swagger snapshot; docs, handoff & report

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
hamid
2026-07-02 12:03:15 +03:30
parent 17a82832ab
commit 39a979b1a7
89 changed files with 6060 additions and 13 deletions
@@ -0,0 +1,215 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Baya.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class IdentityProfilesPatientsBankAccounts : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "CustomerProfiles",
schema: "usr",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<int>(type: "int", nullable: false),
DefaultEmergencyContactName = table.Column<string>(type: "nvarchar(max)", nullable: true),
DefaultEmergencyContactPhone = table.Column<string>(type: "nvarchar(max)", 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_CustomerProfiles", x => x.Id);
table.ForeignKey(
name: "FK_CustomerProfiles_Users_UserId",
column: x => x.UserId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "NurseProfiles",
schema: "usr",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
UserId = table.Column<int>(type: "int", nullable: false),
PartnerCenterId = table.Column<long>(type: "bigint", nullable: true),
Bio = table.Column<string>(type: "nvarchar(2000)", maxLength: 2000, nullable: true),
YearsOfExperience = table.Column<int>(type: "int", nullable: false),
EducationLevel = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
EducationField = table.Column<string>(type: "nvarchar(150)", maxLength: 150, nullable: true),
SpecializationsJson = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsVerified = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
IsAcceptingBookings = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
AverageRating = table.Column<decimal>(type: "decimal(3,2)", precision: 3, scale: 2, nullable: false, defaultValue: 0m),
TotalReviews = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
TotalCompletedBookings = table.Column<int>(type: "int", nullable: false, defaultValue: 0),
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_NurseProfiles", x => x.Id);
table.ForeignKey(
name: "FK_NurseProfiles_Users_UserId",
column: x => x.UserId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "Patients",
schema: "usr",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
CustomerId = table.Column<long>(type: "bigint", nullable: false),
DisplayName = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
FirstName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
LastName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
BirthDate = table.Column<DateOnly>(type: "date", nullable: false),
Gender = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: false),
BloodType = table.Column<string>(type: "nvarchar(10)", maxLength: 10, nullable: true),
InitialMedicalNotes = table.Column<string>(type: "nvarchar(max)", nullable: true),
IsActive = table.Column<bool>(type: "bit", nullable: false, defaultValue: 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_Patients", x => x.Id);
table.ForeignKey(
name: "FK_Patients_CustomerProfiles_CustomerId",
column: x => x.CustomerId,
principalSchema: "usr",
principalTable: "CustomerProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
});
migrationBuilder.CreateTable(
name: "NurseBankAccounts",
schema: "usr",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
NurseId = table.Column<long>(type: "bigint", nullable: false),
BankName = table.Column<string>(type: "nvarchar(100)", maxLength: 100, nullable: true),
AccountHolderName = table.Column<string>(type: "nvarchar(max)", nullable: true),
Iban = table.Column<string>(type: "nvarchar(max)", nullable: true),
IbanHash = table.Column<string>(type: "nvarchar(64)", maxLength: 64, nullable: false),
IsPrimary = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
MatchedNationalId = table.Column<bool>(type: "bit", nullable: true),
AccountHolderFromBank = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
OwnershipVendorRef = table.Column<string>(type: "nvarchar(200)", maxLength: 200, nullable: true),
IsVerified = table.Column<bool>(type: "bit", nullable: false, defaultValue: false),
VerifiedByAdminId = table.Column<int>(type: "int", nullable: true),
VerifiedAt = 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_NurseBankAccounts", x => x.Id);
table.ForeignKey(
name: "FK_NurseBankAccounts_NurseProfiles_NurseId",
column: x => x.NurseId,
principalSchema: "usr",
principalTable: "NurseProfiles",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_NurseBankAccounts_Users_VerifiedByAdminId",
column: x => x.VerifiedByAdminId,
principalSchema: "usr",
principalTable: "Users",
principalColumn: "UserId");
});
migrationBuilder.CreateIndex(
name: "IX_CustomerProfiles_UserId",
schema: "usr",
table: "CustomerProfiles",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NurseBankAccounts_IbanHash",
schema: "usr",
table: "NurseBankAccounts",
column: "IbanHash",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_NurseBankAccounts_VerifiedByAdminId",
schema: "usr",
table: "NurseBankAccounts",
column: "VerifiedByAdminId");
migrationBuilder.CreateIndex(
name: "UX_NurseBankAccounts_NurseId_Primary",
schema: "usr",
table: "NurseBankAccounts",
column: "NurseId",
unique: true,
filter: "[IsPrimary] = 1");
migrationBuilder.CreateIndex(
name: "IX_NurseProfiles_UserId",
schema: "usr",
table: "NurseProfiles",
column: "UserId",
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Patients_CustomerId",
schema: "usr",
table: "Patients",
column: "CustomerId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NurseBankAccounts",
schema: "usr");
migrationBuilder.DropTable(
name: "Patients",
schema: "usr");
migrationBuilder.DropTable(
name: "NurseProfiles",
schema: "usr");
migrationBuilder.DropTable(
name: "CustomerProfiles",
schema: "usr");
}
}
}
@@ -390,6 +390,266 @@ namespace Baya.Infrastructure.Persistence.Migrations
});
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", 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<string>("DefaultEmergencyContactName")
.HasColumnType("nvarchar(max)");
b.Property<string>("DefaultEmergencyContactPhone")
.HasColumnType("nvarchar(max)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("CustomerProfiles", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<string>("AccountHolderFromBank")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("AccountHolderName")
.HasColumnType("nvarchar(max)");
b.Property<string>("BankName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<string>("Iban")
.HasColumnType("nvarchar(max)");
b.Property<string>("IbanHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("nvarchar(64)");
b.Property<bool>("IsPrimary")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("IsVerified")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool?>("MatchedNationalId")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long>("NurseId")
.HasColumnType("bigint");
b.Property<string>("OwnershipVendorRef")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<DateTimeOffset?>("VerifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("VerifiedByAdminId")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("IbanHash")
.IsUnique();
b.HasIndex("NurseId")
.IsUnique()
.HasDatabaseName("UX_NurseBankAccounts_NurseId_Primary")
.HasFilter("[IsPrimary] = 1");
b.HasIndex("VerifiedByAdminId");
b.ToTable("NurseBankAccounts", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<decimal>("AverageRating")
.ValueGeneratedOnAdd()
.HasPrecision(3, 2)
.HasColumnType("decimal(3,2)")
.HasDefaultValue(0m);
b.Property<string>("Bio")
.HasMaxLength(2000)
.HasColumnType("nvarchar(2000)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<DateTimeOffset?>("DeletedAt")
.HasColumnType("datetimeoffset");
b.Property<string>("EducationField")
.HasMaxLength(150)
.HasColumnType("nvarchar(150)");
b.Property<string>("EducationLevel")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<bool>("IsAcceptingBookings")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<bool>("IsVerified")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.Property<long?>("PartnerCenterId")
.HasColumnType("bigint");
b.Property<string>("SpecializationsJson")
.HasColumnType("nvarchar(max)");
b.Property<int>("TotalCompletedBookings")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<int>("TotalReviews")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasDefaultValue(0);
b.Property<int>("UserId")
.HasColumnType("int");
b.Property<int>("YearsOfExperience")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("UserId")
.IsUnique();
b.ToTable("NurseProfiles", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<long>("Id"));
b.Property<DateOnly>("BirthDate")
.HasColumnType("date");
b.Property<string>("BloodType")
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("CreatedById")
.HasColumnType("int");
b.Property<long>("CustomerId")
.HasColumnType("bigint");
b.Property<string>("DisplayName")
.HasMaxLength(200)
.HasColumnType("nvarchar(200)");
b.Property<string>("FirstName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<string>("Gender")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("nvarchar(10)");
b.Property<string>("InitialMedicalNotes")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsActive")
.ValueGeneratedOnAdd()
.HasColumnType("bit")
.HasDefaultValue(true);
b.Property<string>("LastName")
.HasMaxLength(100)
.HasColumnType("nvarchar(100)");
b.Property<DateTimeOffset?>("ModifiedAt")
.HasColumnType("datetimeoffset");
b.Property<int?>("ModifiedById")
.HasColumnType("int");
b.HasKey("Id");
b.HasIndex("CustomerId");
b.ToTable("Patients", "usr");
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{
b.Property<long>("Id")
@@ -880,6 +1140,54 @@ namespace Baya.Infrastructure.Persistence.Migrations
.HasForeignKey("ActorUserId");
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", "User")
.WithOne()
.HasForeignKey("Baya.Domain.Entities.Identity.CustomerProfile", "UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseBankAccount", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.NurseProfile", "Nurse")
.WithMany("BankAccounts")
.HasForeignKey("NurseId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.HasOne("Baya.Domain.Entities.User.User", null)
.WithMany()
.HasForeignKey("VerifiedByAdminId");
b.Navigation("Nurse");
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", "User")
.WithOne()
.HasForeignKey("Baya.Domain.Entities.Identity.NurseProfile", "UserId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.Patient", b =>
{
b.HasOne("Baya.Domain.Entities.Identity.CustomerProfile", "Customer")
.WithMany("Patients")
.HasForeignKey("CustomerId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Customer");
});
modelBuilder.Entity("Baya.Domain.Entities.Notifications.Notification", b =>
{
b.HasOne("Baya.Domain.Entities.User.User", null)
@@ -985,6 +1293,16 @@ namespace Baya.Infrastructure.Persistence.Migrations
b.Navigation("User");
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.CustomerProfile", b =>
{
b.Navigation("Patients");
});
modelBuilder.Entity("Baya.Domain.Entities.Identity.NurseProfile", b =>
{
b.Navigation("BankAccounts");
});
modelBuilder.Entity("Baya.Domain.Entities.User.Role", b =>
{
b.Navigation("Claims");