From e6c4cfb38b0acb9d5974497584356b907ea3eaa0 Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Tue, 26 May 2026 13:04:13 -0400 Subject: [PATCH] Fix all FK constraint violations when purging soft-deleted Jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Controllers/DataPurgeController.cs | 36 +++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/PowderCoating.Web/Controllers/DataPurgeController.cs b/src/PowderCoating.Web/Controllers/DataPurgeController.cs index dce4f88..1676bac 100644 --- a/src/PowderCoating.Web/Controllers/DataPurgeController.cs +++ b/src/PowderCoating.Web/Controllers/DataPurgeController.cs @@ -293,16 +293,48 @@ public class DataPurgeController : Controller break; case "Jobs": - // Appointments.JobId is a nullable FK — null it out first so the DELETE - // doesn't violate FK_Appointments_Jobs_JobId. + // 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;