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,8 +1,6 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Web.Extensions;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -10,12 +8,12 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize]
|
||||
public class InAppNotificationsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly ITenantContext _tenant;
|
||||
|
||||
public InAppNotificationsController(ApplicationDbContext db, ITenantContext tenant)
|
||||
public InAppNotificationsController(IUnitOfWork unitOfWork, ITenantContext tenant)
|
||||
{
|
||||
_db = db;
|
||||
_unitOfWork = unitOfWork;
|
||||
_tenant = tenant;
|
||||
}
|
||||
|
||||
@@ -27,23 +25,15 @@ public class InAppNotificationsController : Controller
|
||||
pageNumber = Math.Max(1, pageNumber);
|
||||
pageSize = pageSize is 10 or 25 or 50 ? pageSize : 25;
|
||||
|
||||
IQueryable<PowderCoating.Core.Entities.InAppNotification> query;
|
||||
var all = _tenant.IsPlatformAdmin()
|
||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
||||
n => !n.IsDeleted && n.CompanyId == 0, ignoreQueryFilters: true)).ToList()
|
||||
: (await _unitOfWork.InAppNotifications.GetAllAsync()).ToList();
|
||||
|
||||
if (_tenant.IsPlatformAdmin())
|
||||
{
|
||||
query = _db.InAppNotifications
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => !n.IsDeleted && n.CompanyId == 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
query = _db.InAppNotifications.AsQueryable();
|
||||
}
|
||||
var totalCount = all.Count;
|
||||
|
||||
var totalCount = await query.CountAsync();
|
||||
|
||||
var items = await query
|
||||
.AsNoTracking()
|
||||
var tz = ViewBag.CompanyTimeZone as string;
|
||||
var items = all
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Skip((pageNumber - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
@@ -58,7 +48,7 @@ public class InAppNotificationsController : Controller
|
||||
n.ReadAt,
|
||||
CreatedAt = n.CreatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
ViewBag.TotalCount = totalCount;
|
||||
ViewBag.PageNumber = pageNumber;
|
||||
@@ -68,31 +58,22 @@ public class InAppNotificationsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AJAX endpoint that returns the 20 most recent notifications (read and unread) for the bell dropdown. The response includes a count of unread items so the badge can be updated without a separate round-trip.
|
||||
/// AJAX endpoint that returns the 20 most recent notifications (read and unread) for the bell dropdown.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Recent()
|
||||
{
|
||||
IQueryable<PowderCoating.Core.Entities.InAppNotification> query;
|
||||
|
||||
if (_tenant.IsPlatformAdmin())
|
||||
{
|
||||
query = _db.InAppNotifications
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => !n.IsDeleted && n.CompanyId == 0);
|
||||
}
|
||||
else
|
||||
{
|
||||
query = _db.InAppNotifications.AsQueryable();
|
||||
}
|
||||
var all = _tenant.IsPlatformAdmin()
|
||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
||||
n => !n.IsDeleted && n.CompanyId == 0, ignoreQueryFilters: true)).ToList()
|
||||
: (await _unitOfWork.InAppNotifications.GetAllAsync()).ToList();
|
||||
|
||||
var tz = ViewBag.CompanyTimeZone as string;
|
||||
var items = await query
|
||||
.AsNoTracking()
|
||||
var items = all
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Take(20)
|
||||
.Select(n => new { n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.IsRead, n.CreatedAt })
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
var unreadCount = items.Count(n => !n.IsRead);
|
||||
return Json(new { count = unreadCount, items = items.Select(n => new {
|
||||
@@ -102,34 +83,19 @@ public class InAppNotificationsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// AJAX endpoint that returns only unread notifications (up to 20) plus an unread count for the bell badge. Used by the initial page load poll; after that the bell relies on Recent to show history.
|
||||
/// AJAX endpoint that returns only unread notifications (up to 20) plus an unread count for the bell badge.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Unread()
|
||||
{
|
||||
IQueryable<PowderCoating.Core.Entities.InAppNotification> query;
|
||||
|
||||
if (_tenant.IsPlatformAdmin())
|
||||
{
|
||||
// SuperAdmins see only platform-level notifications (CompanyId = 0)
|
||||
query = _db.InAppNotifications
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Regular users see their company's notifications (global filter handles tenant isolation)
|
||||
query = _db.InAppNotifications.Where(n => !n.IsRead);
|
||||
}
|
||||
var items = _tenant.IsPlatformAdmin()
|
||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
||||
n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true))
|
||||
.OrderByDescending(n => n.CreatedAt).Take(20).ToList()
|
||||
: (await _unitOfWork.InAppNotifications.FindAsync(n => !n.IsRead))
|
||||
.OrderByDescending(n => n.CreatedAt).Take(20).ToList();
|
||||
|
||||
var tz = ViewBag.CompanyTimeZone as string;
|
||||
var items = await query
|
||||
.AsNoTracking()
|
||||
.OrderByDescending(n => n.CreatedAt)
|
||||
.Take(20)
|
||||
.Select(n => new { n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.CreatedAt })
|
||||
.ToListAsync();
|
||||
|
||||
return Json(new { count = items.Count, items = items.Select(n => new {
|
||||
n.Id, n.Title, n.Message, n.Link, n.NotificationType,
|
||||
CreatedAt = n.CreatedAt.Tz(tz).ToString("MMM d, h:mm tt")
|
||||
@@ -137,44 +103,38 @@ public class InAppNotificationsController : Controller
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a single notification as read and records the timestamp. Tenant isolation is enforced manually for SuperAdmins (CompanyId 0 filter) because IgnoreQueryFilters bypasses the global company filter.
|
||||
/// Marks a single notification as read. Tenant isolation is enforced manually for SuperAdmins (CompanyId 0 filter) because IgnoreQueryFilters bypasses the global company filter.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> MarkRead(int id)
|
||||
{
|
||||
var notification = _tenant.IsPlatformAdmin()
|
||||
? await _db.InAppNotifications.IgnoreQueryFilters().FirstOrDefaultAsync(n => n.Id == id && n.CompanyId == 0 && !n.IsDeleted)
|
||||
: await _db.InAppNotifications.FirstOrDefaultAsync(n => n.Id == id);
|
||||
? await _unitOfWork.InAppNotifications.FirstOrDefaultAsync(
|
||||
n => n.Id == id && n.CompanyId == 0 && !n.IsDeleted, ignoreQueryFilters: true)
|
||||
: await _unitOfWork.InAppNotifications.GetByIdAsync(id);
|
||||
|
||||
if (notification == null) return NotFound();
|
||||
|
||||
notification.IsRead = true;
|
||||
notification.ReadAt = DateTime.UtcNow;
|
||||
notification.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
return Json(new { success = true });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks every unread notification as read for the current user's scope in a single SaveChanges call for efficiency. Returns the count of items marked so the UI can update the badge without refetching.
|
||||
/// Marks every unread notification as read for the current user's scope in a single SaveChanges call.
|
||||
/// </summary>
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> MarkAllRead()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
List<PowderCoating.Core.Entities.InAppNotification> unread;
|
||||
if (_tenant.IsPlatformAdmin())
|
||||
{
|
||||
unread = await _db.InAppNotifications.IgnoreQueryFilters()
|
||||
.Where(n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead)
|
||||
.ToListAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
unread = await _db.InAppNotifications.Where(n => !n.IsRead).ToListAsync();
|
||||
}
|
||||
var unread = _tenant.IsPlatformAdmin()
|
||||
? (await _unitOfWork.InAppNotifications.FindAsync(
|
||||
n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true)).ToList()
|
||||
: (await _unitOfWork.InAppNotifications.FindAsync(n => !n.IsRead)).ToList();
|
||||
|
||||
foreach (var n in unread)
|
||||
{
|
||||
@@ -183,7 +143,7 @@ public class InAppNotificationsController : Controller
|
||||
n.UpdatedAt = now;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
return Json(new { success = true, count = unread.Count });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user