using System.Reflection; using Baya.Application.Contracts.Common; using Baya.Domain.Common; using Baya.Domain.Entities.Booking; using Baya.Domain.Entities.Identity; using Baya.Domain.Entities.Payments; using Baya.Domain.Entities.User; using Baya.Domain.Entities.Verification; using Baya.Infrastructure.Persistence.ValueConversion; using Baya.SharedKernel.Extensions; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace Baya.Infrastructure.Persistence; public class ApplicationDbContext: IdentityDbContext { private readonly IFieldEncryptor _fieldEncryptor; // The encryptor ends up captured inside the cached EF model (value converters), so it must be a // process-wide singleton with stable keys — which is how the seam is registered. public ApplicationDbContext(DbContextOptions options, IFieldEncryptor fieldEncryptor) : base(options) { _fieldEncryptor = fieldEncryptor; base.SavingChanges += OnSavingChanges; } private void OnSavingChanges(object sender, SavingChangesEventArgs e) { _cleanString(); _syncUserPhoneIntegrity(); } /// /// Keeps the deterministic PhoneHash lookup column in step with the encrypted phone, and /// enforces the product rule that a phone change invalidates the Shahkar phone↔national-id binding /// (reset to NULL so b6 re-verifies) — centrally, so no handler can forget it. /// private void _syncUserPhoneIntegrity() { foreach (var entry in ChangeTracker.Entries()) { if (entry.State == EntityState.Added) { entry.Entity.PhoneHash = _fieldEncryptor.Hash(entry.Entity.PhoneNumber); } else if (entry.State == EntityState.Modified) { var phone = entry.Property(u => u.PhoneNumber); if (!string.Equals(phone.OriginalValue, phone.CurrentValue, StringComparison.Ordinal)) { entry.Entity.PhoneHash = _fieldEncryptor.Hash(phone.CurrentValue); entry.Entity.ShahkarVerifiedAt = null; } } } } private void _cleanString() { var changedEntities = ChangeTracker.Entries() .Where(x => x.State == EntityState.Added || x.State == EntityState.Modified); foreach (var item in changedEntities) { if (item.Entity == null) continue; var properties = item.Entity.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance) .Where(p => p.CanRead && p.CanWrite && p.PropertyType == typeof(string)); foreach (var property in properties) { var propName = property.Name; var val = (string)property.GetValue(item.Entity, null); if (val.HasValue()) { var newVal = val.Fa2En().FixPersianChars(); if (newVal == val) continue; property.SetValue(item.Entity, newVal, null); } } } } protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); var entitiesAssembly = typeof(IEntity).Assembly; modelBuilder.RegisterAllEntities(entitiesAssembly); modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly); modelBuilder.AddRestrictDeleteBehaviorConvention(); modelBuilder.AddPluralizingTableNameConvention(); // PII encrypted at rest via the seam. These columns are equality-unqueryable by design — // phone lookups use PhoneHash. Applied here (not in UserConfig) because the converter needs // the encryptor instance. var encrypted = new EncryptedStringConverter(_fieldEncryptor); modelBuilder.Entity(builder => { builder.Property(u => u.PhoneNumber).HasConversion(encrypted); builder.Property(u => u.Email).HasConversion(encrypted); 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(builder => { builder.Property(c => c.DefaultEmergencyContactName).HasConversion(encrypted); builder.Property(c => c.DefaultEmergencyContactPhone).HasConversion(encrypted); }); modelBuilder.Entity(builder => { builder.Property(p => p.InitialMedicalNotes).HasConversion(encrypted); }); modelBuilder.Entity(builder => { builder.Property(a => a.AccountHolderName).HasConversion(encrypted); builder.Property(a => a.Iban).HasConversion(encrypted); }); // b4 address PII: street line, postal code and recipient contact are encrypted at rest through // the same seam; the title label and coordinates are not PII and stay plaintext. modelBuilder.Entity(builder => { builder.Property(a => a.AddressLine).HasConversion(encrypted); builder.Property(a => a.PostalCode).HasConversion(encrypted); builder.Property(a => a.RecipientName).HasConversion(encrypted); builder.Property(a => a.RecipientPhone).HasConversion(encrypted); }); // b6 credential PII: the license/membership number is encrypted at rest through the same seam and // is never serialized on the wire (the trust badge exposes credential types, never numbers). modelBuilder.Entity(builder => { builder.Property(c => c.CredentialNumber).HasConversion(encrypted); }); // b9 snapshot + clinical PII: the frozen address snapshot and every booking_care_instructions field // are encrypted at rest through the same seam. The care fields are the stage-2 clinical disclosure — // decrypted only in the gated care-instructions read (assigned nurse + admin, post-confirmation). modelBuilder.Entity(builder => { builder.Property(b => b.AddressSnapshotJson).HasConversion(encrypted); }); modelBuilder.Entity(builder => { builder.Property(c => c.CurrentConditions).HasConversion(encrypted); builder.Property(c => c.Medications).HasConversion(encrypted); builder.Property(c => c.Allergies).HasConversion(encrypted); builder.Property(c => c.SpecialInstructions).HasConversion(encrypted); builder.Property(c => c.EmergencyContactName).HasConversion(encrypted); builder.Property(c => c.EmergencyContactPhone).HasConversion(encrypted); }); // b10 gateway config: provider-selection/failover config (merchant id, terminal/IBAN registration, // base url, sandbox flag) is encrypted at rest through the same seam and never logged in plaintext. modelBuilder.Entity(builder => { builder.Property(g => g.ConfigJson).HasConversion(encrypted); }); // b13 payout snapshot: the nurse's IBAN is frozen onto each payout at build time and encrypted at rest // through the same seam. Reads mask it to the last 4 digits — the plaintext IBAN is never serialized. modelBuilder.Entity(builder => { builder.Property(p => p.IbanSnapshot).HasConversion(encrypted); }); // b15 partner-center settlement account: the center's IBAN (only when merchant-of-record) is encrypted // at rest through the same seam and never serialized in plaintext — reads mask it to the last 4 digits. modelBuilder.Entity(builder => { builder.Property(c => c.SettlementIban).HasConversion(encrypted); }); // refinement-phase-9 §9.5: ticket message bodies are the refund/dispute paper trail — users type phone // numbers, addresses and clinical detail into them — so they are encrypted at rest through the same seam. // The plaintext-length limit (4000) stays a boundary-validation rule; the stored ciphertext column is // widened to nvarchar(max) in TicketMessageConfig. Body is never a search/filter predicate (the admin // thread read decrypts per row), so losing SQL-searchability on it is an accepted trade-off. modelBuilder.Entity(builder => { builder.Property(m => m.Body).HasConversion(encrypted); }); } }