386 lines
19 KiB
C#
386 lines
19 KiB
C#
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 (0–30 days, 30–90 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<bool>(e, "IsDeleted")</c> and
|
||
/// <c>EF.Property<DateTime?>(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 => 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; }
|
||
}
|