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,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Core.Entities;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -18,39 +17,37 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class DashboardTipsController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _db;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
|
||||
public DashboardTipsController(ApplicationDbContext db)
|
||||
public DashboardTipsController(IUnitOfWork unitOfWork)
|
||||
{
|
||||
_db = db;
|
||||
_unitOfWork = unitOfWork;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a paginated, optionally filtered list of all dashboard tips.
|
||||
/// Active tips are sorted first (then by newest Id) to make the currently
|
||||
/// live pool easy to review at a glance. ViewBag includes both the filtered
|
||||
/// count and the global active/total counts for the header summary cards.
|
||||
/// live pool easy to review at a glance.
|
||||
/// </summary>
|
||||
// GET: /DashboardTips
|
||||
public async Task<IActionResult> Index(string? search, bool? activeOnly, int page = 1)
|
||||
{
|
||||
const int pageSize = 25;
|
||||
|
||||
var query = _db.DashboardTips.AsQueryable();
|
||||
var all = (await _unitOfWork.DashboardTips.GetAllAsync()).ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
query = query.Where(t => t.TipText.Contains(search));
|
||||
all = all.Where(t => t.TipText.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (activeOnly == true)
|
||||
query = query.Where(t => t.IsActive);
|
||||
all = all.Where(t => t.IsActive).ToList();
|
||||
|
||||
var total = await query.CountAsync();
|
||||
var tips = await query
|
||||
var total = all.Count;
|
||||
var tips = all
|
||||
.OrderByDescending(t => t.IsActive)
|
||||
.ThenByDescending(t => t.Id)
|
||||
.Skip((page - 1) * pageSize)
|
||||
.Take(pageSize)
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
ViewBag.Search = search;
|
||||
ViewBag.ActiveOnly = activeOnly ?? false;
|
||||
@@ -58,23 +55,19 @@ public class DashboardTipsController : Controller
|
||||
ViewBag.PageSize = pageSize;
|
||||
ViewBag.Total = total;
|
||||
ViewBag.TotalPages = (int)Math.Ceiling(total / (double)pageSize);
|
||||
ViewBag.ActiveCount = await _db.DashboardTips.CountAsync(t => t.IsActive);
|
||||
ViewBag.TotalCount = await _db.DashboardTips.CountAsync();
|
||||
ViewBag.ActiveCount = await _unitOfWork.DashboardTips.CountAsync(t => t.IsActive);
|
||||
ViewBag.TotalCount = await _unitOfWork.DashboardTips.CountAsync();
|
||||
|
||||
return View(tips);
|
||||
}
|
||||
|
||||
/// <summary>Returns the Create form with an empty <see cref="DashboardTip"/> model.</summary>
|
||||
// GET: /DashboardTips/Create
|
||||
public IActionResult Create() => View(new DashboardTip());
|
||||
|
||||
/// <summary>
|
||||
/// Persists a new dashboard tip. Text is trimmed before saving to prevent
|
||||
/// whitespace-only entries from appearing as blank tiles on the dashboard.
|
||||
/// Model validation is done manually (rather than relying solely on
|
||||
/// <c>[Required]</c> attributes) to ensure a meaningful error message is shown.
|
||||
/// </summary>
|
||||
// POST: /DashboardTips/Create
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Create(DashboardTip model)
|
||||
{
|
||||
@@ -84,37 +77,33 @@ public class DashboardTipsController : Controller
|
||||
return View(model);
|
||||
}
|
||||
|
||||
_db.DashboardTips.Add(new DashboardTip
|
||||
await _unitOfWork.DashboardTips.AddAsync(new DashboardTip
|
||||
{
|
||||
TipText = model.TipText.Trim(),
|
||||
IsActive = model.IsActive,
|
||||
CreatedAt = DateTime.UtcNow
|
||||
});
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = "Tip added successfully.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>Returns the Edit form for an existing tip, or 404 if not found.</summary>
|
||||
// GET: /DashboardTips/Edit/5
|
||||
public async Task<IActionResult> Edit(int id)
|
||||
{
|
||||
var tip = await _db.DashboardTips.FindAsync(id);
|
||||
var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
|
||||
if (tip == null) return NotFound();
|
||||
return View(tip);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates text and active flag for an existing tip. Returns the tracked entity
|
||||
/// (not the posted model) to the view on validation failure so the form shows
|
||||
/// the database version rather than potentially mangled posted data.
|
||||
/// Updates text and active flag for an existing tip.
|
||||
/// </summary>
|
||||
// POST: /DashboardTips/Edit/5
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Edit(int id, DashboardTip model)
|
||||
{
|
||||
var tip = await _db.DashboardTips.FindAsync(id);
|
||||
var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
|
||||
if (tip == null) return NotFound();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.TipText))
|
||||
@@ -125,27 +114,25 @@ public class DashboardTipsController : Controller
|
||||
|
||||
tip.TipText = model.TipText.Trim();
|
||||
tip.IsActive = model.IsActive;
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
|
||||
TempData["Success"] = "Tip updated.";
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Permanently (hard) deletes a dashboard tip. Tips are platform metadata so
|
||||
/// Permanently (hard) deletes a dashboard tip. Tips are platform metadata so
|
||||
/// they do not use soft delete — they have no foreign-key relationships to
|
||||
/// tenant data and nothing references a deleted tip's Id. A missing Id is
|
||||
/// silently ignored to keep the action idempotent.
|
||||
/// tenant data and nothing references a deleted tip's Id.
|
||||
/// </summary>
|
||||
// POST: /DashboardTips/Delete/5
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Delete(int id)
|
||||
{
|
||||
var tip = await _db.DashboardTips.FindAsync(id);
|
||||
var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
|
||||
if (tip != null)
|
||||
{
|
||||
_db.DashboardTips.Remove(tip);
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.DashboardTips.DeleteAsync(tip);
|
||||
await _unitOfWork.CompleteAsync();
|
||||
TempData["Success"] = "Tip deleted.";
|
||||
}
|
||||
return RedirectToAction(nameof(Index));
|
||||
@@ -153,19 +140,15 @@ public class DashboardTipsController : Controller
|
||||
|
||||
/// <summary>
|
||||
/// Flips the <c>IsActive</c> flag on a tip without a full edit round-trip.
|
||||
/// This lets operators quickly remove a tip from the rotation (deactivate)
|
||||
/// without deleting it, preserving the ability to reactivate it later.
|
||||
/// A missing Id is silently ignored to keep the action idempotent.
|
||||
/// </summary>
|
||||
// POST: /DashboardTips/ToggleActive/5
|
||||
[HttpPost, ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> ToggleActive(int id)
|
||||
{
|
||||
var tip = await _db.DashboardTips.FindAsync(id);
|
||||
var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
|
||||
if (tip != null)
|
||||
{
|
||||
tip.IsActive = !tip.IsActive;
|
||||
await _db.SaveChangesAsync();
|
||||
await _unitOfWork.CompleteAsync();
|
||||
}
|
||||
return RedirectToAction(nameof(Index));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user