using foodmarket.Application.Common.Tenancy; using foodmarket.Domain.Catalog; using foodmarket.Domain.Common; using foodmarket.Domain.Inventory; using foodmarket.Domain.Purchases; using foodmarket.Domain.Sales; using foodmarket.Infrastructure.Identity; using foodmarket.Domain.Organizations; using foodmarket.Infrastructure.Persistence.Configurations; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace foodmarket.Infrastructure.Persistence; public class AppDbContext : IdentityDbContext { private readonly ITenantContext _tenant; public AppDbContext(DbContextOptions options, ITenantContext tenant) : base(options) { _tenant = tenant; } public DbSet Organizations => Set(); public DbSet Countries => Set(); public DbSet Currencies => Set(); public DbSet UnitsOfMeasure => Set(); public DbSet Counterparties => Set(); public DbSet Stores => Set(); public DbSet RetailPoints => Set(); public DbSet ProductGroups => Set(); public DbSet PriceTypes => Set(); public DbSet Products => Set(); public DbSet ProductPrices => Set(); public DbSet ProductBarcodes => Set(); public DbSet ProductImages => Set(); public DbSet Stocks => Set(); public DbSet StockMovements => Set(); public DbSet Supplies => Set(); public DbSet SupplyLines => Set(); public DbSet RetailSales => Set(); public DbSet RetailSaleLines => Set(); public DbSet EmployeeRoles => Set(); public DbSet Employees => Set(); public DbSet EmployeeRetailPointAssignments => Set(); public DbSet SuperAdminAuditLogs => Set(); protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); builder.HasDefaultSchema("public"); // OpenIddict EF Core tables builder.UseOpenIddict(); // Identity table renaming for clarity builder.Entity(b => { b.ToTable("users"); b.Property(u => u.FullName).HasMaxLength(200); }); builder.Entity(b => b.ToTable("roles")); builder.Entity(b => { b.ToTable("organizations"); b.Property(o => o.Name).HasMaxLength(200).IsRequired(); b.Property(o => o.CountryCode).HasMaxLength(2).IsRequired(); b.Property(o => o.Bin).HasMaxLength(20); b.Property(o => o.MoySkladToken).HasMaxLength(200); b.HasOne(o => o.DefaultCurrency).WithMany().HasForeignKey(o => o.DefaultCurrencyId).OnDelete(DeleteBehavior.Restrict); b.HasIndex(o => o.Name); b.HasIndex(o => o.IsArchived); }); builder.Entity(b => { b.ToTable("super_admin_audit_log"); b.Property(x => x.ActionType).HasMaxLength(50).IsRequired(); b.Property(x => x.EntityType).HasMaxLength(100); b.Property(x => x.Description).HasMaxLength(500); b.Property(x => x.Reason).HasMaxLength(1000); b.Property(x => x.ChangesJson).HasColumnType("jsonb"); b.Property(x => x.IpAddress).HasMaxLength(45); b.HasIndex(x => x.CreatedAt); b.HasIndex(x => new { x.SuperAdminUserId, x.CreatedAt }); b.HasIndex(x => new { x.OrganizationId, x.CreatedAt }); }); builder.ConfigureCatalog(); builder.ConfigureInventory(); builder.ConfigurePurchases(); builder.ConfigureSales(); builder.ConfigureOrganizationsHr(); // Apply multi-tenant query filter to every entity that implements ITenantEntity. // IOptionalTenantEntity (системные справочники с nullable OrganizationId) — // через отдельный фильтр, который пропускает запись с NULL для всех. foreach (var entityType in builder.Model.GetEntityTypes()) { if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType)) { var method = typeof(AppDbContext) .GetMethod(nameof(ApplyTenantFilter), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! .MakeGenericMethod(entityType.ClrType); method.Invoke(this, new object[] { builder }); } else if (typeof(IOptionalTenantEntity).IsAssignableFrom(entityType.ClrType)) { var method = typeof(AppDbContext) .GetMethod(nameof(ApplyOptionalTenantFilter), System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)! .MakeGenericMethod(entityType.ClrType); method.Invoke(this, new object[] { builder }); } } } private void ApplyTenantFilter(ModelBuilder builder) where T : class, ITenantEntity { // SuperAdmin обходит фильтр ТОЛЬКО когда не в режиме «открыть как…». // В override-режиме (X-Org-Override header активен) он работает в // контексте конкретной орги — фильтр обязан применяться, иначе // возвращаются записи всех орг и нарушается tenant isolation. builder.Entity().HasQueryFilter(e => (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride) || e.OrganizationId == _tenant.OrganizationId); } private void ApplyOptionalTenantFilter(ModelBuilder builder) where T : class, IOptionalTenantEntity { // Системные записи (OrganizationId == null) видны ВСЕМ tenant'ам как // эталонные. Tenant'овские (свои OrganizationId) — обычная изоляция. // SuperAdmin без override видит всё. builder.Entity().HasQueryFilter(e => (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride) || e.OrganizationId == null || e.OrganizationId == _tenant.OrganizationId); } public override int SaveChanges() { StampTenant(); StampTimestamps(); return base.SaveChanges(); } public override Task SaveChangesAsync(CancellationToken ct = default) { StampTenant(); StampTimestamps(); return base.SaveChangesAsync(ct); } private void StampTenant() { foreach (var entry in ChangeTracker.Entries()) { if (entry.State != EntityState.Added) continue; if (entry.Entity is ITenantEntity tenant && tenant.OrganizationId == Guid.Empty) { if (_tenant.OrganizationId.HasValue) tenant.OrganizationId = _tenant.OrganizationId.Value; } else if (entry.Entity is IOptionalTenantEntity opt && opt.OrganizationId is null) { // Если SuperAdmin создаёт запись «как пользователь» (override // активен), стампим выбранную орг. Если SuperAdmin без override // (системная консоль) — оставляем null (системная запись). // Tenant-юзер всегда стампит свой orgId. if (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride) { // null — системная запись; оставляем } else if (_tenant.OrganizationId.HasValue) { opt.OrganizationId = _tenant.OrganizationId.Value; } } } } private void StampTimestamps() { foreach (var entry in ChangeTracker.Entries()) { if (entry.State == EntityState.Added) entry.Entity.CreatedAt = DateTime.UtcNow; else if (entry.State == EntityState.Modified) entry.Entity.UpdatedAt = DateTime.UtcNow; } } }