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:
@@ -1,10 +1,9 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.DTOs.Common;
|
||||
using PowderCoating.Application.DTOs.Notification;
|
||||
using PowderCoating.Core.Enums;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -14,31 +13,23 @@ namespace PowderCoating.Web.Controllers;
|
||||
/// <c>CanManageJobs</c> policy (Managers and above within a company).
|
||||
/// The platform-wide equivalent for SuperAdmins lives in
|
||||
/// <see cref="PlatformNotificationsController"/>.
|
||||
/// Uses <see cref="ApplicationDbContext"/> directly to enable LINQ projections
|
||||
/// that avoid loading full message bodies into memory on the list page.
|
||||
/// </summary>
|
||||
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
|
||||
public class NotificationLogsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ILogger<NotificationLogsController> _logger;
|
||||
|
||||
public NotificationLogsController(ApplicationDbContext context, ILogger<NotificationLogsController> logger)
|
||||
public NotificationLogsController(IUnitOfWork unitOfWork, ILogger<NotificationLogsController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_unitOfWork = unitOfWork;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays a paginated, filterable list of notification log entries for the
|
||||
/// current company. Supports filtering by free-text search, channel (Email/SMS),
|
||||
/// delivery status, notification type, and an optional job context.
|
||||
/// <para>
|
||||
/// The <c>pageSize</c> is validated against an allowlist (10/25/50/100) and
|
||||
/// defaults to 25 to prevent callers from requesting arbitrarily large result sets.
|
||||
/// Sorting defaults to most-recent-first (<c>SentAt DESC</c>) because operators
|
||||
/// almost always want to see the latest delivery attempts first.
|
||||
/// </para>
|
||||
/// Displays a paginated, filterable list of notification log entries for the current company.
|
||||
/// Supports filtering by free-text search, channel, delivery status, notification type, and
|
||||
/// an optional job context. Page size is validated against an allowlist (10/25/50/100).
|
||||
/// </summary>
|
||||
// GET: /NotificationLogs
|
||||
public async Task<IActionResult> Index(
|
||||
@@ -55,74 +46,35 @@ public class NotificationLogsController : Controller
|
||||
pageNumber = Math.Max(1, pageNumber);
|
||||
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
|
||||
|
||||
var query = _context.NotificationLogs
|
||||
.AsNoTracking()
|
||||
.Include(n => n.Customer)
|
||||
.Include(n => n.Job)
|
||||
.Include(n => n.Quote)
|
||||
.AsQueryable();
|
||||
NotificationChannel? channel = Enum.TryParse<NotificationChannel>(channelFilter, out var ch) ? ch : null;
|
||||
NotificationStatus? status = Enum.TryParse<NotificationStatus>(statusFilter, out var st) ? st : null;
|
||||
NotificationType? type = Enum.TryParse<NotificationType>(typeFilter, out var ty) ? ty : null;
|
||||
|
||||
// Filters
|
||||
if (jobId.HasValue)
|
||||
query = query.Where(n => n.JobId == jobId.Value);
|
||||
var (logs, totalCount) = await _unitOfWork.NotificationLogs.GetPagedFilteredAsync(
|
||||
pageNumber, pageSize, searchTerm, channel, status, type, jobId,
|
||||
sortColumn ?? "SentAt", sortDirection);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
var items = logs.Select(n => new NotificationLogDto
|
||||
{
|
||||
var search = searchTerm.ToLower();
|
||||
query = query.Where(n =>
|
||||
n.RecipientName.ToLower().Contains(search) ||
|
||||
n.Recipient.ToLower().Contains(search) ||
|
||||
(n.Subject != null && n.Subject.ToLower().Contains(search)) ||
|
||||
(n.Job != null && n.Job.JobNumber.ToLower().Contains(search)) ||
|
||||
(n.Quote != null && n.Quote.QuoteNumber.ToLower().Contains(search)));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(channelFilter) && Enum.TryParse<NotificationChannel>(channelFilter, out var channel))
|
||||
query = query.Where(n => n.Channel == channel);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(statusFilter) && Enum.TryParse<NotificationStatus>(statusFilter, out var status))
|
||||
query = query.Where(n => n.Status == status);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<NotificationType>(typeFilter, out var type))
|
||||
query = query.Where(n => n.NotificationType == type);
|
||||
|
||||
// Sorting
|
||||
query = (sortColumn ?? "SentAt") switch
|
||||
{
|
||||
"RecipientName" => sortDirection == "asc" ? query.OrderBy(n => n.RecipientName) : query.OrderByDescending(n => n.RecipientName),
|
||||
"Channel" => sortDirection == "asc" ? query.OrderBy(n => n.Channel) : query.OrderByDescending(n => n.Channel),
|
||||
"Status" => sortDirection == "asc" ? query.OrderBy(n => n.Status) : query.OrderByDescending(n => n.Status),
|
||||
"Type" => sortDirection == "asc" ? query.OrderBy(n => n.NotificationType) : query.OrderByDescending(n => n.NotificationType),
|
||||
_ => sortDirection == "asc" ? query.OrderBy(n => n.SentAt) : query.OrderByDescending(n => n.SentAt)
|
||||
};
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
var items = await query
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.Select(n => new NotificationLogDto
|
||||
{
|
||||
Id = n.Id,
|
||||
Channel = n.Channel,
|
||||
NotificationType = n.NotificationType,
|
||||
Status = n.Status,
|
||||
RecipientName = n.RecipientName,
|
||||
Recipient = n.Recipient,
|
||||
Subject = n.Subject,
|
||||
Message = n.Message,
|
||||
ErrorMessage = n.ErrorMessage,
|
||||
SentAt = n.SentAt,
|
||||
CustomerId = n.CustomerId,
|
||||
JobId = n.JobId,
|
||||
QuoteId = n.QuoteId,
|
||||
JobNumber = n.Job != null ? n.Job.JobNumber : null,
|
||||
QuoteNumber = n.Quote != null ? n.Quote.QuoteNumber : null,
|
||||
CustomerName = n.Customer != null
|
||||
? (n.Customer.CompanyName ?? $"{n.Customer.ContactFirstName} {n.Customer.ContactLastName}".Trim())
|
||||
: null
|
||||
})
|
||||
.ToListAsync();
|
||||
Id = n.Id,
|
||||
Channel = n.Channel,
|
||||
NotificationType = n.NotificationType,
|
||||
Status = n.Status,
|
||||
RecipientName = n.RecipientName,
|
||||
Recipient = n.Recipient,
|
||||
Subject = n.Subject,
|
||||
Message = n.Message,
|
||||
ErrorMessage = n.ErrorMessage,
|
||||
SentAt = n.SentAt,
|
||||
CustomerId = n.CustomerId,
|
||||
JobId = n.JobId,
|
||||
QuoteId = n.QuoteId,
|
||||
JobNumber = n.Job?.JobNumber,
|
||||
QuoteNumber = n.Quote?.QuoteNumber,
|
||||
CustomerName = n.Customer != null
|
||||
? (n.Customer.CompanyName ?? $"{n.Customer.ContactFirstName} {n.Customer.ContactLastName}".Trim())
|
||||
: null
|
||||
}).ToList();
|
||||
|
||||
var pagedResult = new PagedResult<NotificationLogDto>
|
||||
{
|
||||
@@ -144,22 +96,17 @@ public class NotificationLogsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Displays the full details of a single notification log entry including the
|
||||
/// complete message body and any error message, which are omitted from the list
|
||||
/// view to keep response sizes small. Loads related Customer, Job, and Quote
|
||||
/// navigation properties to resolve display names.
|
||||
/// Displays the full details of a single notification log entry including the complete message
|
||||
/// body and any error message, which are omitted from the list view. Loads related Customer,
|
||||
/// Job, and Quote navigation properties to resolve display names.
|
||||
/// </summary>
|
||||
// GET: /NotificationLogs/Details/5
|
||||
public async Task<IActionResult> Details(int id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var log = await _context.NotificationLogs
|
||||
.AsNoTracking()
|
||||
.Include(n => n.Customer)
|
||||
.Include(n => n.Job)
|
||||
.Include(n => n.Quote)
|
||||
.FirstOrDefaultAsync(n => n.Id == id);
|
||||
var log = await _unitOfWork.NotificationLogs.GetByIdAsync(
|
||||
id, false, n => n.Customer, n => n.Job, n => n.Quote);
|
||||
|
||||
if (log == null) return NotFound();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user