Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Data/AuditInterceptor.cs
T
spouliot 1a44133a63 Remove ShopWorker entity and migrate worker identity to ApplicationUser
Removes the ShopWorker and ShopWorkerRoleCost entities, all related DTOs,
mappings, controllers, views, and import/export paths. Worker identity is
now handled entirely through ApplicationUser with per-user LaborCostPerHour.
ShopWorkerRoleCosts table remains in production pending manual data migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 20:32:32 -04:00

294 lines
13 KiB
C#

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;
/// <summary>
/// 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.
/// </summary>
public class AuditInterceptor : SaveChangesInterceptor
{
// Entity types we care about auditing
private static readonly HashSet<string> 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<string> ExcludedProperties = new(StringComparer.Ordinal)
{
"LogoData", "ProfilePictureData", "Settings", "UpdatedAt", "CreatedAt"
};
// Per-context pending audit entries (ConditionalWeakTable gives us automatic cleanup)
private readonly ConditionalWeakTable<DbContext, List<AuditLog>> _pending = new();
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<AuditInterceptor> _logger;
public AuditInterceptor(IHttpContextAccessor httpContextAccessor, ILogger<AuditInterceptor> logger)
{
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
// ── Collect entries BEFORE save (so we can capture OriginalValues) ──────────
public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
{
Collect(eventData.Context);
return base.SavingChanges(eventData, result);
}
public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
DbContextEventData eventData, InterceptionResult<int> 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<int> 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<AuditLog> BuildEntries(DbContext context)
{
var logs = new List<AuditLog>();
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<string, object?>();
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);
}
}