Phases 3 & 4: Complete data access architecture migration

Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers,
routing all data access through IUnitOfWork. Added IPlainRepository<T> for
the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote)
that intentionally don't extend BaseEntity and therefore can't use the
constrained IRepository<T>. Added permanent-exception comments to the 18
controllers that legitimately retain direct DbContext access (Identity infra,
cross-tenant platform ops, bulk streaming exports).

Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup
gate that reflects over every Controller subclass and throws at boot if any
non-exempt controller injects ApplicationDbContext. The app cannot start with
a violation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-28 09:17:29 -04:00
parent 90bc0d965f
commit 1cb7a8ca4a
72 changed files with 9060 additions and 2323 deletions
@@ -11,8 +11,6 @@ using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using Microsoft.AspNetCore.Identity;
using PowderCoating.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using QRCoder;
using System.Drawing;
using System.Drawing.Imaging;
@@ -29,7 +27,6 @@ public class InventoryController : Controller
private readonly IMeasurementConversionService _measurementService;
private readonly IInventoryAiLookupService _aiLookupService;
private readonly ISubscriptionService _subscriptionService;
private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager;
public InventoryController(
@@ -40,7 +37,6 @@ public class InventoryController : Controller
IMeasurementConversionService measurementService,
IInventoryAiLookupService aiLookupService,
ISubscriptionService subscriptionService,
ApplicationDbContext context,
UserManager<ApplicationUser> userManager)
{
_unitOfWork = unitOfWork;
@@ -50,7 +46,6 @@ public class InventoryController : Controller
_measurementService = measurementService;
_aiLookupService = aiLookupService;
_subscriptionService = subscriptionService;
_context = context;
_userManager = userManager;
}
@@ -166,12 +161,12 @@ public class InventoryController : Controller
TotalCount = totalCount
};
// Push stats and category list to the database rather than loading all rows
var statsBase = _context.InventoryItems;
ViewBag.Categories = await statsBase.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToListAsync();
ViewBag.StatsLowStockCount = await statsBase.CountAsync(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
ViewBag.StatsActiveCount = await statsBase.CountAsync(i => i.IsActive);
ViewBag.StatsTotalValue = await statsBase.SumAsync(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
// Load all items once to compute sidebar stats and category list in memory
var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
// Set ViewBag for sorting and filters
ViewBag.SearchTerm = searchTerm;
@@ -560,20 +555,8 @@ public class InventoryController : Controller
if (string.IsNullOrEmpty(colorName) && string.IsNullOrEmpty(name))
return Json(new { success = true, photos = Array.Empty<object>(), totalCount = 0, page, pageSize });
IQueryable<JobPhoto> query = _context.JobPhotos
.AsNoTracking()
.Include(p => p.Job).ThenInclude(j => j.Customer)
.Where(p => !p.IsDeleted && p.Tags != null && p.Tags != "");
if (!string.IsNullOrEmpty(colorName) && !string.IsNullOrEmpty(name) && colorName != name)
query = query.Where(p => p.Tags!.ToLower().Contains(colorName) || p.Tags!.ToLower().Contains(name));
else if (!string.IsNullOrEmpty(colorName))
query = query.Where(p => p.Tags!.ToLower().Contains(colorName));
else
query = query.Where(p => p.Tags!.ToLower().Contains(name!));
// Exact tag match (avoid "Black" matching "Gloss Black Semi-Gloss")
var allMatches = await query.OrderByDescending(p => p.UploadedDate).ToListAsync();
var allMatches = await _unitOfWork.JobPhotos.GetTaggedPhotosAsync(colorName, name);
var searchTerms = new[] { colorName, name }
.Where(s => !string.IsNullOrEmpty(s))
@@ -620,15 +603,7 @@ public class InventoryController : Controller
{
try
{
var photos = await _context.JobPhotos
.AsNoTracking()
.Include(p => p.Job).ThenInclude(j => j.Customer)
.Include(p => p.Job).ThenInclude(j => j.JobItems).ThenInclude(ji => ji.Coats)
.Where(p => !p.IsDeleted &&
p.Job != null &&
p.Job.JobItems.Any(ji => ji.Coats.Any(c => c.InventoryItemId == id)))
.OrderByDescending(p => p.UploadedDate)
.ToListAsync();
var photos = await _unitOfWork.JobPhotos.GetPhotosByPowderItemAsync(id);
var totalCount = photos.Count;
var paged = photos
@@ -728,12 +703,12 @@ public class InventoryController : Controller
{
try
{
var allCoatings = await _context.InventoryItems
.AsNoTracking()
.Include(i => i.InventoryCategory)
.Where(i => !i.IsDeleted && i.InventoryCategory != null && i.InventoryCategory.IsCoating)
var allCoatings = (await _unitOfWork.InventoryItems.FindAsync(
i => i.InventoryCategory != null && i.InventoryCategory.IsCoating,
false,
i => i.InventoryCategory))
.OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name)
.ToListAsync();
.ToList();
// Distinct manufacturer list for filter dropdown
ViewBag.Manufacturers = allCoatings
@@ -955,25 +930,39 @@ public class InventoryController : Controller
var userId = _userManager.GetUserId(User);
var myJobs = await _context.Jobs
.AsNoTracking()
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Where(j => !j.IsDeleted && !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId)
var myJobs = (await _unitOfWork.Jobs.FindAsync(
j => !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId,
false,
j => j.Customer,
j => j.JobStatus))
.OrderBy(j => j.JobNumber)
.Select(j => new ScanJobOption { Id = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer != null ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) : "No Customer" })
.ToListAsync();
.Select(j => new ScanJobOption
{
Id = j.Id,
JobNumber = j.JobNumber,
CustomerName = j.Customer != null
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
: "No Customer"
})
.ToList();
var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
var otherJobs = await _context.Jobs
.AsNoTracking()
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Where(j => !j.IsDeleted && !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id))
var otherJobs = (await _unitOfWork.Jobs.FindAsync(
j => !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id),
false,
j => j.Customer,
j => j.JobStatus))
.OrderByDescending(j => j.CreatedAt)
.Take(100)
.Select(j => new ScanJobOption { Id = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer != null ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) : "No Customer" })
.ToListAsync();
.Select(j => new ScanJobOption
{
Id = j.Id,
JobNumber = j.JobNumber,
CustomerName = j.Customer != null
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
: "No Customer"
})
.ToList();
ViewBag.ItemDto = _mapper.Map<InventoryItemDto>(item);
ViewBag.MyJobs = myJobs;
@@ -1169,29 +1158,8 @@ public class InventoryController : Controller
.ToList();
// Build transactions query
var txnQuery = _context.InventoryTransactions
.AsNoTracking()
.Include(t => t.InventoryItem)
.Include(t => t.PurchaseOrder)
.Include(t => t.Job)
.Where(t => !t.IsDeleted);
if (inventoryItemId.HasValue)
txnQuery = txnQuery.Where(t => t.InventoryItemId == inventoryItemId.Value);
if (dateFrom.HasValue)
txnQuery = txnQuery.Where(t => t.TransactionDate >= dateFrom.Value);
if (dateTo.HasValue)
txnQuery = txnQuery.Where(t => t.TransactionDate < dateTo.Value.AddDays(1));
if (!string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<InventoryTransactionType>(typeFilter, out var parsedType))
txnQuery = txnQuery.Where(t => t.TransactionType == parsedType);
var transactions = await txnQuery
.OrderByDescending(t => t.TransactionDate)
.Take(500)
.ToListAsync();
InventoryTransactionType? parsedType = !string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<InventoryTransactionType>(typeFilter, out var pt) ? pt : null;
var transactions = await _unitOfWork.InventoryTransactions.GetForLedgerAsync(inventoryItemId, dateFrom, dateTo, parsedType);
// Resolve JobId for legacy JobUsage transactions that stored job number in Reference but not JobId
var unresolvedRefs = transactions
@@ -1202,36 +1170,12 @@ public class InventoryController : Controller
var jobRefLookup = new Dictionary<string, (int Id, string JobNumber)>();
if (unresolvedRefs.Any())
{
var matched = await _context.Jobs
.AsNoTracking()
.Where(j => unresolvedRefs.Contains(j.JobNumber))
.Select(j => new { j.Id, j.JobNumber })
.ToListAsync();
var matched = await _unitOfWork.Jobs.FindAsync(j => unresolvedRefs.Contains(j.JobNumber));
jobRefLookup = matched.ToDictionary(j => j.JobNumber, j => (j.Id, j.JobNumber));
}
// Build powder usage logs query
var usageQuery = _context.PowderUsageLogs
.AsNoTracking()
.Include(u => u.Job).ThenInclude(j => j.Customer)
.Include(u => u.InventoryItem)
.Include(u => u.JobItemCoat)
.Where(u => !u.IsDeleted);
if (inventoryItemId.HasValue)
usageQuery = usageQuery.Where(u => u.InventoryItemId == inventoryItemId.Value);
if (dateFrom.HasValue)
usageQuery = usageQuery.Where(u => u.RecordedAt >= dateFrom.Value);
if (dateTo.HasValue)
usageQuery = usageQuery.Where(u => u.RecordedAt < dateTo.Value.AddDays(1));
// Exclude JobUsage type from transactions when showing usage tab (avoid double-counting display)
var usageLogs = await usageQuery
.OrderByDescending(u => u.RecordedAt)
.Take(500)
.ToListAsync();
// Powder usage logs with dynamic date + item filters
var usageLogs = await _unitOfWork.PowderUsageLogs.GetForLedgerAsync(inventoryItemId, dateFrom, dateTo);
InventoryItem? selectedItem = null;
if (inventoryItemId.HasValue)