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,38 @@
#nullable enable
using System.Security.Cryptography;
using System.Text;
using Baya.Application.Contracts.Common;
using Microsoft.Extensions.Options;
namespace Baya.Infrastructure.CrossCutting.Seams;
/// <summary>
/// Mock <see cref="IBankAccountOwnershipVerifier"/>: a deterministic fake استعلام شبا inquiry — no real
/// bank/KYC call and no money moves. Every IBAN returns a match except the configured
/// <see cref="BankOwnershipOptions.MismatchIban"/>, which returns <c>MatchedNationalId = false</c> so the
/// ownership-mismatch path is testable. The vendor ref is derived from the IBAN, so re-running the same
/// inquiry is idempotent. The real implementation (Finnotech/banking-bridge) swaps in via a registration
/// change — callers are unchanged.
/// </summary>
public sealed class MockBankAccountOwnershipVerifier(IOptions<SeamOptions> options) : IBankAccountOwnershipVerifier
{
private readonly BankOwnershipOptions _options = options.Value.BankOwnership;
// nurseNationalId is part of the real vendor contract (owner ↔ national-id match); the mock decides
// the outcome from the IBAN alone so both paths are deterministically testable.
public Task<OwnershipInquiryResult> VerifyOwnershipAsync(string iban, string? nurseNationalId, CancellationToken cancellationToken = default)
{
var normalized = Normalize(iban);
var matched = !string.Equals(normalized, Normalize(_options.MismatchIban), StringComparison.OrdinalIgnoreCase);
var holder = matched ? _options.MatchedHolderName : _options.MismatchHolderName;
var vendorRef = $"MOCK-SHEBA-{Token(normalized)}";
return Task.FromResult(new OwnershipInquiryResult(matched, holder, vendorRef));
}
private static string Normalize(string iban)
=> string.IsNullOrEmpty(iban) ? string.Empty : iban.Replace(" ", string.Empty).ToUpperInvariant();
private static string Token(string value)
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))[..12];
}
@@ -10,6 +10,24 @@ public sealed class SeamOptions
public FieldEncryptionOptions FieldEncryption { get; set; } = new();
public ObjectStorageOptions ObjectStorage { get; set; } = new();
public BankOwnershipOptions BankOwnership { get; set; } = new();
}
/// <summary>
/// Tunes the mock <c>IBankAccountOwnershipVerifier</c> (استعلام شبا). A submitted IBAN equal to
/// <see cref="MismatchIban"/> returns an ownership mismatch so the payout-gating path is testable; every
/// other IBAN returns a match. The real vendor implementation ignores these.
/// </summary>
public sealed class BankOwnershipOptions
{
/// <summary>The designated test IBAN that returns <c>matched_national_id = false</c>.</summary>
public string MismatchIban { get; set; } = "IR000000000000000000000000";
/// <summary>The account-holder name the mock echoes back for a matching inquiry.</summary>
public string MatchedHolderName { get; set; } = "Verified Account Holder";
/// <summary>The account-holder name the mock returns for the mismatch IBAN.</summary>
public string MismatchHolderName { get; set; } = "Unmatched Account Holder";
}
public sealed class FieldEncryptionOptions
@@ -28,6 +28,10 @@ public static class ServiceCollectionExtension
// (Kavenegar/Ghasedak/SMS.ir) replaces this registration only.
services.AddSingleton<ISmsSender, LoggingSmsSender>();
// استعلام شبا IBAN-owner ↔ national-id inquiry (backend-phase-3). The mock returns a deterministic
// fake match; a real Finnotech/banking-bridge client replaces this registration only.
services.AddSingleton<IBankAccountOwnershipVerifier, MockBankAccountOwnershipVerifier>();
return services;
}
}
@@ -1,6 +1,7 @@
using System.Reflection;
using Baya.Application.Contracts.Common;
using Baya.Domain.Common;
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Baya.Infrastructure.Persistence.ValueConversion;
using Baya.SharedKernel.Extensions;
@@ -103,5 +104,22 @@ public class ApplicationDbContext: IdentityDbContext<User, Role, int, UserClaim,
builder.Property(u => u.NormalizedEmail).HasConversion(encrypted);
builder.Property(u => u.NationalId).HasConversion(encrypted);
});
// b3 PII: emergency contacts, clinical notes, IBAN and the account-holder name are encrypted at
// rest through the same seam. The IBAN's deterministic lookup uses the iban_hash column instead.
modelBuilder.Entity<CustomerProfile>(builder =>
{
builder.Property(c => c.DefaultEmergencyContactName).HasConversion(encrypted);
builder.Property(c => c.DefaultEmergencyContactPhone).HasConversion(encrypted);
});
modelBuilder.Entity<Patient>(builder =>
{
builder.Property(p => p.InitialMedicalNotes).HasConversion(encrypted);
});
modelBuilder.Entity<NurseBankAccount>(builder =>
{
builder.Property(a => a.AccountHolderName).HasConversion(encrypted);
builder.Property(a => a.Iban).HasConversion(encrypted);
});
}
}
@@ -0,0 +1,22 @@
using Baya.Domain.Entities.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.IdentityConfig;
internal sealed class CustomerProfileConfig : IEntityTypeConfiguration<CustomerProfile>
{
public void Configure(EntityTypeBuilder<CustomerProfile> builder)
{
builder.ToTable("CustomerProfiles", "usr");
// Emergency-contact columns are encrypted at rest (converter wired in ApplicationDbContext) —
// left as nvarchar(max) since ciphertext is longer than the plaintext it carries.
builder.HasIndex(c => c.UserId).IsUnique();
builder.HasOne(c => c.User)
.WithOne()
.HasForeignKey<CustomerProfile>(c => c.UserId)
.IsRequired();
}
}
@@ -0,0 +1,43 @@
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.IdentityConfig;
internal sealed class NurseBankAccountConfig : IEntityTypeConfiguration<NurseBankAccount>
{
public void Configure(EntityTypeBuilder<NurseBankAccount> builder)
{
builder.ToTable("NurseBankAccounts", "usr");
builder.Property(a => a.BankName).HasMaxLength(100);
builder.Property(a => a.IbanHash).HasMaxLength(64).IsRequired();
builder.Property(a => a.AccountHolderFromBank).HasMaxLength(200);
builder.Property(a => a.OwnershipVendorRef).HasMaxLength(200);
builder.Property(a => a.IsPrimary).HasDefaultValue(false);
builder.Property(a => a.IsVerified).HasDefaultValue(false);
// account_holder_name and iban are encrypted at rest (converters wired in ApplicationDbContext).
// One IBAN can't silently serve two nurses — the authoritative duplicate backstop.
builder.HasIndex(a => a.IbanHash).IsUnique();
// Exactly one primary account per nurse — the filtered-unique backstop the set-primary
// transaction must never trip.
builder.HasIndex(a => a.NurseId)
.IsUnique()
.HasFilter("[IsPrimary] = 1")
.HasDatabaseName("UX_NurseBankAccounts_NurseId_Primary");
builder.HasOne(a => a.Nurse)
.WithMany(n => n.BankAccounts)
.HasForeignKey(a => a.NurseId)
.IsRequired();
builder.HasOne<User>()
.WithMany()
.HasForeignKey(a => a.VerifiedByAdminId)
.IsRequired(false);
}
}
@@ -0,0 +1,34 @@
using Baya.Domain.Entities.Identity;
using Baya.Domain.Entities.User;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.IdentityConfig;
internal sealed class NurseProfileConfig : IEntityTypeConfiguration<NurseProfile>
{
public void Configure(EntityTypeBuilder<NurseProfile> builder)
{
builder.ToTable("NurseProfiles", "usr");
builder.Property(p => p.Bio).HasMaxLength(2000);
builder.Property(p => p.EducationLevel).HasMaxLength(100);
builder.Property(p => p.EducationField).HasMaxLength(150);
builder.Property(p => p.IsVerified).HasDefaultValue(false);
builder.Property(p => p.IsAcceptingBookings).HasDefaultValue(false);
// Read-only quality aggregates — default 0, recomputed by reviews/bookings phases.
builder.Property(p => p.AverageRating).HasPrecision(3, 2).HasDefaultValue(0m);
builder.Property(p => p.TotalReviews).HasDefaultValue(0);
builder.Property(p => p.TotalCompletedBookings).HasDefaultValue(0);
// 1:1 with the owning user.
builder.HasIndex(p => p.UserId).IsUnique();
builder.HasOne(p => p.User)
.WithOne()
.HasForeignKey<NurseProfile>(p => p.UserId)
.IsRequired();
builder.HasQueryFilter(p => p.DeletedAt == null);
}
}
@@ -0,0 +1,29 @@
using Baya.Domain.Entities.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Baya.Infrastructure.Persistence.Configuration.IdentityConfig;
internal sealed class PatientConfig : IEntityTypeConfiguration<Patient>
{
public void Configure(EntityTypeBuilder<Patient> builder)
{
builder.ToTable("Patients", "usr");
builder.Property(p => p.DisplayName).HasMaxLength(200);
builder.Property(p => p.FirstName).HasMaxLength(100);
builder.Property(p => p.LastName).HasMaxLength(100);
builder.Property(p => p.Gender).HasMaxLength(10).IsRequired();
builder.Property(p => p.BloodType).HasMaxLength(10);
builder.Property(p => p.IsActive).HasDefaultValue(true);
// initial_medical_notes is encrypted at rest (converter wired in ApplicationDbContext).
// Tenancy anchor: every list/get is scoped by CustomerId.
builder.HasIndex(p => p.CustomerId);
builder.HasOne(p => p.Customer)
.WithMany(c => c.Patients)
.HasForeignKey(p => p.CustomerId)
.IsRequired();
}
}
@@ -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");
@@ -9,6 +9,10 @@ public class UnitOfWork : IUnitOfWork
public IUserRefreshTokenRepository UserRefreshTokenRepository { get; }
public IUserSessionRepository UserSessionRepository { get; }
public IUserAccountRepository UserAccountRepository { get; }
public INurseProfileRepository NurseProfileRepository { get; }
public ICustomerProfileRepository CustomerProfileRepository { get; }
public IPatientRepository PatientRepository { get; }
public INurseBankAccountRepository NurseBankAccountRepository { get; }
public UnitOfWork(ApplicationDbContext db)
{
@@ -16,9 +20,13 @@ public class UnitOfWork : IUnitOfWork
UserRefreshTokenRepository = new UserRefreshTokenRepository(_db);
UserSessionRepository = new UserSessionRepository(_db);
UserAccountRepository = new UserAccountRepository(_db);
NurseProfileRepository = new NurseProfileRepository(_db);
CustomerProfileRepository = new CustomerProfileRepository(_db);
PatientRepository = new PatientRepository(_db);
NurseBankAccountRepository = new NurseBankAccountRepository(_db);
}
public Task CommitAsync()
public Task CommitAsync()
{
return _db.SaveChangesAsync();
}
@@ -28,4 +36,4 @@ public class UnitOfWork : IUnitOfWork
_db.ChangeTracker.Clear();
return ValueTask.CompletedTask;
}
}
}
@@ -0,0 +1,32 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class CustomerProfileRepository : BaseAsyncRepository<CustomerProfile>, ICustomerProfileRepository
{
public CustomerProfileRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task<CustomerProfile> GetByUserIdAsync(int userId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(c => c.UserId == userId, cancellationToken);
public Task AddAsync(CustomerProfile profile, CancellationToken cancellationToken)
=> base.AddAsync(profile);
public Task<CustomerProfileDto> GetMineAsync(int userId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(c => c.UserId == userId)
.Select(c => new CustomerProfileDto(c.Id, c.DefaultEmergencyContactName, c.DefaultEmergencyContactPhone))
.FirstOrDefaultAsync(cancellationToken);
public Task<long?> GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(c => c.UserId == userId)
.Select(c => (long?)c.Id)
.FirstOrDefaultAsync(cancellationToken);
}
@@ -0,0 +1,59 @@
using Baya.Application.Common;
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class NurseBankAccountRepository : BaseAsyncRepository<NurseBankAccount>, INurseBankAccountRepository
{
public NurseBankAccountRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(NurseBankAccount account, CancellationToken cancellationToken)
=> base.AddAsync(account);
public Task<NurseBankAccount> GetOwnedAsync(long id, long nurseId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(a => a.Id == id && a.NurseId == nurseId, cancellationToken);
public async Task SetPrimaryAsync(long nurseId, long accountId, CancellationToken cancellationToken)
{
// Clear-then-set inside one transaction: two ordered statements so the filtered unique index is
// never momentarily violated (setting the new primary while the old one is still primary).
await using var transaction = await DbContext.Database.BeginTransactionAsync(cancellationToken);
await Entities
.Where(a => a.NurseId == nurseId && a.IsPrimary)
.ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsPrimary, false), cancellationToken);
await Entities
.Where(a => a.Id == accountId && a.NurseId == nurseId)
.ExecuteUpdateAsync(setters => setters.SetProperty(a => a.IsPrimary, true), cancellationToken);
await transaction.CommitAsync(cancellationToken);
}
public Task<bool> IbanHashExistsAsync(string ibanHash, CancellationToken cancellationToken)
=> TableNoTracking.AnyAsync(a => a.IbanHash == ibanHash, cancellationToken);
public Task<bool> HasAnyAsync(long nurseId, CancellationToken cancellationToken)
=> TableNoTracking.AnyAsync(a => a.NurseId == nurseId, cancellationToken);
public async Task<IReadOnlyList<NurseBankAccountDto>> ListAsync(long nurseId, CancellationToken cancellationToken)
{
// Decrypt the IBAN in memory, then mask to last-4 — the full value never leaves the repository.
var rows = await TableNoTracking
.Where(a => a.NurseId == nurseId)
.OrderByDescending(a => a.IsPrimary)
.ThenByDescending(a => a.Id)
.Select(a => new { a.Id, a.BankName, a.Iban, a.IsPrimary, a.IsVerified, a.MatchedNationalId })
.ToListAsync(cancellationToken);
return rows
.Select(a => new NurseBankAccountDto(a.Id, a.BankName, Mask.IbanTail(a.Iban), a.IsPrimary, a.IsVerified, a.MatchedNationalId))
.ToList();
}
}
@@ -0,0 +1,49 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class NurseProfileRepository : BaseAsyncRepository<NurseProfile>, INurseProfileRepository
{
public NurseProfileRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task<NurseProfile> GetByUserIdAsync(int userId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(p => p.UserId == userId, cancellationToken);
public Task AddAsync(NurseProfile profile, CancellationToken cancellationToken)
=> base.AddAsync(profile);
public Task<NurseProfileDto> GetMineAsync(int userId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(p => p.UserId == userId)
.Select(p => new NurseProfileDto(
p.Id,
p.Bio,
p.YearsOfExperience,
p.EducationLevel,
p.EducationField,
p.SpecializationsJson,
p.IsVerified,
p.IsAcceptingBookings,
p.AverageRating,
p.TotalReviews,
p.TotalCompletedBookings))
.FirstOrDefaultAsync(cancellationToken);
public Task<long?> GetProfileIdByUserIdAsync(int userId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(p => p.UserId == userId)
.Select(p => (long?)p.Id)
.FirstOrDefaultAsync(cancellationToken);
public Task<NurseIdentityContext> GetIdentityContextByUserIdAsync(int userId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(p => p.UserId == userId)
.Select(p => new NurseIdentityContext(p.Id, p.User.NationalId))
.FirstOrDefaultAsync(cancellationToken);
}
@@ -0,0 +1,60 @@
using Baya.Application.Contracts.Persistence;
using Baya.Application.Models.Common;
using Baya.Application.Models.Identity;
using Baya.Domain.Entities.Identity;
using Baya.Infrastructure.Persistence.Repositories.Common;
using Microsoft.EntityFrameworkCore;
namespace Baya.Infrastructure.Persistence.Repositories;
internal sealed class PatientRepository : BaseAsyncRepository<Patient>, IPatientRepository
{
public PatientRepository(ApplicationDbContext dbContext) : base(dbContext)
{
}
public Task AddAsync(Patient patient, CancellationToken cancellationToken)
=> base.AddAsync(patient);
public Task<Patient> GetOwnedAsync(long id, long customerId, CancellationToken cancellationToken)
=> Table.FirstOrDefaultAsync(p => p.Id == id && p.CustomerId == customerId, cancellationToken);
public async Task<PagedResult<PatientDto>> ListAsync(long customerId, int page, int pageSize, CancellationToken cancellationToken)
{
var query = TableNoTracking.Where(p => p.CustomerId == customerId);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(p => p.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(p => new PatientDto(
p.Id,
p.DisplayName,
p.FirstName,
p.LastName,
p.BirthDate,
p.Gender,
p.BloodType,
p.InitialMedicalNotes,
p.IsActive))
.ToListAsync(cancellationToken);
return new PagedResult<PatientDto>(items, total, page, pageSize);
}
public Task<PatientDto> GetOwnedProjectedAsync(long id, long customerId, CancellationToken cancellationToken)
=> TableNoTracking
.Where(p => p.Id == id && p.CustomerId == customerId)
.Select(p => new PatientDto(
p.Id,
p.DisplayName,
p.FirstName,
p.LastName,
p.BirthDate,
p.Gender,
p.BloodType,
p.InitialMedicalNotes,
p.IsActive))
.FirstOrDefaultAsync(cancellationToken);
}