Compare commits

..

1 Commits

Author SHA1 Message Date
spouliot 8acbc8605d 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 <noreply@anthropic.com>
2026-05-17 18:04:22 -04:00
23 changed files with 569 additions and 192 deletions
@@ -92,7 +92,11 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataPro
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId)) if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
return 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<ApplicationUser>, IDataPro
{ {
get 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; 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;
} }
} }
@@ -60,10 +60,11 @@ public class AccountingExportController : Controller
{ {
var start = startDate.Date; var start = startDate.Date;
var end = endDate.Date.AddDays(1).AddTicks(-1); var end = endDate.Date.AddDays(1).AddTicks(-1);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// ── Load data ───────────────────────────────────────────────────────── // ── Load data ─────────────────────────────────────────────────────────
var invoices = (await _unitOfWork.Invoices.FindAsync( var invoices = (await _unitOfWork.Invoices.FindAsync(
i => i.InvoiceDate >= start && i.InvoiceDate <= end, i => i.CompanyId == companyId && i.InvoiceDate >= start && i.InvoiceDate <= end,
false, false,
i => i.InvoiceItems, i => i.InvoiceItems,
i => i.Payments, i => i.Payments,
@@ -72,7 +73,7 @@ public class AccountingExportController : Controller
.ToList(); .ToList();
var expenses = (await _unitOfWork.Expenses.FindAsync( var expenses = (await _unitOfWork.Expenses.FindAsync(
e => e.Date >= start && e.Date <= end, e => e.CompanyId == companyId && e.Date >= start && e.Date <= end,
false, false,
e => e.Vendor, e => e.Vendor,
e => e.ExpenseAccount, e => e.ExpenseAccount,
@@ -82,7 +83,7 @@ public class AccountingExportController : Controller
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end); 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) .OrderBy(c => c.CompanyName ?? c.ContactFirstName)
.ToList(); .ToList();
@@ -486,9 +486,12 @@ public class AppointmentsController : Controller
try try
{ {
var events = new List<CalendarEventDto>(); var events = new List<CalendarEventDto>();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// 1. Fetch appointments in date range // 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.Customer,
a => a.AppointmentType, a => a.AppointmentType,
a => a.AppointmentStatus); a => a.AppointmentStatus);
@@ -501,7 +504,9 @@ public class AppointmentsController : Controller
events.AddRange(appointmentEvents); events.AddRange(appointmentEvents);
// 2. Fetch maintenance records in date range // 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); m => m.Equipment);
var maintenanceRecords = allMaintenanceRecords var maintenanceRecords = allMaintenanceRecords
@@ -539,7 +544,9 @@ public class AppointmentsController : Controller
} }
// 3. Fetch jobs and add as all-day events // 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.Customer,
j => j.JobStatus); j => j.JobStatus);
@@ -746,13 +753,16 @@ public class AppointmentsController : Controller
try try
{ {
var terminalCodes = new[] { AppConstants.StatusCodes.Job.Completed, AppConstants.StatusCodes.Job.Delivered, AppConstants.StatusCodes.Job.Cancelled }; 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); j => j.Customer, j => j.JobStatus, j => j.JobItems);
// Load coats separately — filter by JobItemId using already-loaded item IDs // Load coats separately — filter by JobItemId using already-loaded item IDs
var jobItemIds = allJobs.SelectMany(j => j.JobItems.Select(i => i.Id)).ToList(); var jobItemIds = allJobs.SelectMany(j => j.JobItems.Select(i => i.Id)).ToList();
var allCoats = await _unitOfWork.JobItemCoats.FindAsync( var allCoats = await _unitOfWork.JobItemCoats.FindAsync(
c => jobItemIds.Contains(c.JobItemId)); c => jobItemIds.Contains(c.JobItemId) && c.CompanyId == calCompanyId);
var coatsByItemId = allCoats var coatsByItemId = allCoats
.Where(c => !c.IsDeleted) .Where(c => !c.IsDeleted)
@@ -891,7 +901,9 @@ public class AppointmentsController : Controller
/// </summary> /// </summary>
private async Task PopulateCreateDropdowns() 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 var customerList = customers.Select(c => new
{ {
c.Id, c.Id,
@@ -903,19 +915,16 @@ public class AppointmentsController : Controller
.ToList(); .ToList();
ViewBag.Customers = new SelectList(customerList, "Id", "DisplayName"); ViewBag.Customers = new SelectList(customerList, "Id", "DisplayName");
// Use cached appointment types
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var types = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId); var types = await _lookupCache.GetAppointmentTypeLookupsAsync(companyId);
ViewBag.AppointmentTypes = new SelectList(types.Where(t => t.IsActive).OrderBy(t => t.DisplayOrder), "Id", "DisplayName"); 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 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) .OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
.ToListAsync(); .ToListAsync();
ViewBag.Workers = new SelectList(workers.Select(u => new { u.Id, FullName = u.FullName }), "Id", "FullName"); 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"); ViewBag.Jobs = new SelectList(jobs.OrderBy(j => j.JobNumber), "Id", "JobNumber");
} }
@@ -27,15 +27,18 @@ namespace PowderCoating.Web.Controllers
{ {
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly ITenantContext _tenantContext;
private readonly ILogger<CatalogCategoriesController> _logger; private readonly ILogger<CatalogCategoriesController> _logger;
public CatalogCategoriesController( public CatalogCategoriesController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
IMapper mapper, IMapper mapper,
ITenantContext tenantContext,
ILogger<CatalogCategoriesController> logger) ILogger<CatalogCategoriesController> logger)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
_tenantContext = tenantContext;
_logger = logger; _logger = logger;
} }
@@ -52,8 +55,9 @@ namespace PowderCoating.Web.Controllers
{ {
try try
{ {
var indexCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = await _unitOfWork.CatalogCategories var categories = await _unitOfWork.CatalogCategories
.GetAllAsync(false, .FindAsync(c => c.CompanyId == indexCompanyId, false,
c => c.ParentCategory, c => c.ParentCategory,
c => c.SubCategories, c => c.SubCategories,
c => c.Items); c => c.Items);
@@ -164,7 +168,8 @@ namespace PowderCoating.Web.Controllers
if (ModelState.IsValid) if (ModelState.IsValid)
{ {
// Check for duplicate category name under the same parent (case-insensitive) // 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 => var existingCategory = allCategories.FirstOrDefault(c =>
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) && c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
c.ParentCategoryId == dto.ParentCategoryId); c.ParentCategoryId == dto.ParentCategoryId);
@@ -272,7 +277,8 @@ namespace PowderCoating.Web.Controllers
if (nameChanged || parentChanged) 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 => var existingCategory = allCategories.FirstOrDefault(c =>
c.Id != id && c.Id != id &&
c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) && c.Name.Equals(dto.Name.Trim(), StringComparison.OrdinalIgnoreCase) &&
@@ -444,7 +450,8 @@ namespace PowderCoating.Web.Controllers
var trimmedName = request.Name.Trim(); var trimmedName = request.Name.Trim();
// Check for duplicate category name under the same parent (case-insensitive) // 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 => var existingCategory = allCategories.FirstOrDefault(c =>
c.Name.Equals(trimmedName, StringComparison.OrdinalIgnoreCase) && c.Name.Equals(trimmedName, StringComparison.OrdinalIgnoreCase) &&
c.ParentCategoryId == request.ParentCategoryId); c.ParentCategoryId == request.ParentCategoryId);
@@ -500,8 +507,9 @@ namespace PowderCoating.Web.Controllers
{ {
try try
{ {
var treeCompanyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var categories = await _unitOfWork.CatalogCategories 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 // Build tree from root categories
var rootCategories = categories var rootCategories = categories
@@ -535,7 +543,8 @@ namespace PowderCoating.Web.Controllers
{ {
try 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) // Build hierarchical list (parents before children)
var hierarchicalList = new List<CatalogCategory>(); var hierarchicalList = new List<CatalogCategory>();
@@ -573,7 +582,8 @@ namespace PowderCoating.Web.Controllers
/// </param> /// </param>
private async Task PopulateParentCategoryDropdown(int? excludeCategoryId = null) 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 // Exclude the current category and its descendants to prevent circular references
var excludedIds = new HashSet<int>(); var excludedIds = new HashSet<int>();
@@ -700,7 +710,8 @@ namespace PowderCoating.Web.Controllers
if (categoryId == newParentId) if (categoryId == newParentId)
return true; 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); var current = categories.FirstOrDefault(c => c.Id == newParentId);
while (current != null) while (current != null)
@@ -83,7 +83,8 @@ namespace PowderCoating.Web.Controllers
try try
{ {
// Get all categories with their items // 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(); var allItems = allCategories.SelectMany(c => c.Items).ToList();
// Apply search filter // Apply search filter
@@ -578,7 +579,8 @@ namespace PowderCoating.Web.Controllers
return Json(new List<object>()); return Json(new List<object>());
} }
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 search = searchTerm.ToLower();
var items = allItems var items = allItems
@@ -694,7 +696,8 @@ namespace PowderCoating.Web.Controllers
/// </summary> /// </summary>
private async Task PopulateCategoryDropdown() 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) // Build hierarchical list (parents before children)
var hierarchicalList = new List<CatalogCategory>(); var hierarchicalList = new List<CatalogCategory>();
@@ -1045,7 +1048,7 @@ namespace PowderCoating.Web.Controllers
// Load all categories so we can build full paths (e.g. "Cerakote > Firearms"). // 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 // 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". // "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); .ToDictionary(c => c.Id);
// Load company operating costs // Load company operating costs
@@ -142,10 +142,10 @@ public class CompanySettingsController : Controller
&& !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase); && !connectClientId.Contains("your_connect_client_id_here", StringComparison.OrdinalIgnoreCase);
// Load notification templates for inline tab // 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()); var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
if (seeded > 0) if (seeded > 0)
existing = await _unitOfWork.NotificationTemplates.GetAllAsync(); existing = await _unitOfWork.NotificationTemplates.FindAsync(t => t.CompanyId == companyId.Value);
dto.NotificationTemplates = existing dto.NotificationTemplates = existing
.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel) .OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
@@ -755,8 +755,8 @@ public class CompanySettingsController : Controller
var costs = company.OperatingCosts; var costs = company.OperatingCosts;
var ovens = (await _unitOfWork.OvenCosts.FindAsync(o => o.IsActive)).OrderBy(o => o.DisplayOrder).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)).ToList(); var coatingCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsCoating && c.CompanyId == companyId.Value)).ToList();
var sb = new System.Text.StringBuilder(); var sb = new System.Text.StringBuilder();
@@ -920,7 +920,8 @@ public class CompanySettingsController : Controller
{ {
try 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 sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<JobStatusLookupDto>>(sortedStatuses); var dtos = _mapper.Map<List<JobStatusLookupDto>>(sortedStatuses);
@@ -1071,7 +1072,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid) if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" }); 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++) for (int i = 0; i < dto.OrderedIds.Count; i++)
{ {
@@ -1084,7 +1086,6 @@ public class CompanySettingsController : Controller
} }
await _unitOfWork.CompleteAsync(); await _unitOfWork.CompleteAsync();
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId.HasValue) _lookupCache.InvalidateCompanyCache(companyId.Value); if (companyId.HasValue) _lookupCache.InvalidateCompanyCache(companyId.Value);
_logger.LogInformation("Job statuses reordered"); _logger.LogInformation("Job statuses reordered");
@@ -1113,7 +1114,8 @@ public class CompanySettingsController : Controller
{ {
try 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 sortedPriorities = priorities.OrderBy(p => p.DisplayOrder).ToList();
var dtos = _mapper.Map<List<JobPriorityLookupDto>>(sortedPriorities); var dtos = _mapper.Map<List<JobPriorityLookupDto>>(sortedPriorities);
@@ -1258,7 +1260,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid) if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" }); 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++) for (int i = 0; i < dto.OrderedIds.Count; i++)
{ {
@@ -1297,7 +1300,8 @@ public class CompanySettingsController : Controller
{ {
try 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 sortedStatuses = statuses.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<QuoteStatusLookupDto>>(sortedStatuses); var dtos = _mapper.Map<List<QuoteStatusLookupDto>>(sortedStatuses);
@@ -1478,7 +1482,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid) if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" }); 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++) for (int i = 0; i < dto.OrderedIds.Count; i++)
{ {
@@ -1517,7 +1522,8 @@ public class CompanySettingsController : Controller
{ {
try 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 sortedServices = services.OrderBy(s => s.DisplayOrder).ToList();
var dtos = _mapper.Map<List<PrepServiceDto>>(sortedServices); var dtos = _mapper.Map<List<PrepServiceDto>>(sortedServices);
@@ -1639,7 +1645,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid) if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" }); 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++) for (int i = 0; i < dto.OrderedIds.Count; i++)
{ {
@@ -1812,7 +1819,8 @@ public class CompanySettingsController : Controller
{ {
try 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 sortedTypes = types.OrderBy(t => t.DisplayOrder).ToList();
var dtos = _mapper.Map<List<AppointmentTypeLookupDto>>(sortedTypes); var dtos = _mapper.Map<List<AppointmentTypeLookupDto>>(sortedTypes);
@@ -1956,7 +1964,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid) if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" }); 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++) for (int i = 0; i < dto.OrderedIds.Count; i++)
{ {
@@ -1996,7 +2005,8 @@ public class CompanySettingsController : Controller
{ {
try 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 sortedCategories = categories.OrderBy(c => c.DisplayOrder).ToList();
var dtos = _mapper.Map<List<InventoryCategoryLookupDto>>(sortedCategories); var dtos = _mapper.Map<List<InventoryCategoryLookupDto>>(sortedCategories);
@@ -2132,7 +2142,8 @@ public class CompanySettingsController : Controller
if (!ModelState.IsValid) if (!ModelState.IsValid)
return Json(new { success = false, message = "Invalid data" }); 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++) for (int i = 0; i < dto.OrderedIds.Count; i++)
{ {
@@ -2349,12 +2360,12 @@ public class CompanySettingsController : Controller
if (companyId == null) return RedirectToAction(nameof(Index)); if (companyId == null) return RedirectToAction(nameof(Index));
// Load all existing templates for this company // 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 // Auto-seed any missing canonical combinations
var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList()); var seeded = await EnsureNotificationTemplatesSeededAsync(companyId.Value, existing.ToList());
if (seeded > 0) 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) var dtos = existing.OrderBy(t => (int)t.NotificationType).ThenBy(t => (int)t.Channel)
.Select(t => new NotificationTemplateDto .Select(t => new NotificationTemplateDto
@@ -315,7 +315,8 @@ public class CreditMemosController : Controller
private async Task PopulateCustomersAsync(int? selectedId) 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 ViewBag.Customers = customers
.OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim()) .OrderBy(c => c.CompanyName ?? $"{c.ContactFirstName} {c.ContactLastName}".Trim())
.Select(c => new SelectListItem .Select(c => new SelectListItem
@@ -342,14 +342,16 @@ public class DashboardController : Controller
TipOfTheDay = data.TipOfTheDay 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 // Dropdowns for the "Add Custom Powder to Inventory" modal
var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.GetAllAsync()) var inventoryCategories = (await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.IsActive && c.CompanyId == companyId))
.Where(c => c.IsActive)
.OrderBy(c => c.DisplayOrder) .OrderBy(c => c.DisplayOrder)
.Select(c => new { c.Id, c.DisplayName }) .Select(c => new { c.Id, c.DisplayName })
.ToList(); .ToList();
var vendors = (await _unitOfWork.Vendors.GetAllAsync()) var vendors = (await _unitOfWork.Vendors.FindAsync(v => v.IsActive && v.CompanyId == companyId))
.Where(v => v.IsActive)
.OrderBy(v => v.CompanyName) .OrderBy(v => v.CompanyName)
.Select(v => new { v.Id, v.CompanyName }) .Select(v => new { v.Id, v.CompanyName })
.ToList(); .ToList();
@@ -357,7 +359,6 @@ public class DashboardController : Controller
ViewBag.VendorList = vendors; ViewBag.VendorList = vendors;
// Config health check — surface setup gaps to company admins // Config health check — surface setup gaps to company admins
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
if (currentCompanyId.HasValue) if (currentCompanyId.HasValue)
{ {
ViewBag.ConfigHealth = await _configHealth.CheckAsync(currentCompanyId.Value); 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); i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
// Check SKU uniqueness // Check SKU uniqueness within this company
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim())) 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." }); return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
// Determine category display name for legacy field // Determine category display name for legacy field
@@ -160,7 +160,8 @@ public class InventoryController : Controller
var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount); var pagedResult = PagedResult<InventoryListDto>.From(gridRequest, itemDtos, totalCount);
// Load all items once to compute sidebar stats and category list in memory // 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.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.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive); 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. // 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. // 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 var existingSkus = existingItems
.Where(i => !string.IsNullOrWhiteSpace(i.ManufacturerPartNumber) && i.Id != (currentId ?? 0)) .Where(i => !string.IsNullOrWhiteSpace(i.ManufacturerPartNumber) && i.Id != (currentId ?? 0))
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower()) .Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
@@ -1182,7 +1184,7 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Find the default coating category to assign // 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 var coatingCategory = categories
.Where(c => c.IsActive && c.IsCoating) .Where(c => c.IsActive && c.IsCoating)
.OrderBy(c => c.DisplayOrder) .OrderBy(c => c.DisplayOrder)
@@ -1369,11 +1371,11 @@ public class InventoryController : Controller
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
ViewBag.AiInventoryAssistEnabled = await _subscriptionService.IsAiInventoryAssistEnabledAsync(companyId); 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"); ViewBag.Vendors = new SelectList(vendors.Where(s => s.IsActive).OrderBy(s => s.CompanyName), "Id", "CompanyName");
// Load categories from lookup table // Load categories from lookup table
var allCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync(); var allCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId);
var categories = allCategories var categories = allCategories
.Where(c => c.IsActive) .Where(c => c.IsActive)
.OrderBy(c => c.DisplayOrder) .OrderBy(c => c.DisplayOrder)
@@ -1738,7 +1740,8 @@ public class InventoryController : Controller
DateTime? dateTo, DateTime? dateTo,
string? typeFilter) 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 var itemList = allItems
.Where(i => i.IsActive || i.QuantityOnHand > 0) .Where(i => i.IsActive || i.QuantityOnHand > 0)
.OrderBy(i => i.Name) .OrderBy(i => i.Name)
@@ -2213,7 +2213,7 @@ public class InvoicesController : Controller
/// </summary> /// </summary>
private async Task PopulateCreateViewBagAsync(int companyId, string? selectedTerms = null) 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(); 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 // Expose company default tax rate and exempt customer IDs for client-side tax handling
@@ -36,7 +36,9 @@ public class JobTemplatesController : Controller
/// </summary> /// </summary>
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
{ {
var templates = await _unitOfWork.JobTemplates.GetAllAsync( var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var templates = await _unitOfWork.JobTemplates.FindAsync(
t => t.CompanyId == companyId,
false, false,
t => t.Customer, t => t.Customer,
t => t.Items); t => t.Items);
@@ -499,7 +499,7 @@ public class JobsController : Controller
ViewBag.MaterialsUsed = allJobTransactions; ViewBag.MaterialsUsed = allJobTransactions;
// Inventory items for the manual log-material modal // 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) .OrderBy(i => i.Name)
.Select(i => new { i.Id, i.Name, i.Manufacturer, i.UnitOfMeasure, i.QuantityOnHand }) .Select(i => new { i.Id, i.Name, i.Manufacturer, i.UnitOfMeasure, i.QuantityOnHand })
.ToList(); .ToList();
@@ -528,7 +528,7 @@ public class JobsController : Controller
ViewBag.JobPhotoMax = photoMax; ViewBag.JobPhotoMax = photoMax;
// Customer list for inline customer-change dropdown // 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 ViewBag.CustomerSelectList = allCustomers
.Where(c => c.IsActive) .Where(c => c.IsActive)
.Select(c => new SelectListItem .Select(c => new SelectListItem
@@ -634,7 +634,8 @@ public class JobsController : Controller
if (job == null) return NotFound(); 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(); .OrderBy(s => s.DisplayOrder).ToList();
ViewBag.AllStatuses = allStatuses; ViewBag.AllStatuses = allStatuses;
@@ -657,7 +658,7 @@ public class JobsController : Controller
if (job == null) return NotFound(); 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); var newStatus = allStatuses.FirstOrDefault(s => s.Id == newStatusId);
if (newStatus == null) return BadRequest("Invalid status."); if (newStatus == null) return BadRequest("Invalid status.");
@@ -845,7 +846,7 @@ public class JobsController : Controller
// Optionally advance status to In Preparation // Optionally advance status to In Preparation
if (advanceToInPreparation && jobToUpdate.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation) 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); var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
if (inPrepStatus != null) if (inPrepStatus != null)
{ {
@@ -902,7 +903,7 @@ public class JobsController : Controller
if (advanceToInPreparation && job.JobStatus.StatusCode != AppConstants.StatusCodes.Job.InPreparation && !job.JobStatus.IsTerminalStatus) 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); var inPrepStatus = allStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.InPreparation);
if (inPrepStatus != null) if (inPrepStatus != null)
{ {
@@ -1809,7 +1810,7 @@ public class JobsController : Controller
ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId); ViewBag.AiPhotoQuotesEnabled = await _subscriptionService.CanUseAiPhotoQuoteAsync(companyId);
await PopulateDropdowns(); await PopulateDropdowns();
await PopulatePrepServicesAsync(); await PopulatePrepServicesAsync(companyId);
var costs = await _pricingService.GetOperatingCostsAsync(companyId); var costs = await _pricingService.GetOperatingCostsAsync(companyId);
await PopulateJobItemDropDownsAsync(companyId, costs?.OvenOperatingCostPerHour ?? 45m); await PopulateJobItemDropDownsAsync(companyId, costs?.OvenOperatingCostPerHour ?? 45m);
ViewBag.TaxPercent = costs?.TaxPercent ?? 0m; ViewBag.TaxPercent = costs?.TaxPercent ?? 0m;
@@ -1829,7 +1830,9 @@ public class JobsController : Controller
/// </summary> /// </summary>
private async Task PopulateDropdowns() 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( ViewBag.Customers = new SelectList(
customers.Where(c => c.IsActive).Select(c => new customers.Where(c => c.IsActive).Select(c => new
{ {
@@ -1840,8 +1843,6 @@ public class JobsController : Controller
}).OrderBy(c => c.DisplayName), }).OrderBy(c => c.DisplayName),
"Id", "Id",
"DisplayName"); "DisplayName");
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var users = await _userManager.Users var users = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null) .Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName) .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. /// 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. /// Prep services are ordered by DisplayOrder so they appear in the intended workflow sequence.
/// </summary> /// </summary>
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(); ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
_logger.LogInformation("Populated {Count} active prep services", prepServices.Count()); _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) ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault }) .Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
.ToList(); .ToList();
@@ -3166,7 +3167,7 @@ public class JobsController : Controller
/// </summary> /// </summary>
private async Task PopulateJobItemDropDownsAsync(int companyId, decimal fallbackOvenRate) 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 ViewBag.InventoryCoatings = inventory
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating) .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) .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 isIncoming = i.IsIncoming
}).ToList(); }).ToList();
var vendors = await _unitOfWork.Vendors.GetAllAsync(false); var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, false);
ViewBag.Vendors = vendors ViewBag.Vendors = vendors
.Where(s => s.IsActive).OrderBy(s => s.CompanyName) .Where(s => s.IsActive).OrderBy(s => s.CompanyName)
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList(); .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 ViewBag.CatalogItems = catalogItems
.Where(i => i.IsActive) .Where(i => i.IsActive)
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder) .OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
@@ -3220,10 +3221,10 @@ public class JobsController : Controller
description = i.Description description = i.Description
}).ToList(); }).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(); 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) ViewBag.BlastSetups = blastSetupsForEditItems.OrderBy(b => b.DisplayOrder)
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault }) .Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
.ToList(); .ToList();
@@ -90,8 +90,8 @@ public class JobsPriorityController : Controller
.ToList(); .ToList();
// Get priorities and workers for modal options // Get priorities and workers for modal options
var priorities = await _unitOfWork.JobPriorityLookups.GetAllAsync();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0; var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var priorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId);
var workers = await _userManager.Users var workers = await _userManager.Users
.Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null) .Where(u => u.CompanyId == companyId && u.IsActive && u.CompanyRole != null)
.OrderBy(u => u.FirstName).ThenBy(u => u.LastName) .OrderBy(u => u.FirstName).ThenBy(u => u.LastName)
@@ -16,15 +16,18 @@ public class MaintenanceController : Controller
{ {
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly ITenantContext _tenantContext;
private readonly ILogger<MaintenanceController> _logger; private readonly ILogger<MaintenanceController> _logger;
public MaintenanceController( public MaintenanceController(
IUnitOfWork unitOfWork, IUnitOfWork unitOfWork,
IMapper mapper, IMapper mapper,
ITenantContext tenantContext,
ILogger<MaintenanceController> logger) ILogger<MaintenanceController> logger)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
_tenantContext = tenantContext;
_logger = logger; _logger = logger;
} }
@@ -740,7 +743,8 @@ public class MaintenanceController : Controller
/// </summary> /// </summary>
private async Task PopulateViewBagAsync(int? selectedEquipmentId = null) 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( ViewBag.EquipmentList = new SelectList(
equipment.Where(e => e.IsActive).OrderBy(e => e.EquipmentName), equipment.Where(e => e.IsActive).OrderBy(e => e.EquipmentName),
"Id", "Id",
@@ -179,8 +179,9 @@ public class OvenSchedulerController : Controller
public async Task<IActionResult> Suggest([FromBody] SuggestRequest req) public async Task<IActionResult> Suggest([FromBody] SuggestRequest req)
{ {
var goal = req?.OptimizationGoal ?? "maximize_throughput"; 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) .Where(o => o.IsActive)
.OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label) .OrderBy(o => o.DisplayOrder).ThenBy(o => o.Label)
.ToList(); .ToList();
@@ -188,10 +189,11 @@ public class OvenSchedulerController : Controller
if (!equipmentList.Any()) if (!equipmentList.Any())
return Json(new { success = false, error = "No active ovens found. Add Named Ovens in Settings → Operating Costs." }); 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 defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
var queueJobs = (await _unitOfWork.Jobs.GetAllAsync( var queueJobs = (await _unitOfWork.Jobs.FindAsync(
j => j.CompanyId == suggestCompanyId,
false, false,
j => j.Customer, j => j.Customer,
j => j.JobStatus, j => j.JobStatus,
@@ -265,7 +267,8 @@ public class OvenSchedulerController : Controller
if (req?.Batches == null || !req.Batches.Any()) if (req?.Batches == null || !req.Batches.Any())
return Json(new { success = false, error = "No batches provided." }); 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 defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
var createdBatches = new List<object>(); var createdBatches = new List<object>();
@@ -357,7 +360,8 @@ public class OvenSchedulerController : Controller
if (oven == null) if (oven == null)
return Json(new { success = false, error = "Oven not found." }); 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 defaultCycleMinutes = companyCosts.FirstOrDefault()?.DefaultOvenCycleMinutes ?? 45;
var batchNumber = await GenerateBatchNumberAsync(); var batchNumber = await GenerateBatchNumberAsync();
@@ -651,7 +655,8 @@ public class OvenSchedulerController : Controller
if (inOvenStatus != null) if (inOvenStatus != null)
{ {
var jobIds = batch.Items.Select(i => i.JobId).Distinct().ToHashSet(); 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) foreach (var job in jobs)
job.JobStatusId = inOvenStatus.Id; job.JobStatusId = inOvenStatus.Id;
} }
@@ -14,12 +14,14 @@ public class PricingTiersController : Controller
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly IMapper _mapper; private readonly IMapper _mapper;
private readonly ILogger<PricingTiersController> _logger; private readonly ILogger<PricingTiersController> _logger;
private readonly ITenantContext _tenantContext;
public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger<PricingTiersController> logger) public PricingTiersController(IUnitOfWork unitOfWork, IMapper mapper, ILogger<PricingTiersController> logger, ITenantContext tenantContext)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_mapper = mapper; _mapper = mapper;
_logger = logger; _logger = logger;
_tenantContext = tenantContext;
} }
/// <summary> /// <summary>
@@ -27,8 +29,9 @@ public class PricingTiersController : Controller
/// </summary> /// </summary>
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
{ {
var tiers = await _unitOfWork.PricingTiers.GetAllAsync(); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var customers = await _unitOfWork.Customers.GetAllAsync(); var tiers = await _unitOfWork.PricingTiers.FindAsync(t => t.CompanyId == companyId);
var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
var customerCountByTier = customers var customerCountByTier = customers
.Where(c => c.PricingTierId.HasValue) .Where(c => c.PricingTierId.HasValue)
@@ -255,7 +255,7 @@ public class QuotesController : Controller
// Calibration nudge — suppress when named blast setups exist OR legacy CFM is set // 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 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 ViewBag.QuotingNotCalibrated = costs != null
&& !hasNamedSetups && !hasNamedSetups
&& costs.CompressorCfm == 0 && costs.CompressorCfm == 0
@@ -441,7 +441,7 @@ public class QuotesController : Controller
ViewBag.Deposits = quoteDeposits; ViewBag.Deposits = quoteDeposits;
// Customer list for inline customer-change dropdown // 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 ViewBag.CustomerSelectList = allCustomers
.Where(c => c.IsActive) .Where(c => c.IsActive)
.Select(c => new SelectListItem .Select(c => new SelectListItem
@@ -2430,7 +2430,7 @@ public class QuotesController : Controller
ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan ViewBag.QuotePhotosEnabled = quotePhotoMax != 0; // 0 = feature disabled for this plan
// Customers // Customers
var customers = await _unitOfWork.Customers.GetAllAsync(); var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId);
ViewBag.Customers = customers ViewBag.Customers = customers
.Select(c => new SelectListItem .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 // 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 ViewBag.InventoryCoatings = inventory
.Where(i => i.IsActive && i.InventoryCategory?.IsActive == true && i.InventoryCategory.IsCoating) .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) .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(); }).ToList();
// Vendors // Vendors
var vendors = await _unitOfWork.Vendors.GetAllAsync(false); var vendors = await _unitOfWork.Vendors.FindAsync(s => s.CompanyId == companyId, false);
ViewBag.Vendors = vendors ViewBag.Vendors = vendors
.Where(s => s.IsActive).OrderBy(s => s.CompanyName) .Where(s => s.IsActive).OrderBy(s => s.CompanyName)
.Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList(); .Select(s => new { value = s.Id.ToString(), text = s.CompanyName }).ToList();
// Catalog items // 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 ViewBag.CatalogItems = catalogItems
.Where(i => i.IsActive) .Where(i => i.IsActive)
.OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder) .OrderBy(i => i.Category.DisplayOrder).ThenBy(i => i.DisplayOrder)
@@ -2528,11 +2528,11 @@ public class QuotesController : Controller
}).ToList(); }).ToList();
// Prep services // 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(); ViewBag.PrepServices = prepServices.OrderBy(ps => ps.DisplayOrder).ToList();
// Blast setups for wizard dropdown // 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) ViewBag.BlastSetups = blastSetups.OrderBy(b => b.DisplayOrder)
.Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault }) .Select(b => new { id = b.Id, name = b.Name, derivedRate = ShopCapabilityCalculator.GetBlastRateSqFtPerHour(b), isDefault = b.IsDefault })
.ToList(); .ToList();
@@ -2599,7 +2599,8 @@ public class QuotesController : Controller
/// </summary> /// </summary>
private async Task PopulatePricingTiersDropDownAsync() 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) ViewBag.PricingTiers = pricingTiers.OrderBy(pt => pt.TierName)
.Select(pt => new SelectListItem .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 // 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. // no-tracking children (which may share InventoryItem instances) causes EF identity conflicts.
// Get default job statuses and priorities // Get default job statuses and priorities — scope to quote's company for defense-in-depth
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync(); var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == quote.CompanyId);
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync(); var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == quote.CompanyId);
var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Approved); var approvedStatus = jobStatuses.FirstOrDefault(s => s.StatusCode == AppConstants.StatusCodes.Job.Approved);
var normalPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "NORMAL"); var normalPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "NORMAL");
var rushPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "RUSH"); var rushPriority = jobPriorities.FirstOrDefault(p => p.PriorityCode == "RUSH");
@@ -3347,7 +3348,7 @@ public class QuotesController : Controller
CompanyBlastSetup? selectedBlastSetup = null; CompanyBlastSetup? selectedBlastSetup = null;
if (request.BlastSetupId.HasValue) 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(); selectedBlastSetup = setups.FirstOrDefault();
} }
@@ -44,7 +44,8 @@ public class RecurringTemplatesController : Controller
/// <summary>Lists all recurring templates for the current company, active first then by name.</summary> /// <summary>Lists all recurring templates for the current company, active first then by name.</summary>
public async Task<IActionResult> Index() public async Task<IActionResult> 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()); return View(templates.OrderByDescending(t => t.IsActive).ThenBy(t => t.Name).ToList());
} }
@@ -425,11 +426,12 @@ public class RecurringTemplatesController : Controller
/// <summary>Loads dropdowns for vendors, accounts, and payment methods into ViewBag.</summary> /// <summary>Loads dropdowns for vendors, accounts, and payment methods into ViewBag.</summary>
private async Task PopulateDropDownsAsync() 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) ViewBag.Vendors = vendors.OrderBy(v => v.CompanyName)
.Select(v => new SelectListItem(v.CompanyName, v.Id.ToString())).ToList(); .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 ViewBag.APAccounts = accounts
.Where(a => a.AccountSubType == AccountSubType.AccountsPayable) .Where(a => a.AccountSubType == AccountSubType.AccountsPayable)
.OrderBy(a => a.AccountNumber) .OrderBy(a => a.AccountNumber)
@@ -11,6 +11,7 @@ using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Entities; using PowderCoating.Core.Entities;
using PowderCoating.Shared.Constants; using PowderCoating.Shared.Constants;
using PowderCoating.Web.ViewModels.Reports; using PowderCoating.Web.ViewModels.Reports;
using System.Security.Claims;
namespace PowderCoating.Web.Controllers; namespace PowderCoating.Web.Controllers;
@@ -25,8 +26,9 @@ public class ReportsController : Controller
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly IAccountingAiService _accountingAi; private readonly IAccountingAiService _accountingAi;
private readonly IAiUsageLogger _usageLogger; private readonly IAiUsageLogger _usageLogger;
private readonly ITenantContext _tenantContext;
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger) public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger, ITenantContext tenantContext)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_logger = logger; _logger = logger;
@@ -36,6 +38,7 @@ public class ReportsController : Controller
_userManager = userManager; _userManager = userManager;
_accountingAi = accountingAi; _accountingAi = accountingAi;
_usageLogger = usageLogger; _usageLogger = usageLogger;
_tenantContext = tenantContext;
} }
/// <summary> /// <summary>
@@ -79,27 +82,26 @@ public class ReportsController : Controller
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" }; "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 // Load only necessary data — all explicitly scoped to this company
// Jobs: Load all jobs (we need various status filters and the collection is needed for job status distribution) var jobs = (await _unitOfWork.Jobs.FindAsync(j => j.CompanyId == companyId, false, j => j.Customer, j => j.JobStatus, j => j.JobPriority, j => j.AssignedUser)).ToList();
// 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();
// Quotes: Load all quotes (needed for quote status distribution and conversion funnel) // 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) // 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 // 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 // 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 // Appointments: Filter to relevant date range at DB level
var appointments = (await _unitOfWork.Appointments.FindAsync( var appointments = (await _unitOfWork.Appointments.FindAsync(
a => a.ScheduledStartTime >= startDate, a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate,
false, false,
a => a.Customer, a => a.Customer,
a => a.AppointmentType, a => a.AppointmentType,
@@ -108,7 +110,7 @@ public class ReportsController : Controller
// Users with assigned jobs/appointments will be loaded below when building worker stats // Users with assigned jobs/appointments will be loaded below when building worker stats
// CatalogItems: Load all for category distribution // 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 === // === OVERVIEW METRICS ===
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); 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()); .ToDictionary(g => g.Key, g => g.Count());
// === FINANCIAL ANALYTICS === // === 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 activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
var totalInvoiced = activeInvoices.Sum(i => i.Total); var totalInvoiced = activeInvoices.Sum(i => i.Total);
@@ -781,7 +783,7 @@ public class ReportsController : Controller
// === POWDER CONSUMPTION VS PURCHASE === // === POWDER CONSUMPTION VS PURCHASE ===
var allInventoryTransactions = (await _unitOfWork.InventoryTransactions var allInventoryTransactions = (await _unitOfWork.InventoryTransactions
.GetAllAsync(false, t => t.InventoryItem)) .FindAsync(t => t.CompanyId == companyId, false, t => t.InventoryItem))
.ToList(); .ToList();
var powderConsumptionItems = allInventoryTransactions var powderConsumptionItems = allInventoryTransactions
@@ -1309,14 +1311,15 @@ public class ReportsController : Controller
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" }; "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 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.GetAllAsync()).ToList(); var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList(); var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
var equipment = (await _unitOfWork.Equipment.GetAllAsync()).ToList(); var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList(); var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
var allAppointments = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentStatus); var allAppointments = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentStatus);
var appointments = allAppointments.Where(a => a.ScheduledStartTime >= startDate).ToList(); var appointments = allAppointments.ToList();
var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var activeJobs = jobs.Where(j => activeStatusCodes.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 now = DateTime.UtcNow;
var startDate = now.AddMonths(-months); var startDate = now.AddMonths(-months);
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; 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 completedJobs = jobs.Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var inRange = completedJobs.Where(j => j.UpdatedAt >= startDate).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()); 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 completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", var activeStatusCodes = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING",
"MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK", "ON_HOLD" }; "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 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.GetAllAsync()).ToList(); var equipment = (await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId)).ToList();
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList(); var inventory = (await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId)).ToList();
var allAppts = await _unitOfWork.Appointments.GetAllAsync(false, a => a.AppointmentType, a => a.AppointmentStatus); var allAppts = await _unitOfWork.Appointments.FindAsync(a => a.CompanyId == companyId && a.ScheduledStartTime >= startDate, false, a => a.AppointmentType, a => a.AppointmentStatus);
var appointments = allAppts.Where(a => a.ScheduledStartTime >= startDate).ToList(); var appointments = allAppts.ToList();
var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); var activeJobs = jobs.Where(j => activeStatusCodes.Contains(j.JobStatus.StatusCode)).ToList();
var completedJobs = jobs.Where(j => completedStatusCodes.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 now = DateTime.UtcNow;
var startDate = now.AddMonths(-months); var startDate = now.AddMonths(-months);
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList(); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var quotes = (await _unitOfWork.Quotes.GetAllAsync(false, q => q.QuoteStatus)).ToList(); var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId)).ToList();
var catalogItems = (await _unitOfWork.CatalogItems.GetAllAsync(false, c => c.Category)).ToList(); var quotes = (await _unitOfWork.Quotes.FindAsync(q => q.CompanyId == companyId, false, q => q.QuoteStatus)).ToList();
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.Customer, j => j.JobStatus, j => j.JobPriority)) 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(); .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()); 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)); if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var today = DateTime.Today; 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 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 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(); 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<string>(); var monthlyBillsPaid = new List<decimal>(); var monthlyDirectExpenses = new List<decimal>(); var monthLabels = new List<string>(); var monthlyBillsPaid = new List<decimal>(); var monthlyDirectExpenses = new List<decimal>();
// Also load collected payments for P&L comparison // 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 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<decimal>(); var plExpenses = new List<decimal>(); var plNet = new List<decimal>(); var plRevenue = new List<decimal>(); var plExpenses = new List<decimal>(); var plNet = new List<decimal>();
for (var i = months - 1; i >= 0; i--) for (var i = months - 1; i >= 0; i--)
@@ -1609,8 +1617,10 @@ public class ReportsController : Controller
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var startDate = now.AddMonths(-months); var startDate = now.AddMonths(-months);
var powderTransactions = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)) var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
.Where(t => t.TransactionType == InventoryTransactionType.JobUsage && t.TransactionDate >= startDate).ToList(); 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) 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() }) .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
/// <summary>Sales by Customer report — all active (non-voided) invoices grouped by customer, sorted by total invoiced.</summary> /// <summary>Sales by Customer report — all active (non-voided) invoices grouped by customer, sorted by total invoiced.</summary>
public async Task<IActionResult> SalesByCustomer(int months = 6) public async Task<IActionResult> 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 activeInvoices = allInvoices.Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff).ToList();
var items = activeInvoices.Where(i => i.Customer != null) 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 }) .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 now = DateTime.UtcNow;
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var customers = (await _unitOfWork.Customers.GetAllAsync()).ToList(); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode)).ToList(); 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 items = customers.Where(c => c.IsActive).Select(c =>
{ {
var cJobs = completedJobs.Where(j => j.CustomerId == c.Id).ToList(); var cJobs = completedJobs.Where(j => j.CustomerId == c.Id).ToList();
@@ -1682,7 +1694,8 @@ public class ReportsController : Controller
{ {
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" }; 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 allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
var historyByJob = allStatusHistory.GroupBy(h => h.JobId).ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList()); 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" }; 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 now = DateTime.UtcNow;
var today = DateTime.Today; 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 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 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(), 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)); if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var today = DateTime.Today; 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) var items = allInvoices.Where(i => i.Customer != null && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
.Select(i => .Select(i =>
{ {
@@ -1758,7 +1773,8 @@ public class ReportsController : Controller
/// </summary> /// </summary>
public async Task<IActionResult> PowderConsumption(int months = 6) public async Task<IActionResult> 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) 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 }) .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() }) .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<IActionResult> InventoryTurnover(int months = 6) public async Task<IActionResult> InventoryTurnover(int months = 6)
{ {
var daysInPeriod = months * 30.0; var daysInPeriod = months * 30.0;
var inventory = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList(); var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var allTx = (await _unitOfWork.InventoryTransactions.GetAllAsync(false, t => t.InventoryItem)).ToList(); 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 items = inventory.Where(i => i.IsActive).Select(i =>
{ {
var iTx = allTx.Where(t => t.InventoryItemId == i.Id).ToList(); var iTx = allTx.Where(t => t.InventoryItemId == i.Id).ToList();
@@ -1835,8 +1852,9 @@ public class ReportsController : Controller
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var today = DateTime.Today; var today = DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Load invoices for AR data // 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 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(); 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 companyName = await GetCompanyNameAsync();
var today = DateTime.Today; var today = DateTime.Today;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Open AR invoices // 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) .Where(i => i.BalanceDue > 0 && i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff && i.Status != InvoiceStatus.Paid)
.ToList(); .ToList();
// Compute avg days to pay per customer from paid invoices // 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) .Where(i => i.Status == InvoiceStatus.Paid && i.InvoiceDate != default)
.ToList(); .ToList();
var avgDaysByCustomer = paidInvoices var avgDaysByCustomer = paidInvoices
@@ -2137,7 +2156,8 @@ public class ReportsController : Controller
var companyName = await GetCompanyNameAsync(); var companyName = await GetCompanyNameAsync();
var today = DateTime.Today; 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 => var activeInvoices = allInvoices.Where(i =>
i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.Voided &&
i.Status != InvoiceStatus.WrittenOff).ToList(); i.Status != InvoiceStatus.WrittenOff).ToList();
@@ -2256,8 +2276,9 @@ public class ReportsController : Controller
var companyName = await GetCompanyNameAsync(); var companyName = await GetCompanyNameAsync();
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
var startOfYear = new DateTime(now.Year, 1, 1); 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) .Where(i => i.Status != InvoiceStatus.Voided && i.Status != InvoiceStatus.WrittenOff)
.ToList(); .ToList();
@@ -15,11 +15,13 @@ namespace PowderCoating.Web.Controllers;
public class SmsConsentAuditController : Controller public class SmsConsentAuditController : Controller
{ {
private readonly IUnitOfWork _unitOfWork; private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly ILogger<SmsConsentAuditController> _logger; private readonly ILogger<SmsConsentAuditController> _logger;
public SmsConsentAuditController(IUnitOfWork unitOfWork, ILogger<SmsConsentAuditController> logger) public SmsConsentAuditController(IUnitOfWork unitOfWork, ITenantContext tenantContext, ILogger<SmsConsentAuditController> logger)
{ {
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_logger = logger; _logger = logger;
} }
@@ -30,7 +32,8 @@ public class SmsConsentAuditController : Controller
{ {
try 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)) if (!string.IsNullOrWhiteSpace(search))
{ {
@@ -98,7 +101,8 @@ public class SmsConsentAuditController : Controller
{ {
try 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) .OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
.ToList(); .ToList();
@@ -32,7 +32,8 @@ public class TaxRatesController : Controller
[HttpGet] [HttpGet]
public async Task<IActionResult> Index() public async Task<IActionResult> 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()); return View(rates.OrderBy(r => r.Name).ToList());
} }
@@ -87,7 +87,8 @@ public class ToolsController : Controller
[HttpGet] [HttpGet]
public async Task<IActionResult> GetImportAccounts() public async Task<IActionResult> 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 var revenue = allAccounts
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive) .Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
@@ -123,7 +124,8 @@ public class ToolsController : Controller
/// </summary> /// </summary>
private async Task PopulateImportAccountDropdownsAsync() 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 var revenueAccounts = allAccounts
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive) .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 // Validate account IDs belong to this company — stale page load can produce IDs
// that were valid before a data reset but no longer exist. // 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(); .Select(a => a.Id).ToHashSet();
if (revenueAccountId.HasValue && !validAccountIds.Contains(revenueAccountId.Value)) if (revenueAccountId.HasValue && !validAccountIds.Contains(revenueAccountId.Value))
revenueAccountId = null; revenueAccountId = null;
@@ -1167,7 +1169,7 @@ public class ToolsController : Controller
// Validate account IDs belong to this company — stale page load can produce IDs // Validate account IDs belong to this company — stale page load can produce IDs
// that were valid before a data reset but no longer exist. // 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(); .Select(a => a.Id).ToHashSet();
if (inventoryAccountId.HasValue && !validAccountIds.Contains(inventoryAccountId.Value)) if (inventoryAccountId.HasValue && !validAccountIds.Contains(inventoryAccountId.Value))
inventoryAccountId = null; 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)) using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true))
{ {
// 1. Customers // 1. Customers
var customers = await _unitOfWork.Customers.GetAllAsync(); var customers = await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId.Value);
var customersCsv = GenerateCustomersCsv(customers); var customersCsv = GenerateCustomersCsv(customers);
var customersEntry = archive.CreateEntry($"customers_{timestamp}.csv"); var customersEntry = archive.CreateEntry($"customers_{timestamp}.csv");
using (var entryStream = customersEntry.Open()) using (var entryStream = customersEntry.Open())
@@ -1949,7 +1951,7 @@ public class ToolsController : Controller
} }
// 2. Quotes // 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 quotesCsv = GenerateQuotesCsv(quotes);
var quotesEntry = archive.CreateEntry($"quotes_{timestamp}.csv"); var quotesEntry = archive.CreateEntry($"quotes_{timestamp}.csv");
using (var entryStream = quotesEntry.Open()) using (var entryStream = quotesEntry.Open())
@@ -1959,7 +1961,7 @@ public class ToolsController : Controller
} }
// 3. Jobs // 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 jobsCsv = GenerateJobsCsv(jobs);
var jobsEntry = archive.CreateEntry($"jobs_{timestamp}.csv"); var jobsEntry = archive.CreateEntry($"jobs_{timestamp}.csv");
using (var entryStream = jobsEntry.Open()) using (var entryStream = jobsEntry.Open())
@@ -1969,7 +1971,7 @@ public class ToolsController : Controller
} }
// 4. Appointments // 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); a => a.Customer, a => a.AppointmentType, a => a.AppointmentStatus);
var appointmentsCsv = GenerateAppointmentsCsv(appointments); var appointmentsCsv = GenerateAppointmentsCsv(appointments);
var appointmentsEntry = archive.CreateEntry($"appointments_{timestamp}.csv"); var appointmentsEntry = archive.CreateEntry($"appointments_{timestamp}.csv");
@@ -1980,9 +1982,9 @@ public class ToolsController : Controller
} }
// 5. Catalog // 5. Catalog
var catalogCategories = await _unitOfWork.CatalogCategories.GetAllAsync(); var catalogCategories = await _unitOfWork.CatalogCategories.FindAsync(cc => cc.CompanyId == companyId.Value);
var catalogCategoryPaths = BuildCategoryPathMap(catalogCategories); 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 catalogCsv = GenerateCatalogCsv(catalog, catalogCategoryPaths);
var catalogEntry = archive.CreateEntry($"catalog_{timestamp}.csv"); var catalogEntry = archive.CreateEntry($"catalog_{timestamp}.csv");
using (var entryStream = catalogEntry.Open()) using (var entryStream = catalogEntry.Open())
@@ -1992,7 +1994,7 @@ public class ToolsController : Controller
} }
// 6. Inventory // 6. Inventory
var inventory = await _unitOfWork.InventoryItems.GetAllAsync(); var inventory = await _unitOfWork.InventoryItems.FindAsync(i => i.CompanyId == companyId.Value);
var inventoryCsv = GenerateInventoryCsv(inventory); var inventoryCsv = GenerateInventoryCsv(inventory);
var inventoryEntry = archive.CreateEntry($"inventory_{timestamp}.csv"); var inventoryEntry = archive.CreateEntry($"inventory_{timestamp}.csv");
using (var entryStream = inventoryEntry.Open()) using (var entryStream = inventoryEntry.Open())
@@ -2002,7 +2004,7 @@ public class ToolsController : Controller
} }
// 7. Equipment // 7. Equipment
var equipment = await _unitOfWork.Equipment.GetAllAsync(); var equipment = await _unitOfWork.Equipment.FindAsync(e => e.CompanyId == companyId.Value);
var equipmentCsv = GenerateEquipmentCsv(equipment); var equipmentCsv = GenerateEquipmentCsv(equipment);
var equipmentEntry = archive.CreateEntry($"equipment_{timestamp}.csv"); var equipmentEntry = archive.CreateEntry($"equipment_{timestamp}.csv");
using (var entryStream = equipmentEntry.Open()) using (var entryStream = equipmentEntry.Open())
@@ -2012,7 +2014,7 @@ public class ToolsController : Controller
} }
// 8. Maintenance // 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 maintenanceCsv = GenerateMaintenanceCsv(maintenance);
var maintenanceEntry = archive.CreateEntry($"maintenance_{timestamp}.csv"); var maintenanceEntry = archive.CreateEntry($"maintenance_{timestamp}.csv");
using (var entryStream = maintenanceEntry.Open()) using (var entryStream = maintenanceEntry.Open())
@@ -2022,7 +2024,7 @@ public class ToolsController : Controller
} }
// 9. Vendors // 9. Vendors
var vendors = await _unitOfWork.Vendors.GetAllAsync(); var vendors = await _unitOfWork.Vendors.FindAsync(v => v.CompanyId == companyId.Value);
var vendorsCsv = GenerateVendorsCsv(vendors); var vendorsCsv = GenerateVendorsCsv(vendors);
var vendorsEntry = archive.CreateEntry($"vendors_{timestamp}.csv"); var vendorsEntry = archive.CreateEntry($"vendors_{timestamp}.csv");
using (var entryStream = vendorsEntry.Open()) using (var entryStream = vendorsEntry.Open())
@@ -2032,7 +2034,7 @@ public class ToolsController : Controller
} }
// 10. Prep Services // 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 prepServicesCsv = GeneratePrepServicesCsv(prepServices);
var prepServicesEntry = archive.CreateEntry($"prep_services_{timestamp}.csv"); var prepServicesEntry = archive.CreateEntry($"prep_services_{timestamp}.csv");
using (var entryStream = prepServicesEntry.Open()) using (var entryStream = prepServicesEntry.Open())
@@ -2042,7 +2044,7 @@ public class ToolsController : Controller
} }
// 11. Invoices // 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 invoicesCsv = GenerateInvoicesCsv(invoices);
var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv"); var invoicesEntry = archive.CreateEntry($"invoices_{timestamp}.csv");
using (var entryStream = invoicesEntry.Open()) using (var entryStream = invoicesEntry.Open())
@@ -2052,7 +2054,7 @@ public class ToolsController : Controller
} }
// 12. Chart of Accounts // 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 accountsCsv = GenerateChartOfAccountsCsv(accounts);
var accountsEntry = archive.CreateEntry($"chart_of_accounts_{timestamp}.csv"); var accountsEntry = archive.CreateEntry($"chart_of_accounts_{timestamp}.csv");
using (var entryStream = accountsEntry.Open()) using (var entryStream = accountsEntry.Open())
@@ -2062,7 +2064,7 @@ public class ToolsController : Controller
} }
// 13. Expenses // 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 expensesCsv = GenerateExpensesCsv(expenses);
var expensesEntry = archive.CreateEntry($"expenses_{timestamp}.csv"); var expensesEntry = archive.CreateEntry($"expenses_{timestamp}.csv");
using (var entryStream = expensesEntry.Open()) using (var entryStream = expensesEntry.Open())
@@ -2072,7 +2074,7 @@ public class ToolsController : Controller
} }
// 14. Payments // 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 paymentsCsv = GeneratePaymentsCsv(payments);
var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv"); var paymentsEntry = archive.CreateEntry($"payments_{timestamp}.csv");
using (var entryStream = paymentsEntry.Open()) using (var entryStream = paymentsEntry.Open())
@@ -2258,9 +2260,9 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 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 csv = GenerateCatalogCsv(catalogItems, catalogCategoryPaths);
var fileName = $"catalog_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"catalog_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2326,7 +2328,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 csv = GenerateEquipmentCsv(equipment);
var fileName = $"equipment_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"equipment_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -2407,13 +2409,13 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); return RedirectToAction(nameof(Index));
} }
// Load all lookup tables // Load all lookup tables — scoped to this company
var jobStatuses = await _unitOfWork.JobStatusLookups.GetAllAsync(); var jobStatuses = await _unitOfWork.JobStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var jobPriorities = await _unitOfWork.JobPriorityLookups.GetAllAsync(); var jobPriorities = await _unitOfWork.JobPriorityLookups.FindAsync(p => p.CompanyId == companyId.Value);
var quoteStatuses = await _unitOfWork.QuoteStatusLookups.GetAllAsync(); var quoteStatuses = await _unitOfWork.QuoteStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.GetAllAsync(); var inventoryCategories = await _unitOfWork.InventoryCategoryLookups.FindAsync(c => c.CompanyId == companyId.Value);
var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.GetAllAsync(); var appointmentStatuses = await _unitOfWork.AppointmentStatusLookups.FindAsync(s => s.CompanyId == companyId.Value);
var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.GetAllAsync(); var appointmentTypes = await _unitOfWork.AppointmentTypeLookups.FindAsync(t => t.CompanyId == companyId.Value);
var csv = GenerateCompanySettingsCsv(company, jobStatuses, jobPriorities, var csv = GenerateCompanySettingsCsv(company, jobStatuses, jobPriorities,
quoteStatuses, inventoryCategories, appointmentStatuses, appointmentTypes); quoteStatuses, inventoryCategories, appointmentStatuses, appointmentTypes);
@@ -4092,7 +4094,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 csv = GeneratePrepServicesCsv(prepServices);
var fileName = $"prep_services_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"prep_services_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -4124,7 +4126,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 csv = GenerateVendorsCsv(vendors);
var fileName = $"vendors_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"vendors_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -4156,7 +4158,7 @@ public class ToolsController : Controller
return RedirectToAction(nameof(Index)); 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 csv = GenerateChartOfAccountsCsv(accounts);
var fileName = $"chart_of_accounts_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv"; var fileName = $"chart_of_accounts_export_{DateTime.UtcNow:yyyyMMddHHmmss}.csv";
@@ -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;
/// <summary>
/// Verifies that the explicit <c>CompanyId == companyId</c> 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.
/// </summary>
public class MultiTenantIsolationTests
{
// ── Repository-level isolation ────────────────────────────────────────────
/// <summary>
/// FindAsync with an explicit CompanyId predicate returns only the matching company's rows,
/// even when rows from other companies exist in the database.
/// </summary>
[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<ILogger<SmsConsentAuditController>>());
SetHttpContext(controller);
var result = await controller.Index();
var view = Assert.IsType<ViewResult>(result);
var vm = Assert.IsType<SmsConsentAuditViewModel>(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<ILogger<SmsConsentAuditController>>());
SetHttpContext(controller);
var result = await controller.ExportCsv();
var file = Assert.IsType<FileContentResult>(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<ILogger<TaxRatesController>>());
SetHttpContext(controller);
var result = await controller.Index();
var view = Assert.IsType<ViewResult>(result);
var rates = Assert.IsAssignableFrom<IEnumerable<TaxRate>>(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<ILogger<RecurringTemplatesController>>());
SetHttpContext(controller);
var result = await controller.Index();
var view = Assert.IsType<ViewResult>(result);
var templates = Assert.IsAssignableFrom<IEnumerable<RecurringTemplate>>(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<ViewResult>(result);
var templates = Assert.IsAssignableFrom<IEnumerable<JobTemplate>>(view.Model).ToList();
Assert.Single(templates);
Assert.Equal("Standard Wheel Coat", templates[0].Name);
}
// ── Cross-tenant write protection ─────────────────────────────────────────
/// <summary>
/// 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.
/// </summary>
[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<ILogger<SmsConsentAuditController>>());
SetHttpContext(controller);
var result = await controller.ExportCsv();
var file = Assert.IsType<FileContentResult>(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<ApplicationDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString())
.Options;
return new ApplicationDbContext(options);
}
/// <summary>Returns a mock ITenantContext that always yields the given companyId.</summary>
private static ITenantContext MockTenant(int companyId)
{
var mock = new Mock<ITenantContext>();
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<UserManager<ApplicationUser>> CreateUserManagerMock()
{
var store = new Mock<IUserStore<ApplicationUser>>();
return new Mock<UserManager<ApplicationUser>>(
store.Object, null!, null!, null!, null!, null!, null!, null!, null!);
}
}