1a44133a63
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>
294 lines
13 KiB
C#
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);
|
|
}
|
|
}
|