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>
191 lines
9.3 KiB
C#
191 lines
9.3 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using PowderCoating.Core.Entities;
|
|
using PowderCoating.Core.Enums;
|
|
using PowderCoating.Infrastructure.Data;
|
|
using PowderCoating.Shared.Constants;
|
|
|
|
namespace PowderCoating.Web.Controllers;
|
|
|
|
/// <summary>
|
|
/// SuperAdmin-only dashboard that shows per-company resource usage versus
|
|
/// plan limits across users, active jobs, customers, active quotes, and catalog items.
|
|
/// Uses <see cref="ApplicationDbContext"/> directly (bypassing UoW) to issue
|
|
/// efficient bulk-count GROUP BY queries in parallel rather than loading
|
|
/// each company's data through separate repository calls.
|
|
/// </summary>
|
|
// Intentional exception: cross-tenant bulk GROUP BY quota queries that would require O(n) repository round-trips if routed through IUnitOfWork. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
|
|
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
|
public class UsageQuotaController : Controller
|
|
{
|
|
private readonly ApplicationDbContext _db;
|
|
|
|
public UsageQuotaController(ApplicationDbContext db) => _db = db;
|
|
|
|
/// <summary>
|
|
/// Renders the usage quota dashboard with one <see cref="UsageRow"/> per company.
|
|
/// <para>
|
|
/// Design decisions:
|
|
/// <list type="bullet">
|
|
/// <item>Five separate GROUP BY queries are issued in sequence (not parallel) so
|
|
/// the same <see cref="ApplicationDbContext"/> instance is not used concurrently.</item>
|
|
/// <item>Comped companies receive unlimited limits (-1) on every resource because
|
|
/// they are exempt from plan restrictions by design.</item>
|
|
/// <item>Company-level overrides (<c>MaxUsersOverride</c> etc.) take precedence
|
|
/// over plan defaults, enabling per-tenant exceptions without a plan change.</item>
|
|
/// <item>"Near limit" is defined as ≥ 80 % of the limit; "at limit" is ≥ 100 %.</item>
|
|
/// <item>The <c>concern</c> filter is applied in-memory after building rows because
|
|
/// the at/near-limit logic combines data from multiple resource types.</item>
|
|
/// </list>
|
|
/// </para>
|
|
/// </summary>
|
|
public async Task<IActionResult> Index(string? search, string? status, string? plan, string? concern)
|
|
{
|
|
// Plan configs for limit resolution
|
|
var planConfigs = await _db.SubscriptionPlanConfigs
|
|
.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(p => p.IsActive)
|
|
.OrderBy(p => p.SortOrder)
|
|
.ToListAsync();
|
|
|
|
var planById = planConfigs.ToDictionary(p => p.Plan);
|
|
|
|
// All non-deleted companies
|
|
var companiesQuery = _db.Companies.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(c => !c.IsDeleted);
|
|
|
|
if (!string.IsNullOrWhiteSpace(search))
|
|
companiesQuery = companiesQuery.Where(c => c.CompanyName.Contains(search));
|
|
|
|
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<SubscriptionStatus>(status, out var statusEnum))
|
|
companiesQuery = companiesQuery.Where(c => c.SubscriptionStatus == statusEnum);
|
|
|
|
if (!string.IsNullOrWhiteSpace(plan) && int.TryParse(plan, out var planInt))
|
|
companiesQuery = companiesQuery.Where(c => c.SubscriptionPlan == planInt);
|
|
|
|
var companies = await companiesQuery.OrderBy(c => c.CompanyName).ToListAsync();
|
|
var companyIds = companies.Select(c => c.Id).ToList();
|
|
|
|
// Bulk-fetch usage counts in parallel — one query per resource type
|
|
var userCounts = await _db.Users.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(u => companyIds.Contains(u.CompanyId))
|
|
.GroupBy(u => u.CompanyId)
|
|
.Select(g => new { CompanyId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.CompanyId, x => x.Count);
|
|
|
|
// Active jobs = non-deleted jobs whose status is not a terminal status
|
|
var activeJobCounts = await _db.Jobs.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(j => companyIds.Contains(j.CompanyId) && !j.IsDeleted &&
|
|
!j.JobStatus.IsTerminalStatus)
|
|
.GroupBy(j => j.CompanyId)
|
|
.Select(g => new { CompanyId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.CompanyId, x => x.Count);
|
|
|
|
var customerCounts = await _db.Customers.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(c => companyIds.Contains(c.CompanyId) && !c.IsDeleted)
|
|
.GroupBy(c => c.CompanyId)
|
|
.Select(g => new { CompanyId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.CompanyId, x => x.Count);
|
|
|
|
// Active quotes = non-deleted quotes not in a terminal status (converted, rejected)
|
|
var quoteCounts = await _db.Quotes.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(q => companyIds.Contains(q.CompanyId) && !q.IsDeleted &&
|
|
!q.QuoteStatus.IsConvertedStatus && !q.QuoteStatus.IsRejectedStatus)
|
|
.GroupBy(q => q.CompanyId)
|
|
.Select(g => new { CompanyId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.CompanyId, x => x.Count);
|
|
|
|
var catalogCounts = await _db.CatalogItems.AsNoTracking().IgnoreQueryFilters()
|
|
.Where(ci => companyIds.Contains(ci.CompanyId) && !ci.IsDeleted)
|
|
.GroupBy(ci => ci.CompanyId)
|
|
.Select(g => new { CompanyId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.CompanyId, x => x.Count);
|
|
|
|
// Build rows
|
|
int Effective(int? companyOverride, int planDefault) =>
|
|
companyOverride.HasValue ? companyOverride.Value : planDefault;
|
|
|
|
var rows = companies.Select(c =>
|
|
{
|
|
var cfg = planById.TryGetValue(c.SubscriptionPlan, out var pc) ? pc : null;
|
|
|
|
int users = userCounts.GetValueOrDefault(c.Id);
|
|
int jobs = activeJobCounts.GetValueOrDefault(c.Id);
|
|
int customers = customerCounts.GetValueOrDefault(c.Id);
|
|
int quotes = quoteCounts.GetValueOrDefault(c.Id);
|
|
int catalog = catalogCounts.GetValueOrDefault(c.Id);
|
|
|
|
int maxUsers = c.IsComped ? -1 : Effective(c.MaxUsersOverride, cfg?.MaxUsers ?? -1);
|
|
int maxJobs = c.IsComped ? -1 : Effective(c.MaxActiveJobsOverride, cfg?.MaxActiveJobs ?? -1);
|
|
int maxCust = c.IsComped ? -1 : Effective(c.MaxCustomersOverride, cfg?.MaxCustomers ?? -1);
|
|
int maxQuotes = c.IsComped ? -1 : Effective(c.MaxQuotesOverride, cfg?.MaxQuotes ?? -1);
|
|
int maxCatalog = c.IsComped ? -1 : Effective(c.MaxCatalogItemsOverride, cfg?.MaxCatalogItems ?? -1);
|
|
|
|
bool IsNearLimit(int used, int max) => max > 0 && used >= max * 0.8;
|
|
bool IsAtLimit(int used, int max) => max > 0 && used >= max;
|
|
|
|
bool anyAtLimit = IsAtLimit(users, maxUsers) || IsAtLimit(jobs, maxJobs) ||
|
|
IsAtLimit(customers, maxCust) || IsAtLimit(quotes, maxQuotes) ||
|
|
IsAtLimit(catalog, maxCatalog);
|
|
bool anyNearLimit = IsNearLimit(users, maxUsers) || IsNearLimit(jobs, maxJobs) ||
|
|
IsNearLimit(customers, maxCust) || IsNearLimit(quotes, maxQuotes) ||
|
|
IsNearLimit(catalog, maxCatalog);
|
|
|
|
return new UsageRow
|
|
{
|
|
CompanyId = c.Id,
|
|
CompanyName = c.CompanyName,
|
|
PlanName = cfg?.DisplayName ?? c.SubscriptionPlan.ToString(),
|
|
Status = c.SubscriptionStatus,
|
|
IsActive = c.IsActive,
|
|
IsComped = c.IsComped,
|
|
Users = users, MaxUsers = maxUsers,
|
|
ActiveJobs = jobs, MaxActiveJobs = maxJobs,
|
|
Customers = customers, MaxCustomers = maxCust,
|
|
ActiveQuotes = quotes, MaxActiveQuotes = maxQuotes,
|
|
CatalogItems = catalog, MaxCatalogItems = maxCatalog,
|
|
IsAtLimit = anyAtLimit,
|
|
IsNearLimit = anyNearLimit && !anyAtLimit
|
|
};
|
|
}).ToList();
|
|
|
|
// "concern" filter — only show rows with issues
|
|
if (concern == "limit") rows = rows.Where(r => r.IsAtLimit || r.IsNearLimit).ToList();
|
|
else if (concern == "atlimit") rows = rows.Where(r => r.IsAtLimit).ToList();
|
|
|
|
ViewBag.Search = search;
|
|
ViewBag.StatusFilter = status;
|
|
ViewBag.PlanFilter = plan;
|
|
ViewBag.ConcernFilter = concern;
|
|
ViewBag.PlanConfigs = planConfigs;
|
|
ViewBag.AtLimitCount = rows.Count(r => r.IsAtLimit);
|
|
ViewBag.NearLimitCount = rows.Count(r => r.IsNearLimit);
|
|
ViewBag.TotalCount = rows.Count;
|
|
|
|
return View(rows);
|
|
}
|
|
}
|
|
|
|
public class UsageRow
|
|
{
|
|
public int CompanyId { get; set; }
|
|
public string CompanyName { get; set; } = string.Empty;
|
|
public string PlanName { get; set; } = string.Empty;
|
|
public SubscriptionStatus Status { get; set; }
|
|
public bool IsActive { get; set; }
|
|
public bool IsComped { get; set; }
|
|
public int Users { get; set; }
|
|
public int MaxUsers { get; set; }
|
|
public int ActiveJobs { get; set; }
|
|
public int MaxActiveJobs { get; set; }
|
|
public int Customers { get; set; }
|
|
public int MaxCustomers { get; set; }
|
|
public int ActiveQuotes { get; set; }
|
|
public int MaxActiveQuotes { get; set; }
|
|
public int CatalogItems { get; set; }
|
|
public int MaxCatalogItems { get; set; }
|
|
public bool IsAtLimit { get; set; }
|
|
public bool IsNearLimit { get; set; }
|
|
}
|