using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Diagnostics; using ROLAC.API.Entities.Base; using ROLAC.API.Entities.Logging; using ROLAC.API.Services.Logging; namespace ROLAC.API.Data.Interceptors; /// /// Writes a before→after row for every Create/Update/Delete of an /// entity. Two-phase: snapshot changed values BEFORE save (while /// original values are still available), then — AFTER save succeeds — read DB-generated keys and /// enqueue the rows. Enqueuing (rather than inserting here) avoids a second SaveChanges, can't /// fail the user's transaction, and never recurses through AppDbContext. /// public sealed class AuditLogInterceptor : SaveChangesInterceptor { private readonly SystemLogQueue _queue; private readonly CurrentUserAccessor _currentUser; private readonly List _pending = []; public AuditLogInterceptor(SystemLogQueue queue, CurrentUserAccessor currentUser) { _queue = queue; _currentUser = currentUser; } public override InterceptionResult SavingChanges( DbContextEventData eventData, InterceptionResult result) { Capture(eventData.Context); return base.SavingChanges(eventData, result); } public override ValueTask> SavingChangesAsync( DbContextEventData eventData, InterceptionResult result, CancellationToken cancellationToken = default) { Capture(eventData.Context); return base.SavingChangesAsync(eventData, result, cancellationToken); } public override int SavedChanges(SaveChangesCompletedEventData eventData, int result) { Flush(); return base.SavedChanges(eventData, result); } public override ValueTask SavedChangesAsync( SaveChangesCompletedEventData eventData, int result, CancellationToken cancellationToken = default) { Flush(); return base.SavedChangesAsync(eventData, result, cancellationToken); } public override void SaveChangesFailed(DbContextErrorEventData eventData) => _pending.Clear(); public override Task SaveChangesFailedAsync( DbContextErrorEventData eventData, CancellationToken cancellationToken = default) { _pending.Clear(); return Task.CompletedTask; } // ── Phase 1: snapshot before save ───────────────────────────────────────── private void Capture(DbContext? db) { if (db is null) return; foreach (var entry in db.ChangeTracker.Entries()) { if (entry.Entity is not IAuditable) continue; switch (entry.State) { case EntityState.Added: _pending.Add(new PendingAudit(entry, AuditActions.Create, null, BuildValues(entry, current: true))); break; case EntityState.Deleted: _pending.Add(new PendingAudit(entry, AuditActions.Delete, BuildValues(entry, current: false), null)); break; case EntityState.Modified: var before = new Dictionary(); var after = new Dictionary(); foreach (var property in entry.Properties) { if (!property.IsModified) continue; var name = property.Metadata.Name; before[name] = Read(name, property.OriginalValue); after[name] = Read(name, property.CurrentValue); } if (after.Count == 0) break; // no real change (e.g. only audit timestamps touched on a no-op) // A soft-delete (IsDeleted false→true) reads more naturally as a Delete. var action = IsSoftDelete(after) ? AuditActions.Delete : AuditActions.Update; _pending.Add(new PendingAudit(entry, action, before, after)); break; } } } // ── Phase 2: keys exist, enqueue ────────────────────────────────────────── private void Flush() { if (_pending.Count == 0) return; var userId = _currentUser.UserId; var userEmail = _currentUser.Email; var ip = _currentUser.IpAddress; var corr = _currentUser.CorrelationId; foreach (var item in _pending) { _queue.TryEnqueue(new AuditLog { Timestamp = DateTimeOffset.UtcNow, Level = LogLevelEnum.Information, Action = item.Action, Category = AuditCategories.DataChange, EntityName = item.Entry.Metadata.ClrType.Name, EntityId = ReadKey(item.Entry), Changes = AuditChangeSerializer.BuildChanges(item.Before, item.After), UserId = userId, UserEmail = userEmail, IpAddress = ip, CorrelationId = corr, }); } _pending.Clear(); } private static Dictionary BuildValues(EntityEntry entry, bool current) { var values = new Dictionary(); foreach (var property in entry.Properties) { if (property.Metadata.IsPrimaryKey()) continue; var name = property.Metadata.Name; values[name] = Read(name, current ? property.CurrentValue : property.OriginalValue); } return values; } private static object? Read(string propertyName, object? value) => AuditChangeSerializer.IsSensitive(propertyName) ? AuditChangeSerializer.MaskValue : value; private static bool IsSoftDelete(Dictionary after) => after.TryGetValue("IsDeleted", out var value) && value is true; private static string? ReadKey(EntityEntry entry) { var key = entry.Metadata.FindPrimaryKey(); if (key is null) return null; var parts = key.Properties .Select(p => entry.Property(p.Name).CurrentValue?.ToString()) .Where(v => v is not null); return string.Join(",", parts); } private sealed record PendingAudit( EntityEntry Entry, string Action, Dictionary? Before, Dictionary? After); }