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; /// /// SuperAdmin-only dashboard that shows per-company resource usage versus /// plan limits across users, active jobs, customers, active quotes, and catalog items. /// Uses directly (bypassing UoW) to issue /// efficient bulk-count GROUP BY queries in parallel rather than loading /// each company's data through separate repository calls. /// // 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; /// /// Renders the usage quota dashboard with one per company. /// /// Design decisions: /// /// Five separate GROUP BY queries are issued in sequence (not parallel) so /// the same instance is not used concurrently. /// Comped companies receive unlimited limits (-1) on every resource because /// they are exempt from plan restrictions by design. /// Company-level overrides (MaxUsersOverride etc.) take precedence /// over plan defaults, enabling per-tenant exceptions without a plan change. /// "Near limit" is defined as ≥ 80 % of the limit; "at limit" is ≥ 100 %. /// The concern filter is applied in-memory after building rows because /// the at/near-limit logic combines data from multiple resource types. /// /// /// public async Task 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(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; } }