Files
baya-monorepo/server/src/Infrastructure/Baya.Infrastructure.Persistence/ApplicationDbContext.cs
T
2026-07-13 22:52:57 +03:30

193 lines
9.0 KiB
C#

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<User, Role, int, UserClaim, UserRole, UserLogin, RoleClaim, UserToken>
{
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();
}
/// <summary>
/// Keeps the deterministic <c>PhoneHash</c> 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.
/// </summary>
private void _syncUserPhoneIntegrity()
{
foreach (var entry in ChangeTracker.Entries<User>())
{
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<IEntity>(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<User>(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<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);
});
// 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<CustomerAddress>(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<NurseCredential>(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<Booking>(builder =>
{
builder.Property(b => b.AddressSnapshotJson).HasConversion(encrypted);
});
modelBuilder.Entity<BookingCareInstruction>(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<PaymentGateway>(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<Baya.Domain.Entities.Payouts.NursePayout>(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<Baya.Domain.Entities.PartnerCenters.PartnerCenter>(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<Baya.Domain.Entities.Messaging.TicketMessage>(builder =>
{
builder.Property(m => m.Body).HasConversion(encrypted);
});
}
}