Initial commit

This commit is contained in:
2026-04-23 21:38:24 -04:00
commit 63e12a9636
1762 changed files with 1672620 additions and 0 deletions
@@ -0,0 +1,189 @@
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>
[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; }
}