using System.Data; using System.Runtime.CompilerServices; using System.Text.Json; using Microsoft.AspNetCore.Http; using Microsoft.Data.SqlClient; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Logging; using PowderCoating.Core.Entities; namespace PowderCoating.Infrastructure.Data; /// /// EF Core SaveChanges interceptor that writes an AuditLog row for every /// meaningful create / update / soft-delete on tracked entities. /// Writes via raw SQL so it never re-triggers itself. /// public class AuditInterceptor : SaveChangesInterceptor { // Entity types we care about auditing private static readonly HashSet AuditedTypes = new(StringComparer.Ordinal) { nameof(Customer), nameof(Job), nameof(Quote), nameof(Equipment), nameof(MaintenanceRecord), nameof(Vendor), nameof(InventoryItem), nameof(Company), // Financial entities nameof(Invoice), nameof(Payment), nameof(Bill), nameof(BillPayment), nameof(Deposit), nameof(CreditMemo), nameof(GiftCertificate), nameof(Expense), nameof(PurchaseOrder), // Operations nameof(Appointment), nameof(InventoryTransaction), nameof(JobTemplate), // User management nameof(ApplicationUser) }; // Properties to always exclude from change capture (noisy / binary) private static readonly HashSet ExcludedProperties = new(StringComparer.Ordinal) { "LogoData", "ProfilePictureData", "Settings", "UpdatedAt", "CreatedAt" }; // Per-context pending audit entries (ConditionalWeakTable gives us automatic cleanup) private readonly ConditionalWeakTable> _pending = new(); private readonly IHttpContextAccessor _httpContextAccessor; private readonly ILogger _logger; public AuditInterceptor(IHttpContextAccessor httpContextAccessor, ILogger logger) { _httpContextAccessor = httpContextAccessor; _logger = logger; } // ── Collect entries BEFORE save (so we can capture OriginalValues) ────────── public override InterceptionResult SavingChanges(DbContextEventData eventData, InterceptionResult result) { Collect(eventData.Context); return base.SavingChanges(eventData, result); } public override ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { Collect(eventData.Context); return base.SavingChangesAsync(eventData, result, cancellationToken); } // ── Write entries AFTER save (entity IDs are populated now) ───────────────── public override int SavedChanges(SaveChangesCompletedEventData eventData, int result) { FlushSync(eventData.Context); return base.SavedChanges(eventData, result); } public override async ValueTask SavedChangesAsync( SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default) { await FlushAsync(eventData.Context, cancellationToken); return await base.SavedChangesAsync(eventData, result, cancellationToken); } // ──────────────────────────────────────────────────────────────────────────── private void Collect(DbContext? context) { if (context == null) return; var entries = BuildEntries(context); if (entries.Count == 0) return; // Remove any stale entries left by a previous failed SaveChanges on this context. // Without this, EF Core's EnableRetryOnFailure would trigger a second SavingChanges // on the same DbContext instance, causing _pending.Add to throw ArgumentException. _pending.Remove(context); _pending.Add(context, entries); } private void FlushSync(DbContext? context) { if (context == null || !_pending.TryGetValue(context, out var entries)) return; _pending.Remove(context); foreach (var log in entries) { try { InsertSync(context, log); } catch (Exception ex) { _logger.LogError(ex, "Failed to write audit log entry for {Action} on {EntityType} {EntityId}", log.Action, log.EntityType, log.EntityId); } } } private async Task FlushAsync(DbContext? context, CancellationToken ct) { if (context == null || !_pending.TryGetValue(context, out var entries)) return; _pending.Remove(context); foreach (var log in entries) { try { await InsertAsync(context, log, ct); } catch (Exception ex) { _logger.LogError(ex, "Failed to write audit log entry for {Action} on {EntityType} {EntityId}", log.Action, log.EntityType, log.EntityId); } } } // ── Build AuditLog objects from change tracker ─────────────────────────────── private List BuildEntries(DbContext context) { var logs = new List(); var (userId, userName, companyId, ip) = GetRequestContext(); foreach (var entry in context.ChangeTracker.Entries()) { var typeName = entry.Entity.GetType().Name; if (!AuditedTypes.Contains(typeName)) continue; string action; string? oldJson = null; string? newJson = null; switch (entry.State) { case EntityState.Added: action = "Created"; newJson = SerializeProperties(entry.CurrentValues, null); break; case EntityState.Modified: // Detect soft-delete vs plain update var isDeletedProp = entry.Properties.FirstOrDefault(p => p.Metadata.Name == "IsDeleted"); if (isDeletedProp != null && isDeletedProp.CurrentValue is true && isDeletedProp.OriginalValue is false) { action = "Deleted"; } else if (isDeletedProp != null && isDeletedProp.CurrentValue is false && isDeletedProp.OriginalValue is true) { action = "Restored"; } else { action = "Updated"; } oldJson = SerializeProperties(entry.OriginalValues, entry.CurrentValues); newJson = SerializeProperties(entry.CurrentValues, entry.OriginalValues); break; case EntityState.Deleted: action = "Deleted"; oldJson = SerializeProperties(entry.OriginalValues, null); break; default: continue; } // Entity ID — try "Id" property var idProp = entry.Properties.FirstOrDefault(p => p.Metadata.Name == "Id"); var entityId = idProp?.CurrentValue?.ToString(); // Entity description — try common naming properties var description = GetDescription(entry); // Company ID on the entity itself var entityCompanyProp = entry.Properties.FirstOrDefault(p => p.Metadata.Name == "CompanyId"); var entityCompanyId = entityCompanyProp?.CurrentValue as int? ?? companyId; logs.Add(new AuditLog { UserId = userId, UserName = userName, CompanyId = entityCompanyId, Action = action, EntityType = typeName, EntityId = entityId, EntityDescription = description, OldValues = oldJson, NewValues = newJson, IpAddress = ip, Timestamp = DateTime.UtcNow }); } return logs; } private static string? SerializeProperties(Microsoft.EntityFrameworkCore.ChangeTracking.PropertyValues values, Microsoft.EntityFrameworkCore.ChangeTracking.PropertyValues? compareAgainst) { var dict = new Dictionary(); foreach (var prop in values.Properties) { if (ExcludedProperties.Contains(prop.Name)) continue; var current = values[prop]; // When comparing, only include changed properties if (compareAgainst != null) { var other = compareAgainst[prop]; if (Equals(current, other)) continue; } dict[prop.Name] = current; } return dict.Count == 0 ? null : JsonSerializer.Serialize(dict); } private static string? GetDescription(Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry entry) { // Try common "name" properties in priority order string[] candidates = ["JobNumber", "QuoteNumber", "InvoiceNumber", "BillNumber", "PaymentNumber", "ReceiptNumber", "CertificateNumber", "Email", "CompanyName", "Name", "EquipmentName", "ItemName"]; foreach (var name in candidates) { var prop = entry.Properties.FirstOrDefault(p => p.Metadata.Name == name); if (prop?.CurrentValue is string s && !string.IsNullOrWhiteSpace(s)) return s; } return null; } // ── Raw SQL insert (bypasses change tracker → no recursion) ───────────────── private static void InsertSync(DbContext context, AuditLog log) { context.Database.ExecuteSqlRaw(InsertSql, BuildParams(log)); } private static async Task InsertAsync(DbContext context, AuditLog log, CancellationToken ct) { await context.Database.ExecuteSqlRawAsync(InsertSql, BuildParams(log), ct); } private static SqlParameter[] BuildParams(AuditLog log) => [ new SqlParameter("@userId", SqlDbType.NVarChar, -1) { Value = (object?)log.UserId ?? DBNull.Value }, new SqlParameter("@userName", SqlDbType.NVarChar, -1) { Value = (object)log.UserName }, new SqlParameter("@companyId", SqlDbType.Int) { Value = (object?)log.CompanyId ?? DBNull.Value }, new SqlParameter("@companyName", SqlDbType.NVarChar, -1) { Value = (object?)log.CompanyName ?? DBNull.Value }, new SqlParameter("@action", SqlDbType.NVarChar, -1) { Value = (object)log.Action }, new SqlParameter("@entityType", SqlDbType.NVarChar, -1) { Value = (object)log.EntityType }, new SqlParameter("@entityId", SqlDbType.NVarChar, -1) { Value = (object?)log.EntityId ?? DBNull.Value }, new SqlParameter("@entityDescription", SqlDbType.NVarChar, -1) { Value = (object?)log.EntityDescription ?? DBNull.Value }, new SqlParameter("@oldValues", SqlDbType.NVarChar, -1) { Value = (object?)log.OldValues ?? DBNull.Value }, new SqlParameter("@newValues", SqlDbType.NVarChar, -1) { Value = (object?)log.NewValues ?? DBNull.Value }, new SqlParameter("@ipAddress", SqlDbType.NVarChar, -1) { Value = (object?)log.IpAddress ?? DBNull.Value }, new SqlParameter("@timestamp", SqlDbType.DateTime2) { Value = (object)log.Timestamp }, ]; private const string InsertSql = """ INSERT INTO AuditLogs (UserId, UserName, CompanyId, CompanyName, Action, EntityType, EntityId, EntityDescription, OldValues, NewValues, IpAddress, Timestamp) VALUES (@userId, @userName, @companyId, @companyName, @action, @entityType, @entityId, @entityDescription, @oldValues, @newValues, @ipAddress, @timestamp) """; // ── Request context helpers ─────────────────────────────────────────────────── private (string? userId, string userName, int? companyId, string? ip) GetRequestContext() { var ctx = _httpContextAccessor.HttpContext; if (ctx == null) return (null, "System", null, null); var user = ctx.User; var userId = user.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; var userName = user.Identity?.Name ?? user.FindFirst(System.Security.Claims.ClaimTypes.Email)?.Value ?? "Unknown"; var companyIdStr = user.FindFirst("CompanyId")?.Value; int? companyId = companyIdStr != null && int.TryParse(companyIdStr, out var cid) ? cid : null; var ip = ctx.Connection.RemoteIpAddress?.ToString(); return (userId, userName, companyId, ip); } }