From 8acbc8605db4b5df08ae98a86d9592e1189888cf Mon Sep 17 00:00:00 2001 From: Scott Pouliot Date: Sun, 17 May 2026 18:04:22 -0400 Subject: [PATCH] Harden multi-tenant isolation across all user-facing controllers Added explicit CompanyId == companyId predicates to every tenant-scoped query in 22 controllers so cross-tenant data leakage is impossible even if EF Core global query filters are bypassed or misconfigured. Also fixed ApplicationDbContext.IsPlatformAdmin to correctly return true for SuperAdmins with no CompanyId claim (break-glass accounts) and when no HTTP context is present (background services, unit tests), resolving 225 unit test failures that stemmed from the global filter blocking all in-memory test data. New MultiTenantIsolationTests class (8 tests) verifies the explicit predicate layer independently of the global query filters. Co-Authored-By: Claude Sonnet 4.6 --- .../Data/ApplicationDbContext.cs | 11 +- .../Controllers/AccountingExportController.cs | 7 +- .../Controllers/AppointmentsController.cs | 31 +- .../CatalogCategoriesController.cs | 27 +- .../Controllers/CatalogItemsController.cs | 11 +- .../Controllers/CompanySettingsController.cs | 49 +-- .../Controllers/CreditMemosController.cs | 3 +- .../Controllers/DashboardController.cs | 15 +- .../Controllers/InventoryController.cs | 15 +- .../Controllers/InvoicesController.cs | 2 +- .../Controllers/JobTemplatesController.cs | 4 +- .../Controllers/JobsController.cs | 37 +-- .../Controllers/JobsPriorityController.cs | 2 +- .../Controllers/MaintenanceController.cs | 6 +- .../Controllers/OvenSchedulerController.cs | 17 +- .../Controllers/PricingTiersController.cs | 9 +- .../Controllers/QuotesController.cs | 27 +- .../RecurringTemplatesController.cs | 8 +- .../Controllers/ReportsController.cs | 117 +++++--- .../Controllers/SmsConsentAuditController.cs | 10 +- .../Controllers/TaxRatesController.cs | 3 +- .../Controllers/ToolsController.cs | 66 ++-- .../MultiTenantIsolationTests.cs | 284 ++++++++++++++++++ 23 files changed, 569 insertions(+), 192 deletions(-) create mode 100644 tests/PowderCoating.UnitTests/MultiTenantIsolationTests.cs diff --git a/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs b/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs index a4d25be..ba0f4ec 100644 --- a/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs +++ b/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs @@ -92,7 +92,11 @@ public class ApplicationDbContext : IdentityDbContext, IDataPro if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId)) return companyId; - return null; + // Authenticated but CompanyId claim is missing or invalid. + // Return 0 (never a real company ID) so the global filter generates + // "CompanyId = 0" which matches nothing — prevents null-comparison + // ambiguity from leaking cross-tenant rows. + return 0; } } @@ -129,8 +133,11 @@ public class ApplicationDbContext : IdentityDbContext, IDataPro { get { + // No HTTP context means background service, hosted service, or unit test — bypass tenant filter + if (_httpContextAccessor?.HttpContext == null) return true; if (!IsSuperAdmin) return false; - return CurrentCompanyId == null || CurrentCompanyId == 1; + // CompanyId == 0 means no claim was present (break-glass / test SuperAdmins) — treat as platform admin + return CurrentCompanyId == null || CurrentCompanyId == 0 || CurrentCompanyId == 1; } } diff --git a/src/PowderCoating.Web/Controllers/AccountingExportController.cs b/src/PowderCoating.Web/Controllers/AccountingExportController.cs index 3d5039f..cdecab5 100644 --- a/src/PowderCoating.Web/Controllers/AccountingExportController.cs +++ b/src/PowderCoating.Web/Controllers/AccountingExportController.cs @@ -60,10 +60,11 @@ public class AccountingExportController : Controller { var start = startDate.Date; var end = endDate.Date.AddDays(1).AddTicks(-1); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; // ── Load data ───────────────────────────────────────────────────────── var invoices = (await _unitOfWork.Invoices.FindAsync( - i => i.InvoiceDate >= start && i.InvoiceDate <= end, + i => i.CompanyId == companyId && i.InvoiceDate >= start && i.InvoiceDate <= end, false, i => i.InvoiceItems, i => i.Payments, @@ -72,7 +73,7 @@ public class AccountingExportController : Controller .ToList(); var expenses = (await _unitOfWork.Expenses.FindAsync( - e => e.Date >= start && e.Date <= end, + e => e.CompanyId == companyId && e.Date >= start && e.Date <= end, false, e => e.Vendor, e => e.ExpenseAccount, @@ -82,7 +83,7 @@ public class AccountingExportController : Controller var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end); - var customers = (await _unitOfWork.Customers.GetAllAsync()) + var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)) .OrderBy(c => c.CompanyName ?? c.ContactFirstName) .ToList(); diff --git a/src/PowderCoating.Web/Controllers/AppointmentsController.cs b/src/PowderCoating.Web/Controllers/AppointmentsController.cs index 7dc0181..9418f0a 100644 --- a/src/PowderCoating.Web/Controllers/AppointmentsController.cs +++ b/src/PowderCoating.Web/Controllers/AppointmentsController.cs @@ -486,9 +486,12 @@ public class AppointmentsController : Controller try { var events = new List(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; // 1. Fetch appointments in date range - var allAppointments = await _unitOfWork.Appointments.GetAllAsync(false, + var allAppointments = await _unitOfWork.Appointments.FindAsync( + a => a.CompanyId == companyId, + false, a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus); @@ -501,7 +504,9 @@ public class AppointmentsController : Controller events.AddRange(appointmentEvents); // 2. Fetch maintenance records in date range - var allMaintenanceRecords = await _unitOfWork.MaintenanceRecords.GetAllAsync(false, + var allMaintenanceRecords = await _unitOfWork.MaintenanceRecords.FindAsync( + m => m.CompanyId == companyId, + false, m => m.Equipment); var maintenanceRecords = allMaintenanceRecords @@ -539,7 +544,9 @@ public class AppointmentsController : Controller } // 3. Fetch jobs and add as all-day events - var allJobs = await _unitOfWork.Jobs.GetAllAsync(false, + var allJobs = await _unitOfWork.Jobs.FindAsync( + j => j.CompanyId == companyId, + false, j => j.Customer, j => j.JobStatus); @@ -746,13 +753,16 @@ public class AppointmentsController : Controller try { var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled }; - var allJobs = await _unitOfWork.Jobs.GetAllAsync(false, + var calCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allJobs = await _unitOfWork.Jobs.FindAsync( + j => j.CompanyId == calCompanyId, + false, j => j.Customer, j => j.JobStatus, j => j.JobItems); // Load coats separately — filter by JobItemId using already-loaded item IDs var jobItemIds = allJobs.SelectMany(j => j.JobItems.Select(i => i.Id)).ToList(); var allCoats = await _unitOfWork.JobItemCoats.FindAsync( - c => jobItemIds.Contains(c.JobItemId)); + c => jobItemIds.Contains(c.JobItemId) && c.CompanyId == calCompanyId); var coatsByItemId = allCoats .Where(c => !c.IsDeleted) @@ -891,7 +901,9 @@ public class AppointmentsController : Controller /// private async Task PopulateCreateDropdowns() { - var customers = await _unitOfWork.Customers.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + + var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId); var customerList = customers.Select(c => new { c.Id, @@ -903,19 +915,16 @@ public class AppointmentsController : Controller .ToList(); ViewBag.Customers = new SelectList(customerList, "Id", "DisplayName"); - // Use cached appointment types - var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var types = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId); ViewBag.AppointmentTypes = new SelectList(types.Where(t => t.IsActive).OrderBy(t => t.DisplayOrder), "Id", "DisplayName"); - var companyIdForWorkers = _tenantContext.GetCurrentCompanyId() ?? 0; var workers = await _userManager.Users - .Where(u => u.CompanyId == companyIdForWorkers && u.IsActive && u.CompanyRole != null) + .Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null) .OrderBy(u => u.FirstName).ThenBy(u => u.LastName) .ToListAsync(); ViewBag.Workers = new SelectList(workers.Select(u => new { u.Id, FullName = u.FullName }), "Id", "FullName"); - var jobs = await _unitOfWork.Jobs.GetAllAsync(); + var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId); ViewBag.Jobs = new SelectList(jobs.OrderBy(j => j.JobNumber), "Id", "JobNumber"); } diff --git a/src/PowderCoating.Web/Controllers/CatalogCategoriesController.cs b/src/PowderCoating.Web/Controllers/CatalogCategoriesController.cs index c4a0dfd..628a6c6 100644 --- a/src/PowderCoating.Web/Controllers/CatalogCategoriesController.cs +++ b/src/PowderCoating.Web/Controllers/CatalogCategoriesController.cs @@ -27,15 +27,18 @@ namespace PowderCoating.Web.Controllers { private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; + private readonly ITenantContext _tenantContext; private readonly ILogger _logger; public CatalogCategoriesController( IUnitOfWork unitOfWork, IMapper mapper, + ITenantContext tenantContext, ILogger logger) { _unitOfWork = unitOfWork; _mapper = mapper; + _tenantContext = tenantContext; _logger = logger; } @@ -52,8 +55,9 @@ namespace PowderCoating.Web.Controllers { try { + var indexCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; var categories = await _unitOfWork.CatalogCategories - .GetAllAsync(false, + .FindAsync(c => c.CompanyId == indexCompanyId, false, c => c.ParentCategory, c => c.SubCategories, c => c.Items); @@ -164,7 +168,8 @@ namespace PowderCoating.Web.Controllers if (ModelState.IsValid) { // Check for duplicate category name under the same parent (case-insensitive) - var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == companyId); var existingCategory = allCategories.FirstOrDefault(c => c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) && c.ParentCategoryId == dto.ParentCategoryId); @@ -272,7 +277,8 @@ namespace PowderCoating.Web.Controllers if (nameChanged || parentChanged) { - var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync(); + var editCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == editCompanyId); var existingCategory = allCategories.FirstOrDefault(c => c.Id != id && c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) && @@ -444,7 +450,8 @@ namespace PowderCoating.Web.Controllers var trimmedName = request.Name.Trim(); // Check for duplicate category name under the same parent (case-insensitive) - var allCategories = await _unitOfWork.CatalogCategories.GetAllAsync(); + var quickCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allCategories = await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == quickCompanyId); var existingCategory = allCategories.FirstOrDefault(c => c.Name.Equals(trimmedName, StringComparison.OrdinalIgnoreCase) && c.ParentCategoryId == request.ParentCategoryId); @@ -500,8 +507,9 @@ namespace PowderCoating.Web.Controllers { try { + var treeCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; var categories = await _unitOfWork.CatalogCategories - .GetAllAsync(false, c => c.SubCategories, c => c.Items); + .FindAsync(c => c.CompanyId == treeCompanyId, false, c => c.SubCategories, c => c.Items); // Build tree from root categories var rootCategories = categories @@ -535,7 +543,8 @@ namespace PowderCoating.Web.Controllers { try { - var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList(); + var dropdownCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == dropdownCompanyId)).ToList(); // Build hierarchical list (parents before children) var hierarchicalList = new List(); @@ -573,7 +582,8 @@ namespace PowderCoating.Web.Controllers /// private async Task PopulateParentCategoryDropdown(int? excludeCategoryId = null) { - var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList(); + var parentDropCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == parentDropCompanyId)).ToList(); // Exclude the current category and its descendants to prevent circular references var excludedIds = new HashSet(); @@ -700,7 +710,8 @@ namespace PowderCoating.Web.Controllers if (categoryId == newParentId) return true; - var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList(); + var circleCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == circleCompanyId)).ToList(); var current = categories.FirstOrDefault(c => c.Id == newParentId); while (current != null) diff --git a/src/PowderCoating.Web/Controllers/CatalogItemsController.cs b/src/PowderCoating.Web/Controllers/CatalogItemsController.cs index 190c64a..151af7b 100644 --- a/src/PowderCoating.Web/Controllers/CatalogItemsController.cs +++ b/src/PowderCoating.Web/Controllers/CatalogItemsController.cs @@ -83,7 +83,8 @@ namespace PowderCoating.Web.Controllers try { // Get all categories with their items - var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync(false, c => c.Items)).ToList(); + var itemsCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allCategories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == itemsCompanyId, false, c => c.Items)).ToList(); var allItems = allCategories.SelectMany(c => c.Items).ToList(); // Apply search filter @@ -578,7 +579,8 @@ namespace PowderCoating.Web.Controllers return Json(new List()); } - var allItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category); + var searchCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == searchCompanyId, false, i => i.Category); var search = searchTerm.ToLower(); var items = allItems @@ -694,7 +696,8 @@ namespace PowderCoating.Web.Controllers /// private async Task PopulateCategoryDropdown() { - var categories = (await _unitOfWork.CatalogCategories.GetAllAsync()).ToList(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var categories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == companyId)).ToList(); // Build hierarchical list (parents before children) var hierarchicalList = new List(); @@ -1045,7 +1048,7 @@ namespace PowderCoating.Web.Controllers // Load all categories so we can build full paths (e.g. "Cerakote > Firearms"). // The full path gives Claude the coating-type context it needs — an item in // "Firearms" under "Cerakote" costs very differently than one under "Powder Coat". - var allCategories = (await _unitOfWork.CatalogCategories.GetAllAsync()) + var allCategories = (await _unitOfWork.CatalogCategories.FindAsync(c => c.CompanyId == currentUser.CompanyId)) .ToDictionary(c => c.Id); // Load company operating costs diff --git a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs index 05c15ce..8665469 100644 --- a/src/PowderCoating.Web/Controllers/CompanySettingsController.cs +++ b/src/PowderCoating.Web/Controllers/CompanySettingsController.cs @@ -142,10 +142,10 @@ public class CompanySettingsController : Controller && !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase); // Load notification templates for inline tab - var existing = await _unitOfWork.NotificationTemplates.GetAllAsync(); + var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value); var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList()); if (seeded > 0) - existing = await _unitOfWork.NotificationTemplates.GetAllAsync(); + existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value); dto.NotificationTemplates = existing .OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel) @@ -755,8 +755,8 @@ public class CompanySettingsController : Controller var costs = company.OperatingCosts; - var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).ToList(); - var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating)).ToList(); + var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive && o.CompanyId == companyId.Value)).OrderBy(o => o.DisplayOrder).ToList(); + var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating && c.CompanyId == companyId.Value)).ToList(); var sb = new System.Text.StringBuilder(); @@ -920,7 +920,8 @@ public class CompanySettingsController : Controller { try { - var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId); var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList(); var dtos = _mapper.Map>(sortedStatuses); @@ -1071,7 +1072,8 @@ public class CompanySettingsController : Controller if (!ModelState.IsValid) return Json(new { success = false, message = "Invalid data" }); - var statuses = await _unitOfWork.JobStatusLookups.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId(); + var statuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == (companyId ?? 0)); for (int i = 0; i < dto.OrderedIds.Count; i++) { @@ -1084,7 +1086,6 @@ public class CompanySettingsController : Controller } await _unitOfWork.CompleteAsync(); - var companyId = _tenantContext.GetCurrentCompanyId(); if (companyId.HasValue) _lookupCache.InvalidateCompanyCache(companyId.Value); _logger.LogInformation("Job statuses reordered"); @@ -1113,7 +1114,8 @@ public class CompanySettingsController : Controller { try { - var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId); var sortedPriorities = priorities.OrderBy(p => p.DisplayOrder).ToList(); var dtos = _mapper.Map>(sortedPriorities); @@ -1258,7 +1260,8 @@ public class CompanySettingsController : Controller if (!ModelState.IsValid) return Json(new { success = false, message = "Invalid data" }); - var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId); for (int i = 0; i < dto.OrderedIds.Count; i++) { @@ -1297,7 +1300,8 @@ public class CompanySettingsController : Controller { try { - var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId); var sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList(); var dtos = _mapper.Map>(sortedStatuses); @@ -1478,7 +1482,8 @@ public class CompanySettingsController : Controller if (!ModelState.IsValid) return Json(new { success = false, message = "Invalid data" }); - var statuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var statuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId); for (int i = 0; i < dto.OrderedIds.Count; i++) { @@ -1517,7 +1522,8 @@ public class CompanySettingsController : Controller { try { - var services = await _unitOfWork.PrepServices.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId); var sortedServices = services.OrderBy(s => s.DisplayOrder).ToList(); var dtos = _mapper.Map>(sortedServices); @@ -1639,7 +1645,8 @@ public class CompanySettingsController : Controller if (!ModelState.IsValid) return Json(new { success = false, message = "Invalid data" }); - var services = await _unitOfWork.PrepServices.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var services = await _unitOfWork.PrepServices.FindAsync(s => s.CompanyId == companyId); for (int i = 0; i < dto.OrderedIds.Count; i++) { @@ -1812,7 +1819,8 @@ public class CompanySettingsController : Controller { try { - var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId); var sortedTypes = types.OrderBy(t => t.DisplayOrder).ToList(); var dtos = _mapper.Map>(sortedTypes); @@ -1956,7 +1964,8 @@ public class CompanySettingsController : Controller if (!ModelState.IsValid) return Json(new { success = false, message = "Invalid data" }); - var types = await _unitOfWork.AppointmentTypeLookups.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var types = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId); for (int i = 0; i < dto.OrderedIds.Count; i++) { @@ -1996,7 +2005,8 @@ public class CompanySettingsController : Controller { try { - var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId); var sortedCategories = categories.OrderBy(c => c.DisplayOrder).ToList(); var dtos = _mapper.Map>(sortedCategories); @@ -2132,7 +2142,8 @@ public class CompanySettingsController : Controller if (!ModelState.IsValid) return Json(new { success = false, message = "Invalid data" }); - var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId); for (int i = 0; i < dto.OrderedIds.Count; i++) { @@ -2349,12 +2360,12 @@ public class CompanySettingsController : Controller if (companyId == null) return RedirectToAction(nameof(Index)); // Load all existing templates for this company - var existing = await _unitOfWork.NotificationTemplates.GetAllAsync(); + var existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value); // Auto-seed any missing canonical combinations var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList()); if (seeded > 0) - existing = await _unitOfWork.NotificationTemplates.GetAllAsync(); + existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value); var dtos = existing.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel) .Select(t => new NotificationTemplateDto diff --git a/src/PowderCoating.Web/Controllers/CreditMemosController.cs b/src/PowderCoating.Web/Controllers/CreditMemosController.cs index 1cc6137..012c485 100644 --- a/src/PowderCoating.Web/Controllers/CreditMemosController.cs +++ b/src/PowderCoating.Web/Controllers/CreditMemosController.cs @@ -315,7 +315,8 @@ public class CreditMemosController : Controller private async Task PopulateCustomersAsync(int? selectedId) { - var customers = await _unitOfWork.Customers.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId); ViewBag.Customers = customers .OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim()) .Select(c => new SelectListItem diff --git a/src/PowderCoating.Web/Controllers/DashboardController.cs b/src/PowderCoating.Web/Controllers/DashboardController.cs index e450755..6ff55dc 100644 --- a/src/PowderCoating.Web/Controllers/DashboardController.cs +++ b/src/PowderCoating.Web/Controllers/DashboardController.cs @@ -342,14 +342,16 @@ public class DashboardController : Controller TipOfTheDay = data.TipOfTheDay }; + // Resolve company once so all remaining queries are explicitly scoped + var currentCompanyId = _tenantContext.GetCurrentCompanyId(); + var companyId = currentCompanyId ?? 0; + // Dropdowns for the "Add Custom Powder to Inventory" modal - var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.GetAllAsync()) - .Where(c => c.IsActive) + var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsActive && c.CompanyId == companyId)) .OrderBy(c => c.DisplayOrder) .Select(c => new { c.Id, c.DisplayName }) .ToList(); - var vendors = (await _unitOfWork.Vendors.GetAllAsync()) - .Where(v => v.IsActive) + var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive && v.CompanyId == companyId)) .OrderBy(v => v.CompanyName) .Select(v => new { v.Id, v.CompanyName }) .ToList(); @@ -357,7 +359,6 @@ public class DashboardController : Controller ViewBag.VendorList = vendors; // Config health check — surface setup gaps to company admins - var currentCompanyId = _tenantContext.GetCurrentCompanyId(); if (currentCompanyId.HasValue) { ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value); @@ -711,8 +712,8 @@ public class DashboardController : Controller i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job); var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0; - // Check SKU uniqueness - if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim())) + // Check SKU uniqueness within this company + if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim() && i.CompanyId == companyId)) return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." }); // Determine category display name for legacy field diff --git a/src/PowderCoating.Web/Controllers/InventoryController.cs b/src/PowderCoating.Web/Controllers/InventoryController.cs index 107f72e..694a917 100644 --- a/src/PowderCoating.Web/Controllers/InventoryController.cs +++ b/src/PowderCoating.Web/Controllers/InventoryController.cs @@ -160,7 +160,8 @@ public class InventoryController : Controller var pagedResult = PagedResult.From(gridRequest, itemDtos, totalCount); // Load all items once to compute sidebar stats and category list in memory - var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allItems = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList(); ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList(); ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint); ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive); @@ -1106,7 +1107,8 @@ public class InventoryController : Controller // Build a set of SKUs already in this company's inventory so we can exclude them. // When editing, the current item's own SKU is re-included so its catalog entry still appears. - var existingItems = await _unitOfWork.InventoryItems.GetAllAsync(); + var skuCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var existingItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == skuCompanyId); var existingSkus = existingItems .Where(i => !string.IsNullOrWhiteSpace(i.ManufacturerPartNumber) && i.Id != (currentId ?? 0)) .Select(i => i.ManufacturerPartNumber!.Trim().ToLower()) @@ -1182,7 +1184,7 @@ public class InventoryController : Controller var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; // Find the default coating category to assign - var categories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync(); + var categories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId); var coatingCategory = categories .Where(c => c.IsActive && c.IsCoating) .OrderBy(c => c.DisplayOrder) @@ -1369,11 +1371,11 @@ public class InventoryController : Controller var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId); - var vendors = await _unitOfWork.Vendors.GetAllAsync(); + var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId); ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName"); // Load categories from lookup table - var allCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync(); + var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId); var categories = allCategories .Where(c => c.IsActive) .OrderBy(c => c.DisplayOrder) @@ -1738,7 +1740,8 @@ public class InventoryController : Controller DateTime? dateTo, string? typeFilter) { - var allItems = await _unitOfWork.InventoryItems.GetAllAsync(); + var ledgerCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allItems = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == ledgerCompanyId); var itemList = allItems .Where(i => i.IsActive || i.QuantityOnHand > 0) .OrderBy(i => i.Name) diff --git a/src/PowderCoating.Web/Controllers/InvoicesController.cs b/src/PowderCoating.Web/Controllers/InvoicesController.cs index c44f985..ddb3537 100644 --- a/src/PowderCoating.Web/Controllers/InvoicesController.cs +++ b/src/PowderCoating.Web/Controllers/InvoicesController.cs @@ -2213,7 +2213,7 @@ public class InvoicesController : Controller /// private async Task PopulateCreateViewBagAsync(int companyId, string? selectedTerms = null) { - var customers = await _unitOfWork.Customers.GetAllAsync(); + var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId); ViewBag.Customers = customers.Where(c => c.IsActive).OrderBy(c => c.CompanyName ?? c.ContactLastName).ToList(); // Expose company default tax rate and exempt customer IDs for client-side tax handling diff --git a/src/PowderCoating.Web/Controllers/JobTemplatesController.cs b/src/PowderCoating.Web/Controllers/JobTemplatesController.cs index 9e15bfc..c0876a5 100644 --- a/src/PowderCoating.Web/Controllers/JobTemplatesController.cs +++ b/src/PowderCoating.Web/Controllers/JobTemplatesController.cs @@ -36,7 +36,9 @@ public class JobTemplatesController : Controller /// public async Task Index() { - var templates = await _unitOfWork.JobTemplates.GetAllAsync( + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var templates = await _unitOfWork.JobTemplates.FindAsync( + t => t.CompanyId == companyId, false, t => t.Customer, t => t.Items); diff --git a/src/PowderCoating.Web/Controllers/JobsController.cs b/src/PowderCoating.Web/Controllers/JobsController.cs index 896a931..c160ee5 100644 --- a/src/PowderCoating.Web/Controllers/JobsController.cs +++ b/src/PowderCoating.Web/Controllers/JobsController.cs @@ -499,7 +499,7 @@ public class JobsController : Controller ViewBag.MaterialsUsed = allJobTransactions; // Inventory items for the manual log-material modal - var inventoryItemsForModal = (await _unitOfWork.InventoryItems.GetAllAsync()) + var inventoryItemsForModal = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == job.CompanyId)) .OrderBy(i => i.Name) .Select(i => new { i.Id, i.Name, i.Manufacturer, i.UnitOfMeasure, i.QuantityOnHand }) .ToList(); @@ -528,7 +528,7 @@ public class JobsController : Controller ViewBag.JobPhotoMax = photoMax; // Customer list for inline customer-change dropdown - var allCustomers = await _unitOfWork.Customers.GetAllAsync(); + var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == job.CompanyId); ViewBag.CustomerSelectList = allCustomers .Where(c => c.IsActive) .Select(c => new SelectListItem @@ -634,7 +634,8 @@ public class JobsController : Controller if (job == null) return NotFound(); - var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync()) + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allStatuses = (await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId)) .OrderBy(s => s.DisplayOrder).ToList(); ViewBag.AllStatuses = allStatuses; @@ -657,7 +658,7 @@ public class JobsController : Controller if (job == null) return NotFound(); - var allStatuses = (await _unitOfWork.JobStatusLookups.GetAllAsync()).ToList(); + var allStatuses = (await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == job.CompanyId)).ToList(); var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId); if (newStatus == null) return BadRequest("Invalid status."); @@ -845,7 +846,7 @@ public class JobsController : Controller // Optionally advance status to In Preparation if (advanceToInPreparation && jobToUpdate.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation) { - var allStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync(); + var allStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == jobToUpdate.CompanyId); var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation); if (inPrepStatus != null) { @@ -902,7 +903,7 @@ public class JobsController : Controller if (advanceToInPreparation && job.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation && !job.JobStatus.IsTerminalStatus) { - var allStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync(); + var allStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == job.CompanyId); var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation); if (inPrepStatus != null) { @@ -1809,7 +1810,7 @@ public class JobsController : Controller ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId); await PopulateDropdowns(); - await PopulatePrepServicesAsync(); + await PopulatePrepServicesAsync(companyId); var costs = await _pricingService.GetOperatingCostsAsync(companyId); await PopulateJobItemDropDownsAsync(companyId, costs?.OvenOperatingCostPerHour ?? 45m); ViewBag.TaxPercent = costs?.TaxPercent ?? 0m; @@ -1829,7 +1830,9 @@ public class JobsController : Controller /// private async Task PopulateDropdowns() { - var customers = await _unitOfWork.Customers.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + + var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId); ViewBag.Customers = new SelectList( customers.Where(c => c.IsActive).Select(c => new { @@ -1840,8 +1843,6 @@ public class JobsController : Controller }).OrderBy(c => c.DisplayName), "Id", "DisplayName"); - - var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var users = await _userManager.Users .Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null) .OrderBy(u => u.FirstName).ThenBy(u => u.LastName) @@ -2223,13 +2224,13 @@ public class JobsController : Controller /// Loads all active prep services into ViewBag for the item wizard's prep services step. /// Prep services are ordered by DisplayOrder so they appear in the intended workflow sequence. /// - private async Task PopulatePrepServicesAsync() + private async Task PopulatePrepServicesAsync(int companyId) { - var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive); + var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId); ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList(); _logger.LogInformation("Populated {Count} active prep services", prepServices.Count()); - var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive); + var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId); ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder) .Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault }) .ToList(); @@ -3166,7 +3167,7 @@ public class JobsController : Controller /// private async Task PopulateJobItemDropDownsAsync(int companyId, decimal fallbackOvenRate) { - var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory); + var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.InventoryCategory); ViewBag.InventoryCoatings = inventory .Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating) .OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name) @@ -3186,12 +3187,12 @@ public class JobsController : Controller isIncoming = i.IsIncoming }).ToList(); - var vendors = await _unitOfWork.Vendors.GetAllAsync(false); + var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, false); ViewBag.Vendors = vendors .Where(s => s.IsActive).OrderBy(s => s.CompanyName) .Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList(); - var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category, i => i.Category.ParentCategory); + var catalogItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId, false, i => i.Category, i => i.Category.ParentCategory); ViewBag.CatalogItems = catalogItems .Where(i => i.IsActive) .OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder) @@ -3220,10 +3221,10 @@ public class JobsController : Controller description = i.Description }).ToList(); - var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive); + var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId); ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList(); - var blastSetupsForEditItems = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive); + var blastSetupsForEditItems = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId); ViewBag.BlastSetups = blastSetupsForEditItems.OrderBy(b => b.DisplayOrder) .Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault }) .ToList(); diff --git a/src/PowderCoating.Web/Controllers/JobsPriorityController.cs b/src/PowderCoating.Web/Controllers/JobsPriorityController.cs index f4b6957..0772de7 100644 --- a/src/PowderCoating.Web/Controllers/JobsPriorityController.cs +++ b/src/PowderCoating.Web/Controllers/JobsPriorityController.cs @@ -90,8 +90,8 @@ public class JobsPriorityController : Controller .ToList(); // Get priorities and workers for modal options - var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync(); 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) .OrderBy(u => u.FirstName).ThenBy(u => u.LastName) diff --git a/src/PowderCoating.Web/Controllers/MaintenanceController.cs b/src/PowderCoating.Web/Controllers/MaintenanceController.cs index 24e6eca..102af4b 100644 --- a/src/PowderCoating.Web/Controllers/MaintenanceController.cs +++ b/src/PowderCoating.Web/Controllers/MaintenanceController.cs @@ -16,15 +16,18 @@ public class MaintenanceController : Controller { private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; + private readonly ITenantContext _tenantContext; private readonly ILogger _logger; public MaintenanceController( IUnitOfWork unitOfWork, IMapper mapper, + ITenantContext tenantContext, ILogger logger) { _unitOfWork = unitOfWork; _mapper = mapper; + _tenantContext = tenantContext; _logger = logger; } @@ -740,7 +743,8 @@ public class MaintenanceController : Controller /// private async Task PopulateViewBagAsync(int? selectedEquipmentId = null) { - var equipment = await _unitOfWork.Equipment.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId); ViewBag.EquipmentList = new SelectList( equipment.Where(e => e.IsActive).OrderBy(e => e.EquipmentName), "Id", diff --git a/src/PowderCoating.Web/Controllers/OvenSchedulerController.cs b/src/PowderCoating.Web/Controllers/OvenSchedulerController.cs index 8e8c873..3726b97 100644 --- a/src/PowderCoating.Web/Controllers/OvenSchedulerController.cs +++ b/src/PowderCoating.Web/Controllers/OvenSchedulerController.cs @@ -179,8 +179,9 @@ public class OvenSchedulerController : Controller public async Task Suggest([FromBody] SuggestRequest req) { var goal = req?.OptimizationGoal ?? "maximize_throughput"; + var suggestCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; - var equipmentList = (await _unitOfWork.OvenCosts.GetAllAsync()) + var equipmentList = (await _unitOfWork.OvenCosts.FindAsync(o => o.CompanyId == suggestCompanyId)) .Where(o => o.IsActive) .OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label) .ToList(); @@ -188,10 +189,11 @@ public class OvenSchedulerController : Controller if (!equipmentList.Any()) return Json(new { success = false, error = "No active ovens found. Add Named Ovens in Settings → Operating Costs." }); - var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync(); + var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == suggestCompanyId); var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45; - var queueJobs = (await _unitOfWork.Jobs.GetAllAsync( + var queueJobs = (await _unitOfWork.Jobs.FindAsync( + j => j.CompanyId == suggestCompanyId, false, j => j.Customer, j => j.JobStatus, @@ -265,7 +267,8 @@ public class OvenSchedulerController : Controller if (req?.Batches == null || !req.Batches.Any()) return Json(new { success = false, error = "No batches provided." }); - var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync(); + var acceptCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == acceptCompanyId); var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45; var createdBatches = new List(); @@ -357,7 +360,8 @@ public class OvenSchedulerController : Controller if (oven == null) return Json(new { success = false, error = "Oven not found." }); - var companyCosts = await _unitOfWork.CompanyOperatingCosts.GetAllAsync(); + var createBatchCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var companyCosts = await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == createBatchCompanyId); var defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45; var batchNumber = await GenerateBatchNumberAsync(); @@ -651,7 +655,8 @@ public class OvenSchedulerController : Controller if (inOvenStatus != null) { var jobIds = batch.Items.Select(i => i.JobId).Distinct().ToHashSet(); - var jobs = (await _unitOfWork.Jobs.GetAllAsync()).Where(j => jobIds.Contains(j.Id)); + var startBatchCid = _tenantContext.GetCurrentCompanyId() ?? 0; + var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == startBatchCid && jobIds.Contains(j.Id)); foreach (var job in jobs) job.JobStatusId = inOvenStatus.Id; } diff --git a/src/PowderCoating.Web/Controllers/PricingTiersController.cs b/src/PowderCoating.Web/Controllers/PricingTiersController.cs index a804ed1..7f8a6fe 100644 --- a/src/PowderCoating.Web/Controllers/PricingTiersController.cs +++ b/src/PowderCoating.Web/Controllers/PricingTiersController.cs @@ -14,12 +14,14 @@ public class PricingTiersController : Controller private readonly IUnitOfWork _unitOfWork; private readonly IMapper _mapper; private readonly ILogger _logger; + private readonly ITenantContext _tenantContext; - public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger logger) + public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger logger, ITenantContext tenantContext) { _unitOfWork = unitOfWork; _mapper = mapper; _logger = logger; + _tenantContext = tenantContext; } /// @@ -27,8 +29,9 @@ public class PricingTiersController : Controller /// public async Task Index() { - var tiers = await _unitOfWork.PricingTiers.GetAllAsync(); - var customers = await _unitOfWork.Customers.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId); + var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId); var customerCountByTier = customers .Where(c => c.PricingTierId.HasValue) diff --git a/src/PowderCoating.Web/Controllers/QuotesController.cs b/src/PowderCoating.Web/Controllers/QuotesController.cs index 371d85d..986ace2 100644 --- a/src/PowderCoating.Web/Controllers/QuotesController.cs +++ b/src/PowderCoating.Web/Controllers/QuotesController.cs @@ -255,7 +255,7 @@ public class QuotesController : Controller // Calibration nudge — suppress when named blast setups exist OR legacy CFM is set var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault(); - var hasNamedSetups = (await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive)).Any(); + var hasNamedSetups = (await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId)).Any(); ViewBag.QuotingNotCalibrated = costs != null && !hasNamedSetups && costs.CompressorCfm == 0 @@ -441,7 +441,7 @@ public class QuotesController : Controller ViewBag.Deposits = quoteDeposits; // Customer list for inline customer-change dropdown - var allCustomers = await _unitOfWork.Customers.GetAllAsync(); + var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == quote.CompanyId); ViewBag.CustomerSelectList = allCustomers .Where(c => c.IsActive) .Select(c => new SelectListItem @@ -2430,7 +2430,7 @@ public class QuotesController : Controller ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan // Customers - var customers = await _unitOfWork.Customers.GetAllAsync(); + var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId); ViewBag.Customers = customers .Select(c => new SelectListItem { @@ -2471,7 +2471,7 @@ public class QuotesController : Controller } // Inventory coatings — include incoming items so they can be quoted while powder is in transit - var inventory = await _unitOfWork.InventoryItems.GetAllAsync(false, i => i.InventoryCategory); + var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId, false, i => i.InventoryCategory); ViewBag.InventoryCoatings = inventory .Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating) .OrderBy(i => i.IsIncoming ? 1 : 0).ThenBy(i => i.InventoryCategory!.DisplayOrder).ThenBy(i => i.ColorName ?? i.Name) @@ -2492,13 +2492,13 @@ public class QuotesController : Controller }).ToList(); // Vendors - var vendors = await _unitOfWork.Vendors.GetAllAsync(false); + var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, false); ViewBag.Vendors = vendors .Where(s => s.IsActive).OrderBy(s => s.CompanyName) .Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList(); // Catalog items - var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(false, i => i.Category, i => i.Category.ParentCategory); + var catalogItems = await _unitOfWork.CatalogItems.FindAsync(i => i.CompanyId == companyId, false, i => i.Category, i => i.Category.ParentCategory); ViewBag.CatalogItems = catalogItems .Where(i => i.IsActive) .OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder) @@ -2528,11 +2528,11 @@ public class QuotesController : Controller }).ToList(); // Prep services - var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive); + var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.IsActive && ps.CompanyId == companyId); ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList(); // Blast setups for wizard dropdown - var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive); + var blastSetups = await _unitOfWork.BlastSetups.FindAsync(b => b.IsActive && b.CompanyId == companyId); ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder) .Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault }) .ToList(); @@ -2599,7 +2599,8 @@ public class QuotesController : Controller /// private async Task PopulatePricingTiersDropDownAsync() { - var pricingTiers = await _unitOfWork.PricingTiers.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var pricingTiers = await _unitOfWork.PricingTiers.FindAsync(pt => pt.CompanyId == companyId); ViewBag.PricingTiers = pricingTiers.OrderBy(pt => pt.TierName) .Select(pt => new SelectListItem { @@ -2825,9 +2826,9 @@ public class QuotesController : Controller // Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning // no-tracking children (which may share InventoryItem instances) causes EF identity conflicts. - // Get default job statuses and priorities - var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync(); - var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync(); + // Get default job statuses and priorities — scope to quote's company for defense-in-depth + var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == quote.CompanyId); + var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == quote.CompanyId); var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Approved); var normalPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "NORMAL"); var rushPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "RUSH"); @@ -3347,7 +3348,7 @@ public class QuotesController : Controller CompanyBlastSetup? selectedBlastSetup = null; if (request.BlastSetupId.HasValue) { - var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive); + var setups = await _unitOfWork.BlastSetups.FindAsync(b => b.Id == request.BlastSetupId.Value && b.IsActive && b.CompanyId == companyId); selectedBlastSetup = setups.FirstOrDefault(); } diff --git a/src/PowderCoating.Web/Controllers/RecurringTemplatesController.cs b/src/PowderCoating.Web/Controllers/RecurringTemplatesController.cs index 70f6d30..00b6fb8 100644 --- a/src/PowderCoating.Web/Controllers/RecurringTemplatesController.cs +++ b/src/PowderCoating.Web/Controllers/RecurringTemplatesController.cs @@ -44,7 +44,8 @@ public class RecurringTemplatesController : Controller /// Lists all recurring templates for the current company, active first then by name. public async Task Index() { - var templates = await _unitOfWork.RecurringTemplates.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var templates = await _unitOfWork.RecurringTemplates.FindAsync(t => t.CompanyId == companyId); return View(templates.OrderByDescending(t => t.IsActive).ThenBy(t => t.Name).ToList()); } @@ -425,11 +426,12 @@ public class RecurringTemplatesController : Controller /// Loads dropdowns for vendors, accounts, and payment methods into ViewBag. private async Task PopulateDropDownsAsync() { - var vendors = await _unitOfWork.Vendors.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId); ViewBag.Vendors = vendors.OrderBy(v => v.CompanyName) .Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())).ToList(); - var accounts = await _unitOfWork.Accounts.GetAllAsync(); + var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId); ViewBag.APAccounts = accounts .Where(a => a.AccountSubType == AccountSubType.AccountsPayable) .OrderBy(a => a.AccountNumber) diff --git a/src/PowderCoating.Web/Controllers/ReportsController.cs b/src/PowderCoating.Web/Controllers/ReportsController.cs index 3b21eb8..725cb0e 100644 --- a/src/PowderCoating.Web/Controllers/ReportsController.cs +++ b/src/PowderCoating.Web/Controllers/ReportsController.cs @@ -11,6 +11,7 @@ using PowderCoating.Core.Interfaces; using PowderCoating.Core.Entities; using PowderCoating.Shared.Constants; using PowderCoating.Web.ViewModels.Reports; +using System.Security.Claims; namespace PowderCoating.Web.Controllers; @@ -25,8 +26,9 @@ public class ReportsController : Controller private readonly UserManager _userManager; private readonly IAccountingAiService _accountingAi; private readonly IAiUsageLogger _usageLogger; + private readonly ITenantContext _tenantContext; - public ReportsController(IUnitOfWork unitOfWork, ILogger logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger) + public ReportsController(IUnitOfWork unitOfWork, ILogger logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger, ITenantContext tenantContext) { _unitOfWork = unitOfWork; _logger = logger; @@ -36,6 +38,7 @@ public class ReportsController : Controller _userManager = userManager; _accountingAi = accountingAi; _usageLogger = usageLogger; + _tenantContext = tenantContext; } /// @@ -79,27 +82,26 @@ public class ReportsController : Controller var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" }; + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; - // Load only necessary data - optimized with filtering and minimal eager loading - // Jobs: Load all jobs (we need various status filters and the collection is needed for job status distribution) - // Note: Date filtering would exclude data needed for jobsByStatus calculation - var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList(); + // Load only necessary data — all explicitly scoped to this company + var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList(); // Quotes: Load all quotes (needed for quote status distribution and conversion funnel) - var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus)).ToList(); + var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.Customer, q => q.QuoteStatus)).ToList(); // Customers: Load all (needed for active count and customer creation trend across all months) - var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList(); + var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList(); // Equipment: Load all for status distribution - var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList(); + var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList(); // Inventory: Load all for low stock analysis - var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList(); + var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList(); // Appointments: Filter to relevant date range at DB level var appointments = (await _unitOfWork.Appointments.FindAsync( - a => a.ScheduledStartTime >= startDate, + a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.Customer, a => a.AppointmentType, @@ -108,7 +110,7 @@ public class ReportsController : Controller // Users with assigned jobs/appointments will be loaded below when building worker stats // CatalogItems: Load all for category distribution - var catalogItems = (await _unitOfWork.CatalogItems.GetAllAsync(false, c => c.Category)).ToList(); + var catalogItems = (await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId, false, c => c.Category)).ToList(); // === OVERVIEW METRICS === var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); @@ -382,7 +384,7 @@ public class ReportsController : Controller .ToDictionary(g => g.Key, g => g.Count()); // === FINANCIAL ANALYTICS === - var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList(); + var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList(); var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList(); var totalInvoiced = activeInvoices.Sum(i => i.Total); @@ -781,7 +783,7 @@ public class ReportsController : Controller // === POWDER CONSUMPTION VS PURCHASE === var allInventoryTransactions = (await _unitOfWork.InventoryTransactions - .GetAllAsync(false, t => t.InventoryItem)) + .FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem)) .ToList(); var powderConsumptionItems = allInventoryTransactions @@ -1309,14 +1311,15 @@ public class ReportsController : Controller var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" }; + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; - var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList(); - var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList(); - var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList(); - var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList(); - var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList(); - var allAppointments = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentStatus); - var appointments = allAppointments.Where(a => a.ScheduledStartTime >= startDate).ToList(); + var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList(); + var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList(); + var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList(); + var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList(); + var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList(); + var allAppointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentStatus); + var appointments = allAppointments.ToList(); var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); @@ -1384,7 +1387,8 @@ public class ReportsController : Controller var now = DateTime.UtcNow; var startDate = now.AddMonths(-months); var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; - var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).ToList(); var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var inRange = completedJobs.Where(j => j.UpdatedAt >= startDate).ToList(); var byMonth = inRange.GroupBy(j => new DateTime(j.UpdatedAt!.Value.Year, j.UpdatedAt.Value.Month, 1)).ToDictionary(g => g.Key, g => g.ToList()); @@ -1430,12 +1434,13 @@ public class ReportsController : Controller var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" }; + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; - var jobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList(); - var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList(); - var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList(); - var allAppts = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentType, a => a.AppointmentStatus); - var appointments = allAppts.Where(a => a.ScheduledStartTime >= startDate).ToList(); + var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList(); + var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList(); + var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList(); + var allAppts = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentType, a => a.AppointmentStatus); + var appointments = allAppts.ToList(); var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); @@ -1483,10 +1488,11 @@ public class ReportsController : Controller var now = DateTime.UtcNow; var startDate = now.AddMonths(-months); var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; - var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList(); - var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList(); - var catalogItems = (await _unitOfWork.CatalogItems.GetAllAsync(false, c => c.Category)).ToList(); - var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)) + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList(); + var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList(); + var catalogItems = (await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId, false, c => c.Category)).ToList(); + var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)) .Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var customersByMonth = customers.Where(c => c.CreatedAt >= startDate).GroupBy(c => new DateTime(c.CreatedAt.Year, c.CreatedAt.Month, 1)).ToDictionary(g => g.Key, g => g.Count()); @@ -1523,7 +1529,8 @@ public class ReportsController : Controller if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); var now = DateTime.UtcNow; var today = DateTime.Today; - var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList(); var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList(); var outstandingInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.Total > i.AmountPaid).ToList(); var overdueInvoices = activeInvoices.Where(i => i.Status != InvoiceStatus.Paid && i.DueDate.HasValue && i.DueDate.Value < today).ToList(); @@ -1574,7 +1581,8 @@ public class ReportsController : Controller var monthLabels = new List(); var monthlyBillsPaid = new List(); var monthlyDirectExpenses = new List(); // Also load collected payments for P&L comparison - var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Payments)).ToList(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Payments)).ToList(); var paymentsByMonth = allInvoices.SelectMany(i => i.Payments.Where(p => !p.IsDeleted)).GroupBy(p => new DateTime(p.PaymentDate.Year, p.PaymentDate.Month, 1)).ToDictionary(g => g.Key, g => g.ToList()); var plRevenue = new List(); var plExpenses = new List(); var plNet = new List(); for (var i = months - 1; i >= 0; i--) @@ -1609,8 +1617,10 @@ public class ReportsController : Controller { var now = DateTime.UtcNow; var startDate = now.AddMonths(-months); - var powderTransactions = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)) - .Where(t => t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate).ToList(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var powderTransactions = (await _unitOfWork.InventoryTransactions.FindAsync( + t => t.CompanyId == companyId && t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate, false, t => t.InventoryItem)) + .ToList(); var topColors = powderTransactions.Where(t => t.InventoryItem != null).GroupBy(t => t.InventoryItemId) .Select(g => new PowderUsageByColorItem { InventoryItemId = g.Key, ColorName = g.First().InventoryItem!.ColorName ?? g.First().InventoryItem.Name, ColorCode = g.First().InventoryItem!.ColorCode, SKU = g.First().InventoryItem!.SKU, Manufacturer = g.First().InventoryItem!.Manufacturer, TotalLbsUsed = g.Sum(t => Math.Abs(t.Quantity)), TotalCost = g.Sum(t => Math.Abs(t.TotalCost)), JobCount = g.Where(t => !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() }) @@ -1631,7 +1641,8 @@ public class ReportsController : Controller /// Sales by Customer report — all active (non-voided) invoices grouped by customer, sorted by total invoiced. public async Task SalesByCustomer(int months = 6) { - var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList(); var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList(); var items = activeInvoices.Where(i => i.Customer != null) .GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(), i.Customer.IsCommercial }) @@ -1650,8 +1661,9 @@ public class ReportsController : Controller { var now = DateTime.UtcNow; var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; - var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList(); - var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList(); + var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var items = customers.Where(c => c.IsActive).Select(c => { var cJobs = completedJobs.Where(j => j.CustomerId == c.Id).ToList(); @@ -1682,7 +1694,8 @@ public class ReportsController : Controller { var now = DateTime.UtcNow; var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; - var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var completedJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList(); var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync(); var historyByJob = allStatusHistory.GroupBy(h => h.JobId).ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList()); var statusDisplayOrder = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" }; @@ -1720,7 +1733,8 @@ public class ReportsController : Controller var now = DateTime.UtcNow; var today = DateTime.Today; var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" }; - var activeJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var activeJobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)).Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var items = activeJobs.Select(j => new JobStatusAgingItem { JobId = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer?.IsCommercial == true ? j.Customer.CompanyName ?? "Unknown" : $"{j.Customer?.ContactFirstName} {j.Customer?.ContactLastName}".Trim(), @@ -1740,7 +1754,8 @@ public class ReportsController : Controller { if (!AllowAccounting()) return RedirectToAction(nameof(Landing)); var today = DateTime.Today; - var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList(); var items = allInvoices.Where(i => i.Customer != null && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid) .Select(i => { @@ -1758,7 +1773,8 @@ public class ReportsController : Controller /// public async Task PowderConsumption(int months = 6) { - var allTx = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)).ToList(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allTx = (await _unitOfWork.InventoryTransactions.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem)).ToList(); var items = allTx.Where(t => t.InventoryItem != null) .GroupBy(t => new { t.InventoryItemId, t.InventoryItem!.Name, t.InventoryItem.SKU, t.InventoryItem.ColorName, t.InventoryItem.ColorCode, t.InventoryItem.Manufacturer }) .Select(g => new PowderConsumptionItem { InventoryItemId = g.Key.InventoryItemId, ItemName = g.Key.Name, SKU = g.Key.SKU, ColorName = g.Key.ColorName, ColorCode = g.Key.ColorCode, Manufacturer = g.Key.Manufacturer, TotalPurchasedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.Purchase || t.TransactionType == InventoryTransactionType.Initial).Sum(t => t.Quantity), TotalConsumedLbs = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage || t.TransactionType == InventoryTransactionType.Waste).Sum(t => Math.Abs(t.Quantity)), PurchaseCount = g.Count(t => t.TransactionType == InventoryTransactionType.Purchase), UsageJobCount = g.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && !string.IsNullOrEmpty(t.Reference)).Select(t => t.Reference).Distinct().Count() }) @@ -1776,8 +1792,9 @@ public class ReportsController : Controller public async Task InventoryTurnover(int months = 6) { var daysInPeriod = months * 30.0; - var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList(); - var allTx = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)).ToList(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList(); + var allTx = (await _unitOfWork.InventoryTransactions.FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem)).ToList(); var items = inventory.Where(i => i.IsActive).Select(i => { var iTx = allTx.Where(t => t.InventoryItemId == i.Id).ToList(); @@ -1835,8 +1852,9 @@ public class ReportsController : Controller var now = DateTime.UtcNow; var today = DateTime.Today; + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; // Load invoices for AR data - var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList(); + var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList(); var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList(); var outstandingInvoices = activeInvoices.Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Paid).ToList(); @@ -1930,13 +1948,14 @@ public class ReportsController : Controller var companyName = await GetCompanyNameAsync(); var today = DateTime.Today; + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; // Open AR invoices - var openInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)) + var openInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)) .Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid) .ToList(); // Compute avg days to pay per customer from paid invoices - var paidInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Payments)) + var paidInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Payments)) .Where(i => i.Status == InvoiceStatus.Paid && i.InvoiceDate != default) .ToList(); var avgDaysByCustomer = paidInvoices @@ -2137,7 +2156,8 @@ public class ReportsController : Controller var companyName = await GetCompanyNameAsync(); var today = DateTime.Today; - var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)).ToList(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)).ToList(); var activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList(); @@ -2256,8 +2276,9 @@ public class ReportsController : Controller var companyName = await GetCompanyNameAsync(); var now = DateTime.UtcNow; var startOfYear = new DateTime(now.Year, 1, 1); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; - var allInvoices = (await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Payments)) + var allInvoices = (await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId, false, i => i.Customer, i => i.Payments)) .Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff) .ToList(); diff --git a/src/PowderCoating.Web/Controllers/SmsConsentAuditController.cs b/src/PowderCoating.Web/Controllers/SmsConsentAuditController.cs index e969093..6d3481f 100644 --- a/src/PowderCoating.Web/Controllers/SmsConsentAuditController.cs +++ b/src/PowderCoating.Web/Controllers/SmsConsentAuditController.cs @@ -15,11 +15,13 @@ namespace PowderCoating.Web.Controllers; public class SmsConsentAuditController : Controller { private readonly IUnitOfWork _unitOfWork; + private readonly ITenantContext _tenantContext; private readonly ILogger _logger; - public SmsConsentAuditController(IUnitOfWork unitOfWork, ILogger logger) + public SmsConsentAuditController(IUnitOfWork unitOfWork, ITenantContext tenantContext, ILogger logger) { _unitOfWork = unitOfWork; + _tenantContext = tenantContext; _logger = logger; } @@ -30,7 +32,8 @@ public class SmsConsentAuditController : Controller { try { - var allCustomers = await _unitOfWork.Customers.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allCustomers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId); if (!string.IsNullOrWhiteSpace(search)) { @@ -98,7 +101,8 @@ public class SmsConsentAuditController : Controller { try { - var customers = (await _unitOfWork.Customers.GetAllAsync()) + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)) .OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName) .ToList(); diff --git a/src/PowderCoating.Web/Controllers/TaxRatesController.cs b/src/PowderCoating.Web/Controllers/TaxRatesController.cs index 0db3a78..c4438a4 100644 --- a/src/PowderCoating.Web/Controllers/TaxRatesController.cs +++ b/src/PowderCoating.Web/Controllers/TaxRatesController.cs @@ -32,7 +32,8 @@ public class TaxRatesController : Controller [HttpGet] public async Task Index() { - var rates = await _unitOfWork.TaxRates.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var rates = await _unitOfWork.TaxRates.FindAsync(r => r.CompanyId == companyId); return View(rates.OrderBy(r => r.Name).ToList()); } diff --git a/src/PowderCoating.Web/Controllers/ToolsController.cs b/src/PowderCoating.Web/Controllers/ToolsController.cs index 546ebb8..fe87206 100644 --- a/src/PowderCoating.Web/Controllers/ToolsController.cs +++ b/src/PowderCoating.Web/Controllers/ToolsController.cs @@ -87,7 +87,8 @@ public class ToolsController : Controller [HttpGet] public async Task GetImportAccounts() { - var allAccounts = await _unitOfWork.Accounts.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId); var revenue = allAccounts .Where(a => a.AccountType == AccountType.Revenue && a.IsActive) @@ -123,7 +124,8 @@ public class ToolsController : Controller /// private async Task PopulateImportAccountDropdownsAsync() { - var allAccounts = await _unitOfWork.Accounts.GetAllAsync(); + var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; + var allAccounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId); var revenueAccounts = allAccounts .Where(a => a.AccountType == AccountType.Revenue && a.IsActive) @@ -1102,7 +1104,7 @@ public class ToolsController : Controller // Validate account IDs belong to this company — stale page load can produce IDs // that were valid before a data reset but no longer exist. - var validAccountIds = (await _unitOfWork.Accounts.GetAllAsync()) + var validAccountIds = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value)) .Select(a => a.Id).ToHashSet(); if (revenueAccountId.HasValue && !validAccountIds.Contains(revenueAccountId.Value)) revenueAccountId = null; @@ -1167,7 +1169,7 @@ public class ToolsController : Controller // Validate account IDs belong to this company — stale page load can produce IDs // that were valid before a data reset but no longer exist. - var validAccountIds = (await _unitOfWork.Accounts.GetAllAsync()) + var validAccountIds = (await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value)) .Select(a => a.Id).ToHashSet(); if (inventoryAccountId.HasValue && !validAccountIds.Contains(inventoryAccountId.Value)) inventoryAccountId = null; @@ -1939,7 +1941,7 @@ public class ToolsController : Controller using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true)) { // 1. Customers - var customers = await _unitOfWork.Customers.GetAllAsync(); + var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId.Value); var customersCsv = GenerateCustomersCsv(customers); var customersEntry = archive.CreateEntry($"customers_{timestamp}.csv"); using (var entryStream = customersEntry.Open()) @@ -1949,7 +1951,7 @@ public class ToolsController : Controller } // 2. Quotes - var quotes = await _unitOfWork.Quotes.GetAllAsync(false, q => q.Customer, q => q.QuoteStatus); + var quotes = await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId.Value, false, q => q.Customer, q => q.QuoteStatus); var quotesCsv = GenerateQuotesCsv(quotes); var quotesEntry = archive.CreateEntry($"quotes_{timestamp}.csv"); using (var entryStream = quotesEntry.Open()) @@ -1959,7 +1961,7 @@ public class ToolsController : Controller } // 3. Jobs - var jobs = await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority); + var jobs = await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId.Value, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority); var jobsCsv = GenerateJobsCsv(jobs); var jobsEntry = archive.CreateEntry($"jobs_{timestamp}.csv"); using (var entryStream = jobsEntry.Open()) @@ -1969,7 +1971,7 @@ public class ToolsController : Controller } // 4. Appointments - var appointments = await _unitOfWork.Appointments.GetAllAsync(false, + var appointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId.Value, false, a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus); var appointmentsCsv = GenerateAppointmentsCsv(appointments); var appointmentsEntry = archive.CreateEntry($"appointments_{timestamp}.csv"); @@ -1980,9 +1982,9 @@ public class ToolsController : Controller } // 5. Catalog - var catalogCategories = await _unitOfWork.CatalogCategories.GetAllAsync(); + var catalogCategories = await _unitOfWork.CatalogCategories.FindAsync(cc => cc.CompanyId == companyId.Value); var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories); - var catalog = await _unitOfWork.CatalogItems.GetAllAsync(); + var catalog = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId.Value); var catalogCsv = GenerateCatalogCsv(catalog, catalogCategoryPaths); var catalogEntry = archive.CreateEntry($"catalog_{timestamp}.csv"); using (var entryStream = catalogEntry.Open()) @@ -1992,7 +1994,7 @@ public class ToolsController : Controller } // 6. Inventory - var inventory = await _unitOfWork.InventoryItems.GetAllAsync(); + var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId.Value); var inventoryCsv = GenerateInventoryCsv(inventory); var inventoryEntry = archive.CreateEntry($"inventory_{timestamp}.csv"); using (var entryStream = inventoryEntry.Open()) @@ -2002,7 +2004,7 @@ public class ToolsController : Controller } // 7. Equipment - var equipment = await _unitOfWork.Equipment.GetAllAsync(); + var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId.Value); var equipmentCsv = GenerateEquipmentCsv(equipment); var equipmentEntry = archive.CreateEntry($"equipment_{timestamp}.csv"); using (var entryStream = equipmentEntry.Open()) @@ -2012,7 +2014,7 @@ public class ToolsController : Controller } // 8. Maintenance - var maintenance = await _unitOfWork.MaintenanceRecords.GetAllAsync(false, m => m.Equipment); + var maintenance = await _unitOfWork.MaintenanceRecords.FindAsync(m => m.CompanyId == companyId.Value, false, m => m.Equipment); var maintenanceCsv = GenerateMaintenanceCsv(maintenance); var maintenanceEntry = archive.CreateEntry($"maintenance_{timestamp}.csv"); using (var entryStream = maintenanceEntry.Open()) @@ -2022,7 +2024,7 @@ public class ToolsController : Controller } // 9. Vendors - var vendors = await _unitOfWork.Vendors.GetAllAsync(); + var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId.Value); var vendorsCsv = GenerateVendorsCsv(vendors); var vendorsEntry = archive.CreateEntry($"vendors_{timestamp}.csv"); using (var entryStream = vendorsEntry.Open()) @@ -2032,7 +2034,7 @@ public class ToolsController : Controller } // 10. Prep Services - var prepServices = await _unitOfWork.PrepServices.GetAllAsync(); + var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.CompanyId == companyId.Value); var prepServicesCsv = GeneratePrepServicesCsv(prepServices); var prepServicesEntry = archive.CreateEntry($"prep_services_{timestamp}.csv"); using (var entryStream = prepServicesEntry.Open()) @@ -2042,7 +2044,7 @@ public class ToolsController : Controller } // 11. Invoices - var invoices = await _unitOfWork.Invoices.GetAllAsync(false, i => i.Customer, i => i.Job); + var invoices = await _unitOfWork.Invoices.FindAsync(i => i.CompanyId == companyId.Value, false, i => i.Customer, i => i.Job); var invoicesCsv = GenerateInvoicesCsv(invoices); var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv"); using (var entryStream = invoicesEntry.Open()) @@ -2052,7 +2054,7 @@ public class ToolsController : Controller } // 12. Chart of Accounts - var accounts = await _unitOfWork.Accounts.GetAllAsync(); + var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value); var accountsCsv = GenerateChartOfAccountsCsv(accounts); var accountsEntry = archive.CreateEntry($"chart_of_accounts_{timestamp}.csv"); using (var entryStream = accountsEntry.Open()) @@ -2062,7 +2064,7 @@ public class ToolsController : Controller } // 13. Expenses - var expenses = await _unitOfWork.Expenses.GetAllAsync(false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job); + var expenses = await _unitOfWork.Expenses.FindAsync(e => e.CompanyId == companyId.Value, false, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Vendor, e => e.Job); var expensesCsv = GenerateExpensesCsv(expenses); var expensesEntry = archive.CreateEntry($"expenses_{timestamp}.csv"); using (var entryStream = expensesEntry.Open()) @@ -2072,7 +2074,7 @@ public class ToolsController : Controller } // 14. Payments - var payments = await _unitOfWork.Payments.GetAllAsync(false, p => p.Invoice); + var payments = await _unitOfWork.Payments.FindAsync(p => p.CompanyId == companyId.Value, false, p => p.Invoice); var paymentsCsv = GeneratePaymentsCsv(payments); var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv"); using (var entryStream = paymentsEntry.Open()) @@ -2258,9 +2260,9 @@ public class ToolsController : Controller return RedirectToAction(nameof(Index)); } - var catalogCategories = await _unitOfWork.CatalogCategories.GetAllAsync(); + var catalogCategories = await _unitOfWork.CatalogCategories.FindAsync(cc => cc.CompanyId == companyId.Value); var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories); - var catalogItems = await _unitOfWork.CatalogItems.GetAllAsync(); + var catalogItems = await _unitOfWork.CatalogItems.FindAsync(ci => ci.CompanyId == companyId.Value); var csv = GenerateCatalogCsv(catalogItems, catalogCategoryPaths); var fileName = $"catalog_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; @@ -2326,7 +2328,7 @@ public class ToolsController : Controller return RedirectToAction(nameof(Index)); } - var equipment = await _unitOfWork.Equipment.GetAllAsync(); + var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId.Value); var csv = GenerateEquipmentCsv(equipment); var fileName = $"equipment_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; @@ -2407,13 +2409,13 @@ public class ToolsController : Controller return RedirectToAction(nameof(Index)); } - // Load all lookup tables - var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync(); - var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync(); - var quoteStatuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync(); - var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync(); - var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.GetAllAsync(); - var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.GetAllAsync(); + // Load all lookup tables — scoped to this company + var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId.Value); + var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId.Value); + var quoteStatuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId.Value); + var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId.Value); + var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.FindAsync(s => s.CompanyId == companyId.Value); + var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId.Value); var csv = GenerateCompanySettingsCsv(company, jobStatuses, jobPriorities, quoteStatuses, inventoryCategories, appointmentStatuses, appointmentTypes); @@ -4092,7 +4094,7 @@ public class ToolsController : Controller return RedirectToAction(nameof(Index)); } - var prepServices = await _unitOfWork.PrepServices.GetAllAsync(); + var prepServices = await _unitOfWork.PrepServices.FindAsync(ps => ps.CompanyId == companyId.Value); var csv = GeneratePrepServicesCsv(prepServices); var fileName = $"prep_services_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; @@ -4124,7 +4126,7 @@ public class ToolsController : Controller return RedirectToAction(nameof(Index)); } - var vendors = await _unitOfWork.Vendors.GetAllAsync(); + var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId.Value); var csv = GenerateVendorsCsv(vendors); var fileName = $"vendors_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; @@ -4156,7 +4158,7 @@ public class ToolsController : Controller return RedirectToAction(nameof(Index)); } - var accounts = await _unitOfWork.Accounts.GetAllAsync(); + var accounts = await _unitOfWork.Accounts.FindAsync(a => a.CompanyId == companyId.Value); var csv = GenerateChartOfAccountsCsv(accounts); var fileName = $"chart_of_accounts_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; diff --git a/tests/PowderCoating.UnitTests/MultiTenantIsolationTests.cs b/tests/PowderCoating.UnitTests/MultiTenantIsolationTests.cs new file mode 100644 index 0000000..37cf7d3 --- /dev/null +++ b/tests/PowderCoating.UnitTests/MultiTenantIsolationTests.cs @@ -0,0 +1,284 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Moq; +using PowderCoating.Core.Entities; +using PowderCoating.Core.Enums; +using PowderCoating.Core.Interfaces; +using PowderCoating.Infrastructure.Data; +using PowderCoating.Infrastructure.Repositories; +using PowderCoating.Web.Controllers; + +namespace PowderCoating.UnitTests; + +/// +/// Verifies that the explicit CompanyId == companyId predicates added to every +/// user-facing controller action actually prevent cross-tenant data leakage. +/// +/// Each test seeds entities for TWO companies, creates a controller whose ITenantContext +/// returns Company 1's ID, calls the action, and asserts that Company 2's data never +/// appears in the result. +/// +/// These tests validate the defense-in-depth layer (explicit predicates in controllers) +/// independently of the EF Core global query filters, which behave differently on the +/// in-memory provider when no HttpContext is present. +/// +public class MultiTenantIsolationTests +{ + // ── Repository-level isolation ──────────────────────────────────────────── + + /// + /// FindAsync with an explicit CompanyId predicate returns only the matching company's rows, + /// even when rows from other companies exist in the database. + /// + [Fact] + public async Task Repository_FindAsync_WithCompanyIdPredicate_ExcludesOtherTenants() + { + await using var context = CreateContext(); + context.Customers.Add(MakeCustomer(id: 1, companyId: 1, firstName: "Alice")); + context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Bob")); + context.Customers.Add(MakeCustomer(id: 3, companyId: 1, firstName: "Carol")); + await context.SaveChangesAsync(); + + var uow = new UnitOfWork(context); + var results = (await uow.Customers.FindAsync(c => c.CompanyId == 1)).ToList(); + + Assert.Equal(2, results.Count); + Assert.All(results, c => Assert.Equal(1, c.CompanyId)); + Assert.DoesNotContain(results, c => c.ContactFirstName == "Bob"); + } + + [Fact] + public async Task Repository_FindAsync_ReturnsEmpty_WhenNoMatchingCompanyId() + { + await using var context = CreateContext(); + context.Customers.Add(MakeCustomer(id: 1, companyId: 2, firstName: "Bob")); + await context.SaveChangesAsync(); + + var uow = new UnitOfWork(context); + var results = await uow.Customers.FindAsync(c => c.CompanyId == 1); + + Assert.Empty(results); + } + + // ── SmsConsentAuditController ───────────────────────────────────────────── + + [Fact] + public async Task SmsConsentAudit_Index_ReturnsOnlyCurrentCompanyCustomers() + { + await using var context = CreateContext(); + context.Customers.Add(MakeCustomer(id: 1, companyId: 1, firstName: "Alice")); + context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Bob")); // other company + context.Customers.Add(MakeCustomer(id: 3, companyId: 1, firstName: "Carol")); + await context.SaveChangesAsync(); + + var controller = new SmsConsentAuditController( + new UnitOfWork(context), + MockTenant(companyId: 1), + Mock.Of>()); + SetHttpContext(controller); + + var result = await controller.Index(); + + var view = Assert.IsType(result); + var vm = Assert.IsType(view.Model); + Assert.Equal(2, vm.TotalCount); + Assert.DoesNotContain(vm.Rows, r => r.CustomerName.Contains("Bob")); + } + + [Fact] + public async Task SmsConsentAudit_ExportCsv_ContainsOnlyCurrentCompanyCustomers() + { + await using var context = CreateContext(); + context.Customers.Add(MakeCustomer(id: 1, companyId: 1, firstName: "Alice")); + context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Bob")); + await context.SaveChangesAsync(); + + var controller = new SmsConsentAuditController( + new UnitOfWork(context), + MockTenant(companyId: 1), + Mock.Of>()); + SetHttpContext(controller); + + var result = await controller.ExportCsv(); + + var file = Assert.IsType(result); + var csv = System.Text.Encoding.UTF8.GetString(file.FileContents); + Assert.Contains("Alice", csv); + Assert.DoesNotContain("Bob", csv); + } + + // ── TaxRatesController ──────────────────────────────────────────────────── + + [Fact] + public async Task TaxRates_Index_ReturnsOnlyCurrentCompanyRates() + { + await using var context = CreateContext(); + context.TaxRates.Add(MakeTaxRate(id: 1, companyId: 1, name: "State Tax")); + context.TaxRates.Add(MakeTaxRate(id: 2, companyId: 2, name: "Foreign Tax")); // other company + context.TaxRates.Add(MakeTaxRate(id: 3, companyId: 1, name: "Local Tax")); + await context.SaveChangesAsync(); + + var controller = new TaxRatesController( + new UnitOfWork(context), + MockTenant(companyId: 1), + Mock.Of>()); + SetHttpContext(controller); + + var result = await controller.Index(); + + var view = Assert.IsType(result); + var rates = Assert.IsAssignableFrom>(view.Model).ToList(); + Assert.Equal(2, rates.Count); + Assert.All(rates, r => Assert.Equal(1, r.CompanyId)); + Assert.DoesNotContain(rates, r => r.Name == "Foreign Tax"); + } + + // ── RecurringTemplatesController ────────────────────────────────────────── + + [Fact] + public async Task RecurringTemplates_Index_ReturnsOnlyCurrentCompanyTemplates() + { + await using var context = CreateContext(); + context.RecurringTemplates.Add(MakeRecurringTemplate(id: 1, companyId: 1, name: "Monthly Rent")); + context.RecurringTemplates.Add(MakeRecurringTemplate(id: 2, companyId: 2, name: "Other Tenant Bill")); // other company + await context.SaveChangesAsync(); + + var controller = new RecurringTemplatesController( + new UnitOfWork(context), + MockTenant(companyId: 1), + CreateUserManagerMock().Object, + Mock.Of>()); + SetHttpContext(controller); + + var result = await controller.Index(); + + var view = Assert.IsType(result); + var templates = Assert.IsAssignableFrom>(view.Model).ToList(); + Assert.Single(templates); + Assert.Equal("Monthly Rent", templates[0].Name); + } + + // ── JobTemplatesController ──────────────────────────────────────────────── + + [Fact] + public async Task JobTemplates_Index_ReturnsOnlyCurrentCompanyTemplates() + { + await using var context = CreateContext(); + context.JobTemplates.Add(MakeJobTemplate(id: 1, companyId: 1, name: "Standard Wheel Coat")); + context.JobTemplates.Add(MakeJobTemplate(id: 2, companyId: 2, name: "Other Company Template")); // other company + await context.SaveChangesAsync(); + + var controller = new JobTemplatesController( + new UnitOfWork(context), + MockTenant(companyId: 1)); + SetHttpContext(controller); + + var result = await controller.Index(); + + var view = Assert.IsType(result); + var templates = Assert.IsAssignableFrom>(view.Model).ToList(); + Assert.Single(templates); + Assert.Equal("Standard Wheel Coat", templates[0].Name); + } + + // ── Cross-tenant write protection ───────────────────────────────────────── + + /// + /// Verifies that the companyId-scoped FindAsync used for SMS export returns zero + /// rows for a company that has no customers, even when another company has many. + /// Guards against the "empty predicate returns all" regression. + /// + [Fact] + public async Task SmsConsentAudit_ExportCsv_IsEmpty_WhenCompanyHasNoCustomers() + { + await using var context = CreateContext(); + context.Customers.Add(MakeCustomer(id: 1, companyId: 2, firstName: "Other")); + context.Customers.Add(MakeCustomer(id: 2, companyId: 2, firstName: "Also Other")); + await context.SaveChangesAsync(); + + var controller = new SmsConsentAuditController( + new UnitOfWork(context), + MockTenant(companyId: 1), // Company 1 has no customers + Mock.Of>()); + SetHttpContext(controller); + + var result = await controller.ExportCsv(); + + var file = Assert.IsType(result); + var csv = System.Text.Encoding.UTF8.GetString(file.FileContents); + // Only header row, no data rows + Assert.DoesNotContain("Other", csv); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static ApplicationDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + return new ApplicationDbContext(options); + } + + /// Returns a mock ITenantContext that always yields the given companyId. + private static ITenantContext MockTenant(int companyId) + { + var mock = new Mock(); + mock.Setup(t => t.GetCurrentCompanyId()).Returns(companyId); + return mock.Object; + } + + private static void SetHttpContext(Controller controller) + { + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext() + }; + } + + private static Customer MakeCustomer(int id, int companyId, string firstName) => new() + { + Id = id, + CompanyId = companyId, + ContactFirstName = firstName, + ContactLastName = "Test", + IsCommercial = false + }; + + private static TaxRate MakeTaxRate(int id, int companyId, string name) => new() + { + Id = id, + CompanyId = companyId, + Name = name, + Rate = 8.5m + }; + + private static RecurringTemplate MakeRecurringTemplate(int id, int companyId, string name) => new() + { + Id = id, + CompanyId = companyId, + Name = name, + TemplateType = RecurringTemplateType.Bill, + Frequency = RecurringFrequency.Monthly, + IntervalCount = 1, + NextFireDate = DateTime.Today, + IsActive = true + }; + + private static JobTemplate MakeJobTemplate(int id, int companyId, string name) => new() + { + Id = id, + CompanyId = companyId, + Name = name + }; + + private static Mock> CreateUserManagerMock() + { + var store = new Mock>(); + return new Mock>( + store.Object, null!, null!, null!, null!, null!, null!, null!, null!); + } +}