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 _logger; public DataPurgeController( ApplicationDbContext db, IJobPhotoService jobPhotoService, ILogger logger) { _db = db; _jobPhotoService = jobPhotoService; _logger = logger; } // ── GET: Index ─────────────────────────────────────────────────────────── /// /// 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. /// /// Stats are built by . 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 /// olderThanDays cutoff before executing. /// /// public async Task Index() { var stats = await BuildStatsAsync(); return View(stats); } // ── POST: Preview (AJAX) ───────────────────────────────────────────────── /// /// Returns a JSON preview of how many records would be permanently deleted if /// were called with the same parameters, without making any /// changes to the database. /// /// 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. /// /// [HttpPost] public async Task Preview([FromBody] PurgeRequest req) { if (req.OlderThanDays < 1) req.OlderThanDays = 30; var cutoff = DateTime.UtcNow.AddDays(-req.OlderThanDays); var results = new List(); 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 ──────────────────────────────────────────────────────── /// /// Permanently (hard) deletes all soft-deleted records of the selected entity /// types that were deleted more than days ago. /// This operation is irreversible and restricted to SuperAdmin. /// /// Key design decisions: /// /// Entity types are processed in child-before-parent order (determined by /// ) to honour foreign-key constraints without /// temporarily disabling them. /// JobPhotos are purged via which /// calls IJobPhotoService.DeleteJobPhotoAsync to also remove the /// corresponding files from storage before the DB row is deleted. /// All DB deletions are batched into a single SaveChangesAsync() /// call at the end (rather than per entity) for performance; this means /// if SaveChanges throws, no records are deleted. /// The operation is logged at Warning level with the full entity /// breakdown so there is a permanent audit trail in the Serilog log files. /// /// /// [HttpPost, ValidateAntiForgeryToken] public async Task 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 ─────────────────────────────────────────────────────── /// /// Builds the full list of entries shown on the /// dashboard Index page by querying each tracked entity type. /// /// Uses a local Stat helper function with an EF.Property shadow /// property accessor for DeletedAt 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 IgnoreQueryFilters() because the /// purpose here is to count records that the global soft-delete filter would /// normally hide. /// /// private async Task> BuildStatsAsync() { var now = DateTime.UtcNow; var stats = new List(); async Task Stat(string name, string label, string icon, string group, IQueryable baseQuery) where T : class { var q = baseQuery.AsNoTracking().IgnoreQueryFilters(); var total = await q.CountAsync(); var d30 = await q.CountAsync(x => EF.Property(x, "DeletedAt") >= now.AddDays(-30)); var d90 = await q.CountAsync(x => EF.Property(x, "DeletedAt") < now.AddDays(-30) && EF.Property(x, "DeletedAt") >= now.AddDays(-90)); var old = await q.CountAsync(x => EF.Property(x, "DeletedAt") < now.AddDays(-90)); var oldest = total > 0 ? await q.MinAsync(x => EF.Property(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; } /// /// Returns the number of soft-deleted records of the given /// type that were deleted on or before , together with the /// earliest deletion timestamp in that set. /// Used by to estimate purge impact without committing changes. /// Delegates to the generic helper for each known type; /// unknown entity names safely return (0, null). /// 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) }; } /// /// Generic helper that counts soft-deleted rows in whose /// DeletedAt shadow property is at or before , /// and returns the minimum (oldest) deletion timestamp in the matching set. /// /// EF.Property<bool>(e, "IsDeleted") and /// EF.Property<DateTime?>(e, "DeletedAt") 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. IgnoreQueryFilters() is applied because the caller /// already passes a pre-filtered IQueryable from the DbSet (e.g. /// _db.Customers.Where(e => e.IsDeleted)). /// /// private static async Task<(int, DateTime?)> QueryCount(IQueryable set, DateTime cutoff) where T : class { var q = set.AsNoTracking().IgnoreQueryFilters() .Where(e => EF.Property(e, "IsDeleted") && EF.Property(e, "DeletedAt") <= cutoff); var count = await q.CountAsync(); var oldest = count > 0 ? await q.MinAsync(e => EF.Property(e, "DeletedAt")) : null; return (count, oldest); } // ── Helpers: Purge ─────────────────────────────────────────────────────── /// /// Permanently removes soft-deleted records of the given /// type that are older than and appends a summary line /// to for audit purposes. /// /// JobPhotos require a two-step process: the physical files are deleted from /// storage via IJobPhotoService before the DB rows are removed, so they use /// RemoveRange with a materialised list rather than ExecuteDeleteAsync. /// All other entity types use ExecuteDeleteAsync (EF bulk delete) for /// performance — no change-tracker involvement, direct SQL DELETE statement. /// Note: ExecuteDeleteAsync does not call SaveChanges; the caller /// () issues a single SaveChangesAsync at the end. /// However, ExecuteDeleteAsync executes immediately on the database, so /// the final SaveChanges is only needed to flush the RemoveRange /// changes for JobPhotos. /// /// private async Task 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; } /// /// 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. /// /// Example: JobItems and JobPhotos must be deleted before /// Jobs; QuoteItems before Quotes; /// CustomerNotes before Customers. Any entity name not in the /// master order array (i.e. unknown / unsupported) is silently dropped. /// /// 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; } }