1cb7a8ca4a
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>
158 lines
6.5 KiB
C#
158 lines
6.5 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using PowderCoating.Core.Enums;
|
|
using PowderCoating.Core.Interfaces;
|
|
using PowderCoating.Shared.Constants;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
/// <summary>
|
|
/// SuperAdmin-only cross-company notification log viewer.
|
|
/// The company-scoped version lives in NotificationLogsController (CanManageJobs policy).
|
|
/// Uses IgnoreQueryFilters throughout to bypass the multi-tenancy global filter and see
|
|
/// logs from all companies simultaneously.
|
|
/// </summary>
|
|
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
|
public class PlatformNotificationsController : Controller
|
|
{
|
|
private readonly IUnitOfWork _unitOfWork;
|
|
|
|
public PlatformNotificationsController(IUnitOfWork unitOfWork) => _unitOfWork = unitOfWork;
|
|
|
|
/// <summary>
|
|
/// Renders a paginated, filterable cross-company notification log view visible only to SuperAdmins.
|
|
/// All filter/sort/paging is applied in-memory after a single filtered repository fetch.
|
|
/// Company names are resolved in a follow-up batch query keyed on the page's distinct company IDs.
|
|
/// Summary counts reflect the full unfiltered dataset so the health cards are accurate regardless of active filters.
|
|
/// </summary>
|
|
public async Task<IActionResult> Index(
|
|
int? companyId,
|
|
string? type,
|
|
string? status,
|
|
string? channel,
|
|
string? search,
|
|
DateTime? from,
|
|
DateTime? to,
|
|
int page = 1,
|
|
int pageSize = 50)
|
|
{
|
|
pageSize = pageSize is 25 or 50 or 100 ? pageSize : 50;
|
|
page = Math.Max(1, page);
|
|
|
|
// Load all non-deleted logs across all tenants, then filter in-memory.
|
|
var all = (await _unitOfWork.NotificationLogs.FindAsync(n => !n.IsDeleted, ignoreQueryFilters: true))
|
|
.AsEnumerable();
|
|
|
|
if (companyId.HasValue)
|
|
all = all.Where(n => n.CompanyId == companyId);
|
|
|
|
if (!string.IsNullOrWhiteSpace(type) && Enum.TryParse<NotificationType>(type, out var typeEnum))
|
|
all = all.Where(n => n.NotificationType == typeEnum);
|
|
|
|
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<NotificationStatus>(status, out var statusEnum))
|
|
all = all.Where(n => n.Status == statusEnum);
|
|
|
|
if (!string.IsNullOrWhiteSpace(channel) && Enum.TryParse<NotificationChannel>(channel, out var channelEnum))
|
|
all = all.Where(n => n.Channel == channelEnum);
|
|
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
all = all.Where(n =>
|
|
n.RecipientName.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
|
n.Recipient.Contains(search, StringComparison.OrdinalIgnoreCase) ||
|
|
(n.Subject != null && n.Subject.Contains(search, StringComparison.OrdinalIgnoreCase)));
|
|
|
|
if (from.HasValue)
|
|
all = all.Where(n => n.SentAt >= from.Value.Date);
|
|
|
|
if (to.HasValue)
|
|
all = all.Where(n => n.SentAt < to.Value.Date.AddDays(1));
|
|
|
|
var filtered = all.OrderByDescending(n => n.SentAt).ToList();
|
|
var totalCount = filtered.Count;
|
|
|
|
var items = filtered
|
|
.Skip((page - 1) * pageSize)
|
|
.Take(pageSize)
|
|
.Select(n => new PlatformNotificationRow
|
|
{
|
|
Id = n.Id,
|
|
CompanyId = (int)n.CompanyId,
|
|
Channel = n.Channel,
|
|
NotificationType = n.NotificationType,
|
|
Status = n.Status,
|
|
RecipientName = n.RecipientName,
|
|
Recipient = n.Recipient,
|
|
Subject = n.Subject,
|
|
ErrorMessage = n.ErrorMessage,
|
|
SentAt = n.SentAt
|
|
})
|
|
.ToList();
|
|
|
|
// Resolve company names for the page's rows in one query
|
|
var cids = items.Select(i => i.CompanyId).Distinct().ToList();
|
|
var companyNames = (await _unitOfWork.Companies.FindAsync(c => cids.Contains(c.Id), ignoreQueryFilters: true))
|
|
.ToDictionary(c => c.Id, c => c.CompanyName);
|
|
foreach (var item in items)
|
|
item.CompanyName = companyNames.GetValueOrDefault(item.CompanyId);
|
|
|
|
// Sidebar company list for filter dropdown
|
|
var companies = (await _unitOfWork.Companies.FindAsync(c => !c.IsDeleted, ignoreQueryFilters: true))
|
|
.OrderBy(c => c.CompanyName)
|
|
.Select(c => new { c.Id, c.CompanyName })
|
|
.ToList();
|
|
|
|
// Summary counts from the unfiltered dataset
|
|
var allLogs = await _unitOfWork.NotificationLogs.FindAsync(n => !n.IsDeleted, ignoreQueryFilters: true);
|
|
ViewBag.TotalCount = allLogs.Count();
|
|
ViewBag.FailedCount = allLogs.Count(n => n.Status == NotificationStatus.Failed);
|
|
ViewBag.Last24hCount = allLogs.Count(n => n.SentAt >= DateTime.UtcNow.AddHours(-24));
|
|
|
|
ViewBag.Companies = companies;
|
|
ViewBag.CompanyIdFilter = companyId;
|
|
ViewBag.TypeFilter = type;
|
|
ViewBag.StatusFilter = status;
|
|
ViewBag.ChannelFilter = channel;
|
|
ViewBag.Search = search;
|
|
ViewBag.From = from?.ToString("yyyy-MM-dd");
|
|
ViewBag.To = to?.ToString("yyyy-MM-dd");
|
|
ViewBag.Page = page;
|
|
ViewBag.PageSize = pageSize;
|
|
ViewBag.FilteredCount = totalCount;
|
|
ViewBag.TotalPages = (int)Math.Ceiling(totalCount / (double)pageSize);
|
|
|
|
return View(items);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns the full notification log entry for the given id, including the complete message body and error details.
|
|
/// </summary>
|
|
public async Task<IActionResult> Details(int id)
|
|
{
|
|
var log = await _unitOfWork.NotificationLogs.FirstOrDefaultAsync(n => n.Id == id, ignoreQueryFilters: true);
|
|
if (log == null) return NotFound();
|
|
|
|
string? companyName = null;
|
|
if (log.CompanyId > 0)
|
|
companyName = (await _unitOfWork.Companies.FirstOrDefaultAsync(
|
|
c => c.Id == log.CompanyId, ignoreQueryFilters: true))?.CompanyName;
|
|
|
|
ViewBag.CompanyName = companyName;
|
|
return View(log);
|
|
}
|
|
}
|
|
|
|
public class PlatformNotificationRow
|
|
{
|
|
public int Id { get; set; }
|
|
public int CompanyId { get; set; }
|
|
public string? CompanyName { get; set; }
|
|
public NotificationChannel Channel { get; set; }
|
|
public NotificationType NotificationType { get; set; }
|
|
public NotificationStatus Status { get; set; }
|
|
public string RecipientName { get; set; } = string.Empty;
|
|
public string Recipient { get; set; } = string.Empty;
|
|
public string? Subject { get; set; }
|
|
public string? ErrorMessage { get; set; }
|
|
public DateTime SentAt { get; set; }
|
|
}
|