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
@@ -10,7 +10,6 @@ using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -22,7 +21,6 @@ public class BugReportController : Controller
private readonly IMapper _mapper;
private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _context;
private readonly IEmailService _emailService;
private readonly IAdminNotificationService _adminNotification;
private readonly IAzureBlobStorageService _blobService;
@@ -40,7 +38,6 @@ public class BugReportController : Controller
IMapper mapper,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager,
ApplicationDbContext context,
IEmailService emailService,
IAdminNotificationService adminNotification,
IAzureBlobStorageService blobService,
@@ -51,7 +48,6 @@ public class BugReportController : Controller
_mapper = mapper;
_tenantContext = tenantContext;
_userManager = userManager;
_context = context;
_emailService = emailService;
_adminNotification = adminNotification;
_blobService = blobService;
@@ -153,7 +149,7 @@ public class BugReportController : Controller
ContentType = file.ContentType,
FileSizeBytes = file.Length
};
_context.BugReportAttachments.Add(attachment);
await _unitOfWork.BugReportAttachments.AddAsync(attachment);
uploadedCount++;
}
else
@@ -164,7 +160,7 @@ public class BugReportController : Controller
}
if (uploadedCount > 0)
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
}
_logger.LogInformation("Bug report #{Id} submitted by {UserName} ({Company}): {Title} with {AttachmentCount} attachment(s)",
@@ -211,16 +207,13 @@ public class BugReportController : Controller
pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
var query = _context.BugReports
.AsNoTracking()
.IgnoreQueryFilters()
.Where(r => !r.IsDeleted)
.AsQueryable();
var allReports = (await _unitOfWork.BugReports.GetAllAsync(ignoreQueryFilters: true))
.AsEnumerable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
query = query.Where(r =>
allReports = allReports.Where(r =>
r.Title.ToLower().Contains(search) ||
r.Description.ToLower().Contains(search) ||
r.SubmittedByUserName.ToLower().Contains(search));
@@ -228,31 +221,31 @@ public class BugReportController : Controller
if (!string.IsNullOrWhiteSpace(statusFilter) &&
Enum.TryParse<BugReportStatus>(statusFilter, out var status))
query = query.Where(r => r.Status == status);
allReports = allReports.Where(r => r.Status == status);
if (!string.IsNullOrWhiteSpace(priorityFilter) &&
Enum.TryParse<BugReportPriority>(priorityFilter, out var priority))
query = query.Where(r => r.Priority == priority);
allReports = allReports.Where(r => r.Priority == priority);
query = (sortColumn, sortDirection == "asc") switch
allReports = (sortColumn, sortDirection == "asc") switch
{
("Title", true) => query.OrderBy(r => r.Title),
("Title", false) => query.OrderByDescending(r => r.Title),
("Status", true) => query.OrderBy(r => r.Status),
("Status", false) => query.OrderByDescending(r => r.Status),
("Priority", true) => query.OrderBy(r => r.Priority),
("Priority", false) => query.OrderByDescending(r => r.Priority),
("Submitted", true) => query.OrderBy(r => r.SubmittedByUserName),
("Submitted", false) => query.OrderByDescending(r => r.SubmittedByUserName),
(_, true) => query.OrderBy(r => r.CreatedAt),
_ => query.OrderByDescending(r => r.CreatedAt)
("Title", true) => allReports.OrderBy(r => r.Title),
("Title", false) => allReports.OrderByDescending(r => r.Title),
("Status", true) => allReports.OrderBy(r => r.Status),
("Status", false) => allReports.OrderByDescending(r => r.Status),
("Priority", true) => allReports.OrderBy(r => r.Priority),
("Priority", false) => allReports.OrderByDescending(r => r.Priority),
("Submitted", true) => allReports.OrderBy(r => r.SubmittedByUserName),
("Submitted", false) => allReports.OrderByDescending(r => r.SubmittedByUserName),
(_, true) => allReports.OrderBy(r => r.CreatedAt),
_ => allReports.OrderByDescending(r => r.CreatedAt)
};
var totalCount = await query.CountAsync();
var items = await query
var totalCount = allReports.Count();
var items = allReports
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
.ToList();
var dtos = _mapper.Map<List<BugReportDto>>(items);
@@ -291,12 +284,10 @@ public class BugReportController : Controller
var dto = _mapper.Map<EditBugReportDto>(bugReport);
var attachments = await _context.BugReportAttachments
.AsNoTracking()
.IgnoreQueryFilters()
.Where(a => a.BugReportId == id && !a.IsDeleted)
var attachments = (await _unitOfWork.BugReportAttachments.FindAsync(
a => a.BugReportId == id && !a.IsDeleted, ignoreQueryFilters: true))
.OrderBy(a => a.CreatedAt)
.ToListAsync();
.ToList();
dto.Attachments = _mapper.Map<List<BugReportAttachmentDto>>(attachments);
@@ -319,10 +310,7 @@ public class BugReportController : Controller
[HttpGet]
public async Task<IActionResult> Attachment(int id)
{
var attachment = await _context.BugReportAttachments
.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.Id == id && !a.IsDeleted);
var attachment = await _unitOfWork.BugReportAttachments.GetByIdAsync(id, ignoreQueryFilters: true);
if (attachment == null)
return NotFound();