From f8049064813b13b7f942e0dd2e48e9fe2f5871cc Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sat, 20 Jun 2026 17:30:21 -0400 Subject: [PATCH] Finish FindAsync tenant sweep: CompanySettings, dashboards, lookups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the multi-tenant defense-in-depth FindAsync/Count/Any pass. - CompanySettings (~21): in-use Count checks, dup-code Any checks, and the Quote-status "single approved/converted" Any checks now filter by CompanyId. (Were scoped for normal users via the global filter; this hardens them against raw platform-admin sessions.) - ReportsController.Analytics: powder-usage transactions scoped. - Dashboard pill counts (Invoices, Jobs) + onboarding status-history check. - JobsPriority / Jobs ShopDisplay+ShopMobile: today's priorities, maintenance, and status-lookup queries scoped. - OvenScheduler: scheduled-coat set and CompanyOperatingCosts lookup (c => true) scoped — the latter could return another tenant's defaults under a raw platform-admin session. Confirmed safe / left as-is: parent-FK child queries, by-PK fetches, platform tables (PowderCatalog, SubscriptionPlanConfig), SuperAdmin-only controllers (AuditLog/UserActivity/StripeEvents/SubscriptionManagement), already-filtered IQueryables, and intentional IgnoreQueryFilters number generators. Build clean; 293 unit tests pass. Co-Authored-By: Claude Opus 4.8 --- .../Controllers/CompanySettingsController.cs | 40 +++++++++---------- .../Controllers/DashboardController.cs | 2 +- .../Controllers/InvoicesController.cs | 11 ++--- .../Controllers/JobsController.cs | 26 +++++++----- .../Controllers/JobsPriorityController.cs | 9 +++-- .../Controllers/OvenSchedulerController.cs | 4 +- .../Controllers/ReportsController.cs | 3 +- 7 files changed, 53 insertions(+), 42 deletions(-) diff --git a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs index b3aeffd..75fa002 100644 --- a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs +++ b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs @@ -990,7 +990,7 @@ public class CompanySettingsController : Controller // Add job counts foreach (var dto in dtos) { - dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.JobStatusId == dto.Id); + dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId && j.JobStatusId == dto.Id); } return Json(dtos); @@ -1023,7 +1023,7 @@ public class CompanySettingsController : Controller // Check if status code already exists for this company var exists = await _unitOfWork.JobStatusLookups - .AnyAsync(s => s.StatusCode == dto.StatusCode); + .AnyAsync(s => s.CompanyId == companyId.Value && s.StatusCode == dto.StatusCode); if (exists) return Json(new { success = false, message = "Status code already exists" }); @@ -1100,7 +1100,7 @@ public class CompanySettingsController : Controller return Json(new { success = false, message = "Cannot delete system-defined status" }); // Check if status is in use - var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.JobStatusId == id); + var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.CompanyId == status.CompanyId && j.JobStatusId == id); if (inUse) return Json(new { success = false, message = "Status is in use and cannot be deleted" }); @@ -1184,7 +1184,7 @@ public class CompanySettingsController : Controller // Add job counts foreach (var dto in dtos) { - dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.JobPriorityId == dto.Id); + dto.JobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId && j.JobPriorityId == dto.Id); } return Json(dtos); @@ -1216,7 +1216,7 @@ public class CompanySettingsController : Controller // Check if priority code already exists for this company var exists = await _unitOfWork.JobPriorityLookups - .AnyAsync(p => p.PriorityCode == dto.PriorityCode); + .AnyAsync(p => p.CompanyId == companyId.Value && p.PriorityCode == dto.PriorityCode); if (exists) return Json(new { success = false, message = "Priority code already exists" }); @@ -1290,7 +1290,7 @@ public class CompanySettingsController : Controller return Json(new { success = false, message = "Cannot delete system-defined priority" }); // Check if priority is in use - var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.JobPriorityId == id); + var inUse = await _unitOfWork.Jobs.AnyAsync(j => j.CompanyId == priority.CompanyId && j.JobPriorityId == id); if (inUse) return Json(new { success = false, message = "Priority is in use and cannot be deleted" }); @@ -1370,7 +1370,7 @@ public class CompanySettingsController : Controller // Add quote counts foreach (var dto in dtos) { - dto.QuoteCount = await _unitOfWork.Quotes.CountAsync(q => q.QuoteStatusId == dto.Id); + dto.QuoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId && q.QuoteStatusId == dto.Id); } return Json(dtos); @@ -1403,7 +1403,7 @@ public class CompanySettingsController : Controller // Check if status code already exists for this company var exists = await _unitOfWork.QuoteStatusLookups - .AnyAsync(s => s.StatusCode == dto.StatusCode); + .AnyAsync(s => s.CompanyId == companyId.Value && s.StatusCode == dto.StatusCode); if (exists) return Json(new { success = false, message = "Status code already exists" }); @@ -1411,7 +1411,7 @@ public class CompanySettingsController : Controller if (dto.IsApprovedStatus) { var hasApproved = await _unitOfWork.QuoteStatusLookups - .AnyAsync(s => s.IsApprovedStatus); + .AnyAsync(s => s.CompanyId == companyId.Value && s.IsApprovedStatus); if (hasApproved) return Json(new { success = false, message = "Only one status can be marked as 'Approved'" }); } @@ -1419,7 +1419,7 @@ public class CompanySettingsController : Controller if (dto.IsConvertedStatus) { var hasConverted = await _unitOfWork.QuoteStatusLookups - .AnyAsync(s => s.IsConvertedStatus); + .AnyAsync(s => s.CompanyId == companyId.Value && s.IsConvertedStatus); if (hasConverted) return Json(new { success = false, message = "Only one status can be marked as 'Converted'" }); } @@ -1466,7 +1466,7 @@ public class CompanySettingsController : Controller if (dto.IsApprovedStatus && !status.IsApprovedStatus) { var hasApproved = await _unitOfWork.QuoteStatusLookups - .AnyAsync(s => s.Id != dto.Id && s.IsApprovedStatus); + .AnyAsync(s => s.CompanyId == status.CompanyId && s.Id != dto.Id && s.IsApprovedStatus); if (hasApproved) return Json(new { success = false, message = "Only one status can be marked as 'Approved'" }); } @@ -1474,7 +1474,7 @@ public class CompanySettingsController : Controller if (dto.IsConvertedStatus && !status.IsConvertedStatus) { var hasConverted = await _unitOfWork.QuoteStatusLookups - .AnyAsync(s => s.Id != dto.Id && s.IsConvertedStatus); + .AnyAsync(s => s.CompanyId == status.CompanyId && s.Id != dto.Id && s.IsConvertedStatus); if (hasConverted) return Json(new { success = false, message = "Only one status can be marked as 'Converted'" }); } @@ -1512,7 +1512,7 @@ public class CompanySettingsController : Controller return Json(new { success = false, message = "Cannot delete system-defined status" }); // Check if status is in use - var inUse = await _unitOfWork.Quotes.AnyAsync(q => q.QuoteStatusId == id); + var inUse = await _unitOfWork.Quotes.AnyAsync(q => q.CompanyId == status.CompanyId && q.QuoteStatusId == id); if (inUse) return Json(new { success = false, message = "Status is in use and cannot be deleted" }); @@ -1909,7 +1909,7 @@ public class CompanySettingsController : Controller // Add appointment counts foreach (var dto in dtos) { - dto.AppointmentCount = await _unitOfWork.Appointments.CountAsync(a => a.AppointmentTypeId == dto.Id); + dto.AppointmentCount = await _unitOfWork.Appointments.CountAsync(a => a.CompanyId == companyId && a.AppointmentTypeId == dto.Id); } return Json(dtos); @@ -1941,7 +1941,7 @@ public class CompanySettingsController : Controller // Check if type code already exists for this company var exists = await _unitOfWork.AppointmentTypeLookups - .AnyAsync(t => t.TypeCode == dto.TypeCode); + .AnyAsync(t => t.CompanyId == companyId.Value && t.TypeCode == dto.TypeCode); if (exists) return Json(new { success = false, message = "Type code already exists" }); @@ -2015,7 +2015,7 @@ public class CompanySettingsController : Controller return Json(new { success = false, message = "Cannot delete system-defined type" }); // Check if type is in use - var inUse = await _unitOfWork.Appointments.AnyAsync(a => a.AppointmentTypeId == id); + var inUse = await _unitOfWork.Appointments.AnyAsync(a => a.CompanyId == type.CompanyId && a.AppointmentTypeId == id); if (inUse) return Json(new { success = false, message = "Type is in use and cannot be deleted" }); @@ -2095,7 +2095,7 @@ public class CompanySettingsController : Controller // Add item counts foreach (var dto in dtos) { - dto.ItemCount = await _unitOfWork.InventoryItems.CountAsync(i => i.InventoryCategoryId == dto.Id); + dto.ItemCount = await _unitOfWork.InventoryItems.CountAsync(i => i.CompanyId == companyId && i.InventoryCategoryId == dto.Id); } return Json(dtos); @@ -2127,7 +2127,7 @@ public class CompanySettingsController : Controller // Check if category code already exists for this company var exists = await _unitOfWork.InventoryCategoryLookups - .AnyAsync(c => c.CategoryCode == dto.CategoryCode); + .AnyAsync(c => c.CompanyId == companyId.Value && c.CategoryCode == dto.CategoryCode); if (exists) return Json(new { success = false, message = "Category code already exists" }); @@ -2193,7 +2193,7 @@ public class CompanySettingsController : Controller return Json(new { success = false, message = "Category not found" }); // Check if category is in use - var inUse = await _unitOfWork.InventoryItems.AnyAsync(i => i.InventoryCategoryId == id); + var inUse = await _unitOfWork.InventoryItems.AnyAsync(i => i.CompanyId == category.CompanyId && i.InventoryCategoryId == id); if (inUse) return Json(new { success = false, message = "Category is in use and cannot be deleted" }); @@ -2404,7 +2404,7 @@ public class CompanySettingsController : Controller return Json(new { success = false, message = "Oven not found." }); // Check if any quotes reference this oven - var usageCount = await _unitOfWork.Quotes.CountAsync(q => q.OvenCostId == id); + var usageCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value && q.OvenCostId == id); if (usageCount > 0) return Json(new { success = false, message = $"Cannot delete: {usageCount} quote(s) reference this oven. Deactivate it instead." }); diff --git a/src/PowderCoating.Web/Controllers/DashboardController.cs b/src/PowderCoating.Web/Controllers/DashboardController.cs index dfea15d..0731e90 100644 --- a/src/PowderCoating.Web/Controllers/DashboardController.cs +++ b/src/PowderCoating.Web/Controllers/DashboardController.cs @@ -499,7 +499,7 @@ public class DashboardController : Controller return null; // These share the same scoped DbContext so must run sequentially - var hasStatusHistory = await _unitOfWork.JobStatusHistory.AnyAsync(_ => true); + var hasStatusHistory = await _unitOfWork.JobStatusHistory.AnyAsync(h => h.CompanyId == companyId); // ignoreQueryFilters so soft-deleted lookups (UpdatedAt set on delete) are also visible var hasCustomizedLookups = await _unitOfWork.JobStatusLookups.AnyAsync( j => j.CompanyId == companyId && j.UpdatedAt != null, diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index 4b97036..90ae829 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -241,11 +241,12 @@ public class InvoicesController : Controller ViewBag.SortDirection = gridRequest.SortDirection; // Pill badge counts — always global (not scoped to current filter/page) - ViewBag.UnpaidCount = await _unitOfWork.Invoices.CountAsync(i => - i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue); - ViewBag.PartialCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.PartiallyPaid); - ViewBag.PaidCount = await _unitOfWork.Invoices.CountAsync(i => i.Status == InvoiceStatus.Paid); - ViewBag.AllCount = await _unitOfWork.Invoices.CountAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + ViewBag.UnpaidCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId && + (i.Status == InvoiceStatus.Draft || i.Status == InvoiceStatus.Sent || i.Status == InvoiceStatus.Overdue)); + ViewBag.PartialCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId && i.Status == InvoiceStatus.PartiallyPaid); + ViewBag.PaidCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId && i.Status == InvoiceStatus.Paid); + ViewBag.AllCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId); return View(pagedResult); } diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index b3c00d7..6de01fb 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -213,24 +213,29 @@ public class JobsController : Controller // Pill badge counts — always global (not scoped to current filter/page) var today = DateTime.Today; - ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + ViewBag.AllJobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId); ViewBag.ActiveCount = await _unitOfWork.Jobs.CountAsync(j => - j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed + j.CompanyId == companyId + && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled); ViewBag.OverdueCount = await _unitOfWork.Jobs.CountAsync(j => - j.DueDate < today + j.CompanyId == companyId + && j.DueDate < today && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Completed && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.ReadyForPickup && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Delivered && j.JobStatus.StatusCode != AppConstants.StatusCodes.Job.Cancelled); ViewBag.CompletedCount = await _unitOfWork.Jobs.CountAsync(j => - j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed + j.CompanyId == companyId && + (j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Completed || j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup - || j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered); + || j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.Delivered)); ViewBag.ReadyCount = await _unitOfWork.Jobs.CountAsync(j => - j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup); + j.CompanyId == companyId + && j.JobStatus.StatusCode == AppConstants.StatusCodes.Job.ReadyForPickup); // Set ViewBag for sorting ViewBag.SearchTerm = searchTerm; @@ -2171,10 +2176,12 @@ public class JobsController : Controller try { var today = date?.Date ?? DateTime.Today; + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; // Load all non-terminal statuses for the progress strip (excluding nav/hold/cancel) var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s => - s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled + s.CompanyId == companyId + && s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled && s.StatusCode != AppConstants.StatusCodes.Job.Delivered && s.StatusCode != AppConstants.StatusCodes.Job.Quoted && s.StatusCode != AppConstants.StatusCodes.Job.Pending && s.StatusCode != AppConstants.StatusCodes.Job.Approved); var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList(); @@ -2184,7 +2191,7 @@ public class JobsController : Controller // Get existing priority records for today var existingPriorities = await _unitOfWork.JobDailyPriorities - .FindAsync(p => p.ScheduledDate.Date == today); + .FindAsync(p => p.CompanyId == companyId && p.ScheduledDate.Date == today); var priorityDict = existingPriorities.ToDictionary(p => p.JobId); @@ -2281,7 +2288,8 @@ public class JobsController : Controller if (!companyId.HasValue) return RedirectToAction(nameof(Index)); var allStatusesEnum = await _unitOfWork.JobStatusLookups.FindAsync(s => - !s.IsTerminalStatus + s.CompanyId == companyId.Value + && !s.IsTerminalStatus && s.StatusCode != AppConstants.StatusCodes.Job.OnHold && s.StatusCode != AppConstants.StatusCodes.Job.Cancelled && s.StatusCode != AppConstants.StatusCodes.Job.Delivered); var allStatuses = allStatusesEnum.OrderBy(s => s.DisplayOrder).ToList(); diff --git a/src/PowderCoating.Web/Controllers/JobsPriorityController.cs b/src/PowderCoating.Web/Controllers/JobsPriorityController.cs index 0772de7..28e45aa 100644 --- a/src/PowderCoating.Web/Controllers/JobsPriorityController.cs +++ b/src/PowderCoating.Web/Controllers/JobsPriorityController.cs @@ -57,13 +57,14 @@ public class JobsPriorityController : Controller public async Task Index(DateTime? date) { var today = date?.Date ?? DateTime.Today; + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; // Get all jobs scheduled for today with related data var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today); // Get existing priority records for today var existingPriorities = await _unitOfWork.JobDailyPriorities - .FindAsync(p => p.ScheduledDate.Date == today); + .FindAsync(p => p.CompanyId == companyId && p.ScheduledDate.Date == today); var priorityDict = existingPriorities.ToDictionary(p => p.JobId); @@ -90,7 +91,6 @@ public class JobsPriorityController : Controller .ToList(); // Get priorities and workers for modal options - var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId); var workers = await _userManager.Users .Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null) @@ -99,7 +99,7 @@ public class JobsPriorityController : Controller // Get maintenance records scheduled for today (Scheduled or InProgress) var maintenanceItems = (await _unitOfWork.MaintenanceRecords.FindAsync( - m => m.ScheduledDate.Date == today && + m => m.CompanyId == companyId && m.ScheduledDate.Date == today && (m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.InProgress), false, m => m.Equipment, m => m.AssignedUser)) @@ -169,10 +169,11 @@ public class JobsPriorityController : Controller } var today = DateTime.Today; + var cid = _tenantContext.GetCurrentCompanyId() ?? 0; // Get all existing priority records for today var existingPriorities = await _unitOfWork.JobDailyPriorities - .FindAsync(p => p.ScheduledDate.Date == today); + .FindAsync(p => p.CompanyId == cid && p.ScheduledDate.Date == today); var priorityDict = existingPriorities.ToDictionary(p => p.JobId); diff --git a/src/PowderCoating.Web/Controllers/OvenSchedulerController.cs b/src/PowderCoating.Web/Controllers/OvenSchedulerController.cs index b5986e0..6ec4ff4 100644 --- a/src/PowderCoating.Web/Controllers/OvenSchedulerController.cs +++ b/src/PowderCoating.Web/Controllers/OvenSchedulerController.cs @@ -127,14 +127,14 @@ public class OvenSchedulerController : Controller // Determine which coats are already scheduled — filter out removed/cancelled at database level var scheduledCoatIds = (await _unitOfWork.OvenBatchItems.FindAsync( - i => i.Status != OvenBatchItemStatus.Removed && i.Batch.Status != OvenBatchStatus.Cancelled, + i => i.CompanyId == companyId && i.Status != OvenBatchItemStatus.Removed && i.Batch.Status != OvenBatchStatus.Cancelled, false, i => i.Batch)) .Select(i => i.JobItemCoatId) .ToHashSet(); // Get company defaults - var companyCosts = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => true); + var companyCosts = await _unitOfWork.CompanyOperatingCosts.FirstOrDefaultAsync(c => c.CompanyId == companyId); var defaultCycleMinutes = companyCosts?.DefaultOvenCycleMinutes ?? 45; // Build the view model diff --git a/src/PowderCoating.Web/Controllers/ReportsController.cs b/src/PowderCoating.Web/Controllers/ReportsController.cs index d8984b0..a9b2cd1 100644 --- a/src/PowderCoating.Web/Controllers/ReportsController.cs +++ b/src/PowderCoating.Web/Controllers/ReportsController.cs @@ -590,7 +590,8 @@ public class ReportsController : Controller // === POWDER USAGE ANALYTICS === var powderTransactions = (await _unitOfWork.InventoryTransactions - .FindAsync(t => t.TransactionType == InventoryTransactionType.JobUsage + .FindAsync(t => t.CompanyId == companyId + && t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate, false, t => t.InventoryItem))