Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/DataPurgeController.cs
T
spouliot e6c4cfb38b Fix all FK constraint violations when purging soft-deleted Jobs
Before deleting Jobs, now:
- Deletes non-nullable child rows: ReworkRecords, PowderUsageLogs, OvenBatchItems
- Nulls out nullable FK refs: Invoices, Deposits, Appointments, BillLineItems,
  Expenses, InventoryTransactions
DB-cascade / SET NULL relationships (JobChangeHistory, JobStatusHistory,
JobTimeEntry, JobItems, JobNotes, JobPhotos, KioskSession, NotificationLog)
are excluded — the DB handles them automatically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 13:04:13 -04:00

421 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Text;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class DataPurgeController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IJobPhotoService _jobPhotoService;
private readonly ILogger<DataPurgeController> _logger;
public DataPurgeController(
ApplicationDbContext db,
IJobPhotoService jobPhotoService,
ILogger<DataPurgeController> logger)
{
_db = db;
_jobPhotoService = jobPhotoService;
_logger = logger;
}
// ── GET: Index ───────────────────────────────────────────────────────────
/// <summary>
/// Renders the data-purge dashboard showing, per entity type, how many
/// soft-deleted records are waiting to be permanently removed and how they
/// are distributed across age buckets (030 days, 3090 days, 90+ days).
/// Restricted to SuperAdmin only.
/// <para>
/// Stats are built by <see cref="BuildStatsAsync"/>. The age buckets help
/// administrators understand whether a purge operation will reclaim a lot of
/// space or only a handful of old records, and inform a sensible
/// <c>olderThanDays</c> cutoff before executing.
/// </para>
/// </summary>
public async Task<IActionResult> Index()
{
var stats = await BuildStatsAsync();
return View(stats);
}
// ── POST: Preview (AJAX) ─────────────────────────────────────────────────
/// <summary>
/// Returns a JSON preview of how many records would be permanently deleted if
/// <see cref="Execute"/> were called with the same parameters, without making any
/// changes to the database.
/// <para>
/// Called from the purge UI before the user confirms the destructive action,
/// allowing them to see the exact impact (record count and oldest deletion date)
/// for each selected entity type. The minimum cutoff is clamped to 1 day to
/// prevent accidental purges of records deleted moments ago.
/// </para>
/// </summary>
[HttpPost]
public async Task<IActionResult> Preview([FromBody] PurgeRequest req)
{
if (req.OlderThanDays < 1) req.OlderThanDays = 30;
var cutoff = DateTime.UtcNow.AddDays(-req.OlderThanDays);
var results = new List<object>();
foreach (var entity in req.Entities ?? [])
{
var (count, oldest) = await CountDeletableAsync(entity, cutoff);
results.Add(new { entity, count, oldest = oldest?.ToString("yyyy-MM-dd") });
}
return Json(results);
}
// ── POST: Execute ────────────────────────────────────────────────────────
/// <summary>
/// Permanently (hard) deletes all soft-deleted records of the selected entity
/// types that were deleted more than <paramref name="olderThanDays"/> days ago.
/// This operation is irreversible and restricted to SuperAdmin.
/// <para>
/// Key design decisions:
/// <list type="bullet">
/// <item>Entity types are processed in child-before-parent order (determined by
/// <see cref="OrderForDeletion"/>) to honour foreign-key constraints without
/// temporarily disabling them.</item>
/// <item><c>JobPhotos</c> are purged via <see cref="PurgeEntityAsync"/> which
/// calls <c>IJobPhotoService.DeleteJobPhotoAsync</c> to also remove the
/// corresponding files from storage before the DB row is deleted.</item>
/// <item>All DB deletions are batched into a single <c>SaveChangesAsync()</c>
/// call at the end (rather than per entity) for performance; this means
/// if <c>SaveChanges</c> throws, no records are deleted.</item>
/// <item>The operation is logged at <c>Warning</c> level with the full entity
/// breakdown so there is a permanent audit trail in the Serilog log files.</item>
/// </list>
/// </para>
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Execute(int olderThanDays, string[] entities)
{
if (olderThanDays < 1) olderThanDays = 30;
var cutoff = DateTime.UtcNow.AddDays(-olderThanDays);
var totalDeleted = 0;
var log = new StringBuilder();
// Children must be deleted before parents to respect FK constraints
var ordered = OrderForDeletion(entities);
foreach (var entity in ordered)
{
var deleted = await PurgeEntityAsync(entity, cutoff, log);
totalDeleted += deleted;
}
if (totalDeleted > 0)
await _db.SaveChangesAsync();
_logger.LogWarning("DataPurge by {User}: {Total} records permanently deleted (>{Days}d old). {Detail}",
User.Identity?.Name, totalDeleted, olderThanDays, log);
TempData["PurgeSuccess"] = $"Permanently deleted {totalDeleted:N0} records older than {olderThanDays} days.";
TempData["PurgeDetail"] = log.ToString();
return RedirectToAction(nameof(Index));
}
// ── Helpers: Stats ───────────────────────────────────────────────────────
/// <summary>
/// Builds the full list of <see cref="EntityPurgeStat"/> entries shown on the
/// dashboard Index page by querying each tracked entity type.
/// <para>
/// Uses a local <c>Stat</c> helper function with an <c>EF.Property</c> shadow
/// property accessor for <c>DeletedAt</c> so the age buckets can be computed in
/// a single DB round-trip per entity type without requiring a concrete CLR type
/// for each query. All queries use <c>IgnoreQueryFilters()</c> because the
/// purpose here is to count records that the global soft-delete filter would
/// normally hide.
/// </para>
/// </summary>
private async Task<List<EntityPurgeStat>> BuildStatsAsync()
{
var now = DateTime.UtcNow;
var stats = new List<EntityPurgeStat>();
async Task<EntityPurgeStat> Stat<T>(string name, string label, string icon, string group,
IQueryable<T> baseQuery) where T : class
{
var q = baseQuery.AsNoTracking().IgnoreQueryFilters();
var total = await q.CountAsync();
var d30 = await q.CountAsync(x => EF.Property<DateTime?>(x, "DeletedAt") >= now.AddDays(-30));
var d90 = await q.CountAsync(x => EF.Property<DateTime?>(x, "DeletedAt") < now.AddDays(-30)
&& EF.Property<DateTime?>(x, "DeletedAt") >= now.AddDays(-90));
var old = await q.CountAsync(x => EF.Property<DateTime?>(x, "DeletedAt") < now.AddDays(-90));
var oldest = total > 0
? await q.MinAsync(x => EF.Property<DateTime?>(x, "DeletedAt"))
: null;
return new EntityPurgeStat(name, label, icon, group, total, d30, d90, old, oldest);
}
// Jobs & Customers group
stats.Add(await Stat("Customers", "Customers", "bi-people", "Jobs & CRM", _db.Customers.Where(e => e.IsDeleted)));
stats.Add(await Stat("CustomerNotes", "Customer Notes", "bi-chat-left-text", "Jobs & CRM", _db.CustomerNotes.Where(e => e.IsDeleted)));
stats.Add(await Stat("Jobs", "Jobs", "bi-briefcase", "Jobs & CRM", _db.Jobs.Where(e => e.IsDeleted)));
stats.Add(await Stat("JobItems", "Job Items", "bi-list-ul", "Jobs & CRM", _db.JobItems.Where(e => e.IsDeleted)));
stats.Add(await Stat("JobPhotos", "Job Photos", "bi-camera", "Jobs & CRM", _db.JobPhotos.Where(e => e.IsDeleted)));
stats.Add(await Stat("JobNotes", "Job Notes", "bi-sticky", "Jobs & CRM", _db.JobNotes.Where(e => e.IsDeleted)));
// Quotes group
stats.Add(await Stat("Quotes", "Quotes", "bi-file-earmark-text", "Quotes", _db.Quotes.Where(e => e.IsDeleted)));
stats.Add(await Stat("QuoteItems", "Quote Items", "bi-list-check", "Quotes", _db.QuoteItems.Where(e => e.IsDeleted)));
// Inventory & Operations group
stats.Add(await Stat("InventoryItems", "Inventory Items", "bi-boxes", "Inventory & Ops", _db.InventoryItems.Where(e => e.IsDeleted)));
stats.Add(await Stat("Equipment", "Equipment", "bi-tools", "Inventory & Ops", _db.Equipment.Where(e => e.IsDeleted)));
stats.Add(await Stat("MaintenanceRecords","Maintenance Records", "bi-wrench", "Inventory & Ops", _db.MaintenanceRecords.Where(e => e.IsDeleted)));
stats.Add(await Stat("Vendors", "Vendors", "bi-truck", "Inventory & Ops", _db.Vendors.Where(e => e.IsDeleted)));
return stats;
}
/// <summary>
/// Returns the number of soft-deleted records of the given <paramref name="entity"/>
/// type that were deleted on or before <paramref name="cutoff"/>, together with the
/// earliest deletion timestamp in that set.
/// Used by <see cref="Preview"/> to estimate purge impact without committing changes.
/// Delegates to the generic <see cref="QueryCount{T}"/> helper for each known type;
/// unknown entity names safely return (0, null).
/// </summary>
private async Task<(int count, DateTime? oldest)> CountDeletableAsync(string entity, DateTime cutoff)
{
return entity switch
{
"Customers" => await QueryCount(_db.Customers, cutoff),
"CustomerNotes" => await QueryCount(_db.CustomerNotes, cutoff),
"Jobs" => await QueryCount(_db.Jobs, cutoff),
"JobItems" => await QueryCount(_db.JobItems, cutoff),
"JobPhotos" => await QueryCount(_db.JobPhotos, cutoff),
"JobNotes" => await QueryCount(_db.JobNotes, cutoff),
"Quotes" => await QueryCount(_db.Quotes, cutoff),
"QuoteItems" => await QueryCount(_db.QuoteItems, cutoff),
"InventoryItems" => await QueryCount(_db.InventoryItems, cutoff),
"Equipment" => await QueryCount(_db.Equipment, cutoff),
"MaintenanceRecords" => await QueryCount(_db.MaintenanceRecords, cutoff),
"Vendors" => await QueryCount(_db.Vendors, cutoff),
_ => (0, null)
};
}
/// <summary>
/// Generic helper that counts soft-deleted rows in <paramref name="set"/> whose
/// <c>DeletedAt</c> shadow property is at or before <paramref name="cutoff"/>,
/// and returns the minimum (oldest) deletion timestamp in the matching set.
/// <para>
/// <c>EF.Property&lt;bool&gt;(e, "IsDeleted")</c> and
/// <c>EF.Property&lt;DateTime?&gt;(e, "DeletedAt")</c> are used instead of
/// interface casts so this method works with any entity type that has those
/// shadow/column properties, without requiring all entities to implement a
/// common interface. <c>IgnoreQueryFilters()</c> is applied because the caller
/// already passes a pre-filtered <c>IQueryable</c> from the DbSet (e.g.
/// <c>_db.Customers.Where(e =&gt; e.IsDeleted)</c>).
/// </para>
/// </summary>
private static async Task<(int, DateTime?)> QueryCount<T>(IQueryable<T> set, DateTime cutoff) where T : class
{
var q = set.AsNoTracking().IgnoreQueryFilters()
.Where(e => EF.Property<bool>(e, "IsDeleted")
&& EF.Property<DateTime?>(e, "DeletedAt") <= cutoff);
var count = await q.CountAsync();
var oldest = count > 0 ? await q.MinAsync(e => EF.Property<DateTime?>(e, "DeletedAt")) : null;
return (count, oldest);
}
// ── Helpers: Purge ───────────────────────────────────────────────────────
/// <summary>
/// Permanently removes soft-deleted records of the given <paramref name="entity"/>
/// type that are older than <paramref name="cutoff"/> and appends a summary line
/// to <paramref name="log"/> for audit purposes.
/// <para>
/// <c>JobPhotos</c> require a two-step process: the physical files are deleted from
/// storage via <c>IJobPhotoService</c> before the DB rows are removed, so they use
/// <c>RemoveRange</c> with a materialised list rather than <c>ExecuteDeleteAsync</c>.
/// All other entity types use <c>ExecuteDeleteAsync</c> (EF bulk delete) for
/// performance — no change-tracker involvement, direct SQL DELETE statement.
/// Note: <c>ExecuteDeleteAsync</c> does not call <c>SaveChanges</c>; the caller
/// (<see cref="Execute"/>) issues a single <c>SaveChangesAsync</c> at the end.
/// However, <c>ExecuteDeleteAsync</c> executes immediately on the database, so
/// the final <c>SaveChanges</c> is only needed to flush the <c>RemoveRange</c>
/// changes for JobPhotos.
/// </para>
/// </summary>
private async Task<int> PurgeEntityAsync(string entity, DateTime cutoff, StringBuilder log)
{
int count;
switch (entity)
{
case "JobPhotos":
var photos = await _db.JobPhotos.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ToListAsync();
foreach (var p in photos)
{
if (!string.IsNullOrEmpty(p.FilePath))
await _jobPhotoService.DeleteJobPhotoAsync(p.FilePath);
}
_db.JobPhotos.RemoveRange(photos);
count = photos.Count;
break;
case "JobItems":
count = await _db.JobItems.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "JobNotes":
count = await _db.JobNotes.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "CustomerNotes":
count = await _db.CustomerNotes.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "QuoteItems":
count = await _db.QuoteItems.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "Customers":
count = await _db.Customers.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "Jobs":
// Collect IDs first so all FK cleanup targets the exact same set of jobs.
var purgingJobIds = await _db.Jobs.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff)
.Select(e => e.Id)
.ToListAsync();
if (purgingJobIds.Count > 0)
{
// Non-nullable FK children must be deleted before the parent row can go.
// ReworkRecord.JobId (Restrict), PowderUsageLog.JobId (NoAction),
// OvenBatchItem.JobId (NoAction) — cannot be nulled, so rows are removed.
await _db.ReworkRecords.IgnoreQueryFilters()
.Where(r => purgingJobIds.Contains(r.JobId))
.ExecuteDeleteAsync();
await _db.PowderUsageLogs.IgnoreQueryFilters()
.Where(l => purgingJobIds.Contains(l.JobId))
.ExecuteDeleteAsync();
await _db.OvenBatchItems.IgnoreQueryFilters()
.Where(i => purgingJobIds.Contains(i.JobId))
.ExecuteDeleteAsync();
// Nullable FKs with NO ACTION / RESTRICT — null them out so the DELETE
// does not violate the constraint. KioskSession and NotificationLog are
// excluded here because their FKs use SET NULL and the DB handles them.
await _db.Invoices.IgnoreQueryFilters()
.Where(i => i.JobId.HasValue && purgingJobIds.Contains(i.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(i => i.JobId, (int?)null));
await _db.Deposits.IgnoreQueryFilters()
.Where(d => d.JobId.HasValue && purgingJobIds.Contains(d.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(d => d.JobId, (int?)null));
await _db.Appointments.IgnoreQueryFilters()
.Where(a => a.JobId.HasValue && purgingJobIds.Contains(a.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(a => a.JobId, (int?)null));
await _db.BillLineItems.IgnoreQueryFilters()
.Where(b => b.JobId.HasValue && purgingJobIds.Contains(b.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(b => b.JobId, (int?)null));
await _db.Expenses.IgnoreQueryFilters()
.Where(e => e.JobId.HasValue && purgingJobIds.Contains(e.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(e => e.JobId, (int?)null));
await _db.InventoryTransactions.IgnoreQueryFilters()
.Where(t => t.JobId.HasValue && purgingJobIds.Contains(t.JobId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(t => t.JobId, (int?)null));
}
count = await _db.Jobs.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "Quotes":
count = await _db.Quotes.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "InventoryItems":
count = await _db.InventoryItems.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "Equipment":
count = await _db.Equipment.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "MaintenanceRecords":
count = await _db.MaintenanceRecords.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
case "Vendors":
count = await _db.Vendors.IgnoreQueryFilters()
.Where(e => e.IsDeleted && e.DeletedAt <= cutoff).ExecuteDeleteAsync();
break;
default:
return 0;
}
if (count > 0)
log.AppendLine($" {entity}: {count} records");
return count;
}
/// <summary>
/// Filters and reorders the caller-supplied entity names so that child entities
/// always appear before their parent entities, satisfying SQL foreign-key
/// constraints during bulk deletion.
/// <para>
/// Example: <c>JobItems</c> and <c>JobPhotos</c> must be deleted before
/// <c>Jobs</c>; <c>QuoteItems</c> before <c>Quotes</c>;
/// <c>CustomerNotes</c> before <c>Customers</c>. Any entity name not in the
/// master order array (i.e. unknown / unsupported) is silently dropped.
/// </para>
/// </summary>
private static string[] OrderForDeletion(string[] entities)
{
// Children before parents to respect FK constraints
var order = new[] {
"JobPhotos", "JobNotes", "JobItems",
"CustomerNotes",
"QuoteItems",
"MaintenanceRecords",
"Jobs", "Customers", "Quotes",
"InventoryItems", "Equipment",
"Vendors"
};
return order.Where(entities.Contains).ToArray();
}
}
// ── DTOs ─────────────────────────────────────────────────────────────────────
public record EntityPurgeStat(
string EntityName,
string Label,
string Icon,
string Group,
int Total,
int DeletedLast30Days,
int Deleted30To90Days,
int DeletedOlderThan90Days,
DateTime? OldestDeletion);
public class PurgeRequest
{
public int OlderThanDays { get; set; } = 90;
public string[]? Entities { get; set; }
}