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
@@ -1,8 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -10,28 +9,21 @@ namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only cross-company notification log viewer.
/// The company-scoped version lives in NotificationLogsController (CanManageJobs policy).
/// Uses IgnoreQueryFilters throughout to bypass the multi-tenancy global filter and see
/// logs from all companies simultaneously.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class PlatformNotificationsController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IUnitOfWork _unitOfWork;
public PlatformNotificationsController(ApplicationDbContext db) => _db = db;
public PlatformNotificationsController(IUnitOfWork unitOfWork) => _unitOfWork = unitOfWork;
/// <summary>
/// Renders a paginated, filterable cross-company notification log view visible
/// only to SuperAdmins. Unlike the tenant-scoped
/// <see cref="NotificationLogsController.Index"/>, this action uses
/// <c>IgnoreQueryFilters()</c> to bypass the multi-tenancy global filter and
/// see logs from all companies simultaneously.
/// <para>
/// Company names are resolved in a single follow-up query after paging (not via
/// a JOIN) because company data lives outside the notification-log table's usual
/// query scope, and a separate query keeps the main sort/filter logic clean.
/// Summary counts (total, failed, last 24 h) are computed as independent queries
/// against the full unfiltered dataset so the summary reflects platform-wide
/// health regardless of the active filters.
/// </para>
/// Renders a paginated, filterable cross-company notification log view visible only to SuperAdmins.
/// All filter/sort/paging is applied in-memory after a single filtered repository fetch.
/// Company names are resolved in a follow-up batch query keyed on the page's distinct company IDs.
/// Summary counts reflect the full unfiltered dataset so the health cards are accurate regardless of active filters.
/// </summary>
public async Task<IActionResult> Index(
int? companyId,
@@ -47,41 +39,38 @@ public class PlatformNotificationsController : Controller
pageSize = pageSize is 25 or 50 or 100 ? pageSize : 50;
page = Math.Max(1, page);
var query = _db.NotificationLogs
.AsNoTracking()
.IgnoreQueryFilters()
.Where(n => !n.IsDeleted)
.AsQueryable();
// Load all non-deleted logs across all tenants, then filter in-memory.
var all = (await _unitOfWork.NotificationLogs.FindAsync(n => !n.IsDeleted, ignoreQueryFilters: true))
.AsEnumerable();
if (companyId.HasValue)
query = query.Where(n => n.CompanyId == companyId);
all = all.Where(n => n.CompanyId == companyId);
if (!string.IsNullOrWhiteSpace(type) && Enum.TryParse<NotificationType>(type, out var typeEnum))
query = query.Where(n => n.NotificationType == typeEnum);
all = all.Where(n => n.NotificationType == typeEnum);
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<NotificationStatus>(status, out var statusEnum))
query = query.Where(n => n.Status == statusEnum);
all = all.Where(n => n.Status == statusEnum);
if (!string.IsNullOrWhiteSpace(channel) && Enum.TryParse<NotificationChannel>(channel, out var channelEnum))
query = query.Where(n => n.Channel == channelEnum);
all = all.Where(n => n.Channel == channelEnum);
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(n =>
n.RecipientName.Contains(search) ||
n.Recipient.Contains(search) ||
(n.Subject != null && n.Subject.Contains(search)));
all = all.Where(n =>
n.RecipientName.Contains(search, StringComparison.OrdinalIgnoreCase) ||
n.Recipient.Contains(search, StringComparison.OrdinalIgnoreCase) ||
(n.Subject != null && n.Subject.Contains(search, StringComparison.OrdinalIgnoreCase)));
if (from.HasValue)
query = query.Where(n => n.SentAt >= from.Value.Date);
all = all.Where(n => n.SentAt >= from.Value.Date);
if (to.HasValue)
query = query.Where(n => n.SentAt < to.Value.Date.AddDays(1));
all = all.Where(n => n.SentAt < to.Value.Date.AddDays(1));
query = query.OrderByDescending(n => n.SentAt);
var filtered = all.OrderByDescending(n => n.SentAt).ToList();
var totalCount = filtered.Count;
var totalCount = await query.CountAsync();
var items = await query
var items = filtered
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(n => new PlatformNotificationRow
@@ -97,30 +86,26 @@ public class PlatformNotificationsController : Controller
ErrorMessage = n.ErrorMessage,
SentAt = n.SentAt
})
.ToListAsync();
.ToList();
// Resolve company names for the rows in one query
// Resolve company names for the page's rows in one query
var cids = items.Select(i => i.CompanyId).Distinct().ToList();
var companyNames = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => cids.Contains(c.Id))
.ToDictionaryAsync(c => c.Id, c => c.CompanyName);
var companyNames = (await _unitOfWork.Companies.FindAsync(c => cids.Contains(c.Id), ignoreQueryFilters: true))
.ToDictionary(c => c.Id, c => c.CompanyName);
foreach (var item in items)
item.CompanyName = companyNames.GetValueOrDefault(item.CompanyId);
// Sidebar company list for filter dropdown
var companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
var companies = (await _unitOfWork.Companies.FindAsync(c => !c.IsDeleted, ignoreQueryFilters: true))
.OrderBy(c => c.CompanyName)
.Select(c => new { c.Id, c.CompanyName })
.ToListAsync();
.ToList();
// Summary counts
ViewBag.TotalCount = await _db.NotificationLogs.IgnoreQueryFilters().CountAsync(n => !n.IsDeleted);
ViewBag.FailedCount = await _db.NotificationLogs.IgnoreQueryFilters()
.CountAsync(n => !n.IsDeleted && n.Status == NotificationStatus.Failed);
ViewBag.Last24hCount = await _db.NotificationLogs.IgnoreQueryFilters()
.CountAsync(n => !n.IsDeleted && n.SentAt >= DateTime.UtcNow.AddHours(-24));
// Summary counts from the unfiltered dataset
var allLogs = await _unitOfWork.NotificationLogs.FindAsync(n => !n.IsDeleted, ignoreQueryFilters: true);
ViewBag.TotalCount = allLogs.Count();
ViewBag.FailedCount = allLogs.Count(n => n.Status == NotificationStatus.Failed);
ViewBag.Last24hCount = allLogs.Count(n => n.SentAt >= DateTime.UtcNow.AddHours(-24));
ViewBag.Companies = companies;
ViewBag.CompanyIdFilter = companyId;
@@ -139,23 +124,17 @@ public class PlatformNotificationsController : Controller
}
/// <summary>
/// Returns the full notification log entry for the given <paramref name="id"/>,
/// including the complete message body and error details. The owning company name
/// is resolved separately (rather than via navigation property) because company
/// records are in a different query-filter context.
/// Returns the full notification log entry for the given id, including the complete message body and error details.
/// </summary>
public async Task<IActionResult> Details(int id)
{
var log = await _db.NotificationLogs.AsNoTracking().IgnoreQueryFilters()
.FirstOrDefaultAsync(n => n.Id == id);
var log = await _unitOfWork.NotificationLogs.FirstOrDefaultAsync(n => n.Id == id, ignoreQueryFilters: true);
if (log == null) return NotFound();
string? companyName = null;
if (log.CompanyId > 0)
companyName = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => c.Id == log.CompanyId)
.Select(c => c.CompanyName)
.FirstOrDefaultAsync();
companyName = (await _unitOfWork.Companies.FirstOrDefaultAsync(
c => c.Id == log.CompanyId, ignoreQueryFilters: true))?.CompanyName;
ViewBag.CompanyName = companyName;
return View(log);