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,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces.Repositories;
using PowderCoating.Infrastructure.Data;
@@ -60,4 +61,69 @@ public class NotificationLogRepository : Repository<NotificationLog>, INotificat
.Where(n => n.JobId == jobId)
.OrderByDescending(n => n.SentAt)
.ToListAsync();
/// <inheritdoc/>
public async Task<(List<NotificationLog> Items, int TotalCount)> GetPagedFilteredAsync(
int pageNumber, int pageSize,
string? searchTerm = null,
NotificationChannel? channel = null,
NotificationStatus? status = null,
NotificationType? type = null,
int? jobId = null,
string sortColumn = "SentAt",
string sortDirection = "desc")
{
var query = _dbSet
.AsNoTracking()
.Include(n => n.Customer)
.Include(n => n.Job)
.Include(n => n.Quote)
.AsQueryable();
if (jobId.HasValue)
query = query.Where(n => n.JobId == jobId.Value);
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var s = searchTerm.ToLower();
query = query.Where(n =>
n.RecipientName.ToLower().Contains(s) ||
n.Recipient.ToLower().Contains(s) ||
(n.Subject != null && n.Subject.ToLower().Contains(s)) ||
(n.Job != null && n.Job.JobNumber.ToLower().Contains(s)) ||
(n.Quote != null && n.Quote.QuoteNumber.ToLower().Contains(s)));
}
if (channel.HasValue)
query = query.Where(n => n.Channel == channel.Value);
if (status.HasValue)
query = query.Where(n => n.Status == status.Value);
if (type.HasValue)
query = query.Where(n => n.NotificationType == type.Value);
var totalCount = await query.CountAsync();
query = (sortColumn, sortDirection) switch
{
("RecipientName", "asc") => query.OrderBy(n => n.RecipientName),
("RecipientName", _) => query.OrderByDescending(n => n.RecipientName),
("Channel", "asc") => query.OrderBy(n => n.Channel),
("Channel", _) => query.OrderByDescending(n => n.Channel),
("Status", "asc") => query.OrderBy(n => n.Status),
("Status", _) => query.OrderByDescending(n => n.Status),
("Type", "asc") => query.OrderBy(n => n.NotificationType),
("Type", _) => query.OrderByDescending(n => n.NotificationType),
(_, "asc") => query.OrderBy(n => n.SentAt),
_ => query.OrderByDescending(n => n.SentAt)
};
var items = await query
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
return (items, totalCount);
}
}