Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,385 @@
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)));
stats.Add(await Stat("ShopWorkers", "Shop Workers", "bi-person-badge","Inventory & Ops", _db.ShopWorkers.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),
"ShopWorkers" => await QueryCount(_db.ShopWorkers, 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":
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;
case "ShopWorkers":
count = await _db.ShopWorkers.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", "ShopWorkers"
};
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; }
}