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,9 +1,8 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -11,12 +10,12 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class AnnouncementsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IInAppNotificationService _inApp;
|
||||
|
||||
public AnnouncementsController(ApplicationDbContext db, IInAppNotificationService inApp)
|
||||
public AnnouncementsController(IUnitOfWork unitOfWork, IInAppNotificationService inApp)
|
||||
{
|
||||
_db = db;
|
||||
_unitOfWork = unitOfWork;
|
||||
_inApp = inApp;
|
||||
}
|
||||
|
||||
@@ -25,18 +24,18 @@ public class AnnouncementsController : Controller
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Index()
|
||||
{
|
||||
var announcements = await _db.Announcements
|
||||
var announcements = (await _unitOfWork.Announcements.GetAllAsync())
|
||||
.OrderByDescending(a => a.CreatedAt)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
return View(announcements);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows the announcement creation form with sensible defaults: starts now, dismissible, and active.
|
||||
/// </summary>
|
||||
public IActionResult Create()
|
||||
public async Task<IActionResult> Create()
|
||||
{
|
||||
PopulateDropdowns();
|
||||
await PopulateDropdownsAsync();
|
||||
return View(new Announcement { StartsAt = DateTime.Now, IsDismissible = true, IsActive = true });
|
||||
}
|
||||
|
||||
@@ -46,7 +45,7 @@ public class AnnouncementsController : Controller
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(Announcement model)
|
||||
{
|
||||
if (!ModelState.IsValid) { PopulateDropdowns(); return View(model); }
|
||||
if (!ModelState.IsValid) { await PopulateDropdownsAsync(); return View(model); }
|
||||
|
||||
model.CreatedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
|
||||
model.CreatedByUserName = User.Identity?.Name ?? "SuperAdmin";
|
||||
@@ -54,10 +53,9 @@ public class AnnouncementsController : Controller
|
||||
model.StartsAt = model.StartsAt.ToUniversalTime();
|
||||
if (model.ExpiresAt.HasValue) model.ExpiresAt = model.ExpiresAt.Value.ToUniversalTime();
|
||||
|
||||
_db.Announcements.Add(model);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.Announcements.AddAsync(model);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
// Dispatch as in-app notifications to targeted companies
|
||||
await DispatchNotificationsAsync(model);
|
||||
|
||||
TempData["Success"] = "Announcement created and sent as notifications.";
|
||||
@@ -69,9 +67,9 @@ public class AnnouncementsController : Controller
|
||||
/// </summary>
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var announcement = await _db.Announcements.FindAsync(id);
|
||||
var announcement = await _unitOfWork.Announcements.GetByIdAsync(id);
|
||||
if (announcement == null) return NotFound();
|
||||
PopulateDropdowns();
|
||||
await PopulateDropdownsAsync();
|
||||
return View(announcement);
|
||||
}
|
||||
|
||||
@@ -81,9 +79,9 @@ public class AnnouncementsController : Controller
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, Announcement model)
|
||||
{
|
||||
if (!ModelState.IsValid) { PopulateDropdowns(); return View(model); }
|
||||
if (!ModelState.IsValid) { await PopulateDropdownsAsync(); return View(model); }
|
||||
|
||||
var existing = await _db.Announcements.FindAsync(id);
|
||||
var existing = await _unitOfWork.Announcements.GetByIdAsync(id);
|
||||
if (existing == null) return NotFound();
|
||||
|
||||
existing.Title = model.Title;
|
||||
@@ -98,7 +96,7 @@ public class AnnouncementsController : Controller
|
||||
existing.IsActive = model.IsActive;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = "Announcement updated.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
@@ -109,49 +107,42 @@ public class AnnouncementsController : Controller
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var announcement = await _db.Announcements.FindAsync(id);
|
||||
var announcement = await _unitOfWork.Announcements.GetByIdAsync(id);
|
||||
if (announcement == null) return NotFound();
|
||||
_db.Announcements.Remove(announcement);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.Announcements.DeleteAsync(announcement);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = "Announcement deleted.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fans out the announcement as in-app notifications to each matching company. IgnoreQueryFilters is required to reach all active companies regardless of tenant context (this runs as SuperAdmin). Filtering by Target/Plan/Company happens before the foreach so only relevant tenants receive the notification.
|
||||
/// Fans out the announcement as in-app notifications to each matching company. IgnoreQueryFilters is required to reach all active companies regardless of tenant context. Filtering by Target/Plan/Company happens after the fetch so only relevant tenants receive the notification.
|
||||
/// </summary>
|
||||
private async Task DispatchNotificationsAsync(Announcement model)
|
||||
{
|
||||
IQueryable<PowderCoating.Core.Entities.Company> companyQuery = _db.Companies
|
||||
.IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted && c.IsActive);
|
||||
var companies = (await _unitOfWork.Companies.FindAsync(
|
||||
c => !c.IsDeleted && c.IsActive, ignoreQueryFilters: true)).ToList();
|
||||
|
||||
if (model.Target == "Plan" && model.TargetPlan.HasValue)
|
||||
companyQuery = companyQuery.Where(c => c.SubscriptionPlan == model.TargetPlan.Value);
|
||||
companies = companies.Where(c => c.SubscriptionPlan == model.TargetPlan.Value).ToList();
|
||||
else if (model.Target == "Company" && model.TargetCompanyId.HasValue)
|
||||
companyQuery = companyQuery.Where(c => c.Id == model.TargetCompanyId.Value);
|
||||
companies = companies.Where(c => c.Id == model.TargetCompanyId.Value).ToList();
|
||||
|
||||
var companyIds = await companyQuery.Select(c => c.Id).ToListAsync();
|
||||
|
||||
foreach (var companyId in companyIds)
|
||||
{
|
||||
await _inApp.CreateAsync(
|
||||
companyId,
|
||||
model.Title,
|
||||
model.Message,
|
||||
"Announcement");
|
||||
}
|
||||
foreach (var company in companies)
|
||||
await _inApp.CreateAsync(company.Id, model.Title, model.Message, "Announcement");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads company and plan lists into ViewBag for the Create/Edit form dropdowns. Uses AsNoTracking and IgnoreQueryFilters to bypass soft-delete and tenant filters so all companies are available for targeting.
|
||||
/// Loads company and plan lists into ViewBag for the Create/Edit form dropdowns. Uses IgnoreQueryFilters to bypass soft-delete and tenant filters so all companies are available for targeting.
|
||||
/// </summary>
|
||||
private void PopulateDropdowns()
|
||||
private async Task PopulateDropdownsAsync()
|
||||
{
|
||||
ViewBag.Companies = _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(c => !c.IsDeleted).OrderBy(c => c.CompanyName)
|
||||
.Select(c => new { c.Id, c.CompanyName }).ToList();
|
||||
ViewBag.PlanConfigs = _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
|
||||
.Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToList();
|
||||
ViewBag.Companies = (await _unitOfWork.Companies.FindAsync(c => !c.IsDeleted, ignoreQueryFilters: true))
|
||||
.OrderBy(c => c.CompanyName)
|
||||
.Select(c => new { c.Id, c.CompanyName })
|
||||
.ToList();
|
||||
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(p => p.IsActive, ignoreQueryFilters: true))
|
||||
.OrderBy(p => p.SortOrder)
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user