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,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;
@@ -19,17 +18,16 @@ namespace PowderCoating.Web.Controllers;
/// </summary>
public class ReleaseNotesController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IUnitOfWork _unitOfWork;
private readonly IInAppNotificationService _inApp;
public ReleaseNotesController(ApplicationDbContext db, IInAppNotificationService inApp)
public ReleaseNotesController(IUnitOfWork unitOfWork, IInAppNotificationService inApp)
{
_db = db;
_unitOfWork = unitOfWork;
_inApp = inApp;
}
// ── Public: Changelog ────────────────────────────────────────────────────
// Visible to all authenticated users
/// <summary>
/// Renders the public changelog — shows only published release notes ordered
@@ -39,54 +37,39 @@ public class ReleaseNotesController : Controller
[Authorize]
public async Task<IActionResult> Index()
{
var notes = await _db.ReleaseNotes
.AsNoTracking()
.Where(r => r.IsPublished)
var notes = (await _unitOfWork.ReleaseNotes.FindAsync(r => r.IsPublished))
.OrderByDescending(r => r.ReleasedAt)
.ThenByDescending(r => r.Id)
.ToListAsync();
.ToList();
return View(notes);
}
// ── SuperAdmin: Manage ───────────────────────────────────────────────────
/// <summary>
/// Returns the SuperAdmin management list of all release notes (published and
/// draft alike), ordered newest-first. Unlike <see cref="Index"/> there is no
/// <c>IsPublished</c> filter here so admins can see and edit drafts.
/// Returns the SuperAdmin management list of all release notes (published and draft alike), ordered newest-first.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> Manage()
{
var notes = await _db.ReleaseNotes
.AsNoTracking()
var notes = (await _unitOfWork.ReleaseNotes.GetAllAsync())
.OrderByDescending(r => r.ReleasedAt)
.ThenByDescending(r => r.Id)
.ToListAsync();
.ToList();
return View(notes);
}
/// <summary>
/// Returns the Create form pre-populated with today's UTC date and the "Feature"
/// tag as sensible defaults for new entries, reducing data-entry friction.
/// Returns the Create form pre-populated with today's UTC date and the "Feature" tag as sensible defaults.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public IActionResult Create()
{
return View(new ReleaseNote
{
ReleasedAt = DateTime.UtcNow,
Tag = "Feature"
});
return View(new ReleaseNote { ReleasedAt = DateTime.UtcNow, Tag = "Feature" });
}
/// <summary>
/// Persists a new release note and captures the creating SuperAdmin's identity
/// (<c>CreatedByUserId</c> / <c>CreatedByUserName</c>) for audit purposes.
/// New notes start unpublished by default unless the form explicitly sets
/// <c>IsPublished = true</c>, giving authors a chance to review before going live.
/// Persists a new release note. New notes start unpublished unless the form explicitly sets IsPublished = true.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
@@ -99,8 +82,8 @@ public class ReleaseNotesController : Controller
model.CreatedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
model.CreatedByUserName = User.Identity?.Name;
_db.ReleaseNotes.Add(model);
await _db.SaveChangesAsync();
await _unitOfWork.ReleaseNotes.AddAsync(model);
await _unitOfWork.CompleteAsync();
if (model.IsPublished)
await NotifyAllTenantsAsync(model);
@@ -109,22 +92,18 @@ public class ReleaseNotesController : Controller
return RedirectToAction(nameof(Manage));
}
/// <summary>
/// Returns the Edit form loaded from the database by primary key.
/// </summary>
/// <summary>Returns the Edit form loaded from the database by primary key.</summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> Edit(int id)
{
var note = await _db.ReleaseNotes.FindAsync(id);
var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id);
if (note == null) return NotFound();
return View(note);
}
/// <summary>
/// Applies the edited values to the tracked entity. Uses explicit field
/// mapping (rather than <c>_db.Entry(model).State = Modified</c>) to prevent
/// over-posting attacks and to ensure audit fields like <c>CreatedAt</c> and
/// <c>CreatedByUserId</c> are never overwritten.
/// Applies the edited values to the tracked entity using explicit field mapping to prevent
/// over-posting attacks and ensure audit fields are never overwritten.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
@@ -134,40 +113,37 @@ public class ReleaseNotesController : Controller
if (!ModelState.IsValid)
return View(model);
var note = await _db.ReleaseNotes.FindAsync(id);
var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id);
if (note == null) return NotFound();
note.Version = model.Version;
note.Title = model.Title;
note.Body = model.Body;
note.Tag = model.Tag;
note.IsPublished= model.IsPublished;
note.ReleasedAt = model.ReleasedAt;
note.UpdatedAt = DateTime.UtcNow;
note.Version = model.Version;
note.Title = model.Title;
note.Body = model.Body;
note.Tag = model.Tag;
note.IsPublished = model.IsPublished;
note.ReleasedAt = model.ReleasedAt;
note.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Release note v{note.Version} updated.";
return RedirectToAction(nameof(Manage));
}
/// <summary>
/// Toggles the published state of a release note. Publishing makes the note
/// immediately visible to all authenticated users via <see cref="Index"/>;
/// un-publishing hides it without permanently deleting it so it can be revised
/// and re-published later.
/// Toggles the published state of a release note. Publishing fires an in-app notification to all tenants.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> TogglePublish(int id)
{
var note = await _db.ReleaseNotes.FindAsync(id);
var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id);
if (note == null) return NotFound();
var wasPublished = note.IsPublished;
note.IsPublished = !note.IsPublished;
note.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
if (note.IsPublished && !wasPublished)
await NotifyAllTenantsAsync(note);
@@ -179,29 +155,24 @@ public class ReleaseNotesController : Controller
}
/// <summary>
/// Permanently (hard) deletes a release note. This is intentional — release notes
/// are platform metadata, not business data, so they do not use soft delete.
/// Use <see cref="TogglePublish"/> to hide a note without permanent removal.
/// Permanently (hard) deletes a release note. Release notes are platform metadata and do not use soft delete.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> Delete(int id)
{
var note = await _db.ReleaseNotes.FindAsync(id);
var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id);
if (note == null) return NotFound();
_db.ReleaseNotes.Remove(note);
await _db.SaveChangesAsync();
await _unitOfWork.ReleaseNotes.DeleteAsync(note);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Release note v{note.Version} deleted.";
return RedirectToAction(nameof(Manage));
}
/// <summary>
/// Fans out a "What's New" in-app notification to every active tenant company when
/// a release note transitions to published. Notification fires exactly once per
/// publish event — re-publishing after unpublishing will send a second notification,
/// which is intentional (the content may have changed).
/// Fans out a "What's New" in-app notification to every active tenant company when a release note is published.
/// </summary>
private Task NotifyAllTenantsAsync(ReleaseNote note)
{