using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using PowderCoating.Application.DTOs.Health; using PowderCoating.Application.Interfaces; using PowderCoating.Core.Enums; using PowderCoating.Infrastructure.Data; using PowderCoating.Shared.Constants; namespace PowderCoating.Web.Controllers; [Authorize(Policy = AppConstants.Policies.SuperAdminOnly)] public class CompanyHealthController : Controller { private readonly ApplicationDbContext _db; private readonly ICompanyConfigHealthService _configHealth; public CompanyHealthController(ApplicationDbContext db, ICompanyConfigHealthService configHealth) { _db = db; _configHealth = configHealth; } /// /// Renders the Company Health dashboard showing a churn-risk score and engagement /// signals for every tenant company (SuperAdmin only). /// /// Data is collected via a series of focused, aggregated DB queries (one per signal /// type) rather than a single join query, because the join approach would require /// complex LEFT JOINs across five tables and produce a large intermediate result /// set. Each query groups by CompanyId and materialises to a dictionary for /// O(1) lookup when building the per-company . /// /// /// All queries use IgnoreQueryFilters() so deleted companies and their /// data are visible to SuperAdmins. The !c.IsDeleted predicate on the /// companies query is then applied manually to exclude companies that have been /// permanently removed from the platform. /// /// /// Health scoring and signal classification are delegated to /// and the result is used to assign a /// bucket. Summary counts (for the dashboard header KPI /// cards) are computed from the full un-filtered list before applying the /// user's risk/search filters, so the KPI cards always show platform-wide totals. /// /// public async Task Index(string? risk, string? search, bool configIssuesOnly = false, bool showChurned = false) { var now = DateTime.UtcNow; var d30 = now.AddDays(-30); var d90 = now.AddDays(-90); var churnedCutoff = now.AddDays(-14); // One query per signal — all keyed by CompanyId var allCompanies = await _db.Companies .AsNoTracking().IgnoreQueryFilters() .Where(c => !c.IsDeleted) .ToListAsync(); var churnedCount = allCompanies.Count(c => (c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled) && c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff); var companies = showChurned ? allCompanies : allCompanies.Where(c => !((c.SubscriptionStatus == SubscriptionStatus.Expired || c.SubscriptionStatus == SubscriptionStatus.Canceled) && c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value < churnedCutoff)) .ToList(); var lastLogins = await _db.Users .AsNoTracking().IgnoreQueryFilters() .Where(u => u.LastLoginDate != null) .GroupBy(u => u.CompanyId) .Select(g => new { CompanyId = g.Key, Last = g.Max(u => u.LastLoginDate) }) .ToDictionaryAsync(x => x.CompanyId, x => x.Last); var jobs30 = await _db.Jobs .AsNoTracking().IgnoreQueryFilters() .Where(j => !j.IsDeleted && j.CreatedAt >= d30) .GroupBy(j => j.CompanyId) .Select(g => new { g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.Key, x => x.Count); var jobs90 = await _db.Jobs .AsNoTracking().IgnoreQueryFilters() .Where(j => !j.IsDeleted && j.CreatedAt >= d90) .GroupBy(j => j.CompanyId) .Select(g => new { g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.Key, x => x.Count); var totalJobs = await _db.Jobs .AsNoTracking().IgnoreQueryFilters() .Where(j => !j.IsDeleted) .GroupBy(j => j.CompanyId) .Select(g => new { g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.Key, x => x.Count); var totalCustomers = await _db.Customers .AsNoTracking().IgnoreQueryFilters() .Where(c => !c.IsDeleted) .GroupBy(c => c.CompanyId) .Select(g => new { g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.Key, x => x.Count); var totalQuotes = await _db.Quotes .AsNoTracking().IgnoreQueryFilters() .GroupBy(q => q.CompanyId) .Select(g => new { g.Key, Count = g.Count() }) .ToDictionaryAsync(x => x.Key, x => x.Count); var planNames = await _db.SubscriptionPlanConfigs .AsNoTracking().IgnoreQueryFilters() .Where(p => p.IsActive) .ToDictionaryAsync(p => p.Plan, p => p.DisplayName); // Batch config health check — one set of queries for all companies var companyIds = companies.Select(c => c.Id).ToList(); var configHealthMap = await _configHealth.CheckBatchAsync(companyIds); var all = companies.Select(c => { var lastLogin = lastLogins.TryGetValue(c.Id, out var ll) ? ll : null; var daysSince = lastLogin.HasValue ? (int)(now - lastLogin.Value).TotalDays : -1; var j30v = jobs30.TryGetValue(c.Id, out var v30) ? v30 : 0; var j90v = jobs90.TryGetValue(c.Id, out var v90) ? v90 : 0; var tjobs = totalJobs.TryGetValue(c.Id, out var tj) ? tj : 0; var tcust = totalCustomers.TryGetValue(c.Id, out var tc) ? tc : 0; var tquotes = totalQuotes.TryGetValue(c.Id, out var tq) ? tq : 0; var planName = planNames.TryGetValue(c.SubscriptionPlan, out var pn) ? pn : c.SubscriptionPlan.ToString(); var (score, signals) = CompanyHealthHelper.ComputeHealth(c, daysSince, j30v, j90v, tjobs, now); var neverActivated = tjobs == 0 && tcust == 0 && tquotes == 0 && c.CreatedAt < now.AddDays(-7); var riskLevel = CompanyHealthHelper.ToRiskLevel(score, neverActivated); var configHealth = configHealthMap.TryGetValue(c.Id, out var ch) ? ch : new CompanyConfigHealth { CompanyId = c.Id }; return new CompanyHealthDto { Id = c.Id, CompanyName = c.CompanyName, PrimaryContactEmail = c.PrimaryContactEmail, PlanDisplayName = planName, SubscriptionStatus = c.SubscriptionStatus, SubscriptionEndDate = c.SubscriptionEndDate, IsActive = c.IsActive, IsComped = c.IsComped, IsNeverActivated = neverActivated, CreatedAt = c.CreatedAt, LastLoginDate = lastLogin, DaysSinceLastLogin = daysSince, JobsLast30Days = j30v, JobsLast90Days = j90v, TotalJobs = tjobs, TotalCustomers = tcust, TotalQuotes = tquotes, HealthScore = score, RiskLevel = riskLevel, RiskSignals = signals, ConfigHealth = configHealth, }; }).ToList(); // Summary counts before filtering (always platform-wide totals) ViewBag.HealthyCount = all.Count(h => h.RiskLevel == ChurnRisk.Healthy); ViewBag.AtRiskCount = all.Count(h => h.RiskLevel == ChurnRisk.AtRisk); ViewBag.CriticalCount = all.Count(h => h.RiskLevel == ChurnRisk.Critical); ViewBag.NeverActivatedCount = all.Count(h => h.RiskLevel == ChurnRisk.NeverActivated); ViewBag.ConfigIssuesCount = all.Count(h => !h.ConfigHealth.IsHealthy); ViewBag.Risk = risk; ViewBag.Search = search; ViewBag.ConfigIssuesOnly = configIssuesOnly; ViewBag.ShowChurned = showChurned; ViewBag.ChurnedCount = churnedCount; if (!string.IsNullOrWhiteSpace(search)) all = all.Where(h => h.CompanyName.Contains(search, StringComparison.OrdinalIgnoreCase) || h.PrimaryContactEmail.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList(); if (!string.IsNullOrWhiteSpace(risk) && Enum.TryParse(risk, out var riskEnum)) all = all.Where(h => h.RiskLevel == riskEnum).ToList(); if (configIssuesOnly) all = all.Where(h => !h.ConfigHealth.IsHealthy).ToList(); // Worst first all = all.OrderBy(h => (int)h.RiskLevel == 3 ? 3 : 0) // NeverActivated last .ThenBy(h => h.HealthScore) .ThenBy(h => h.CompanyName) .ToList(); return View(all); } } // ── View models ──────────────────────────────────────────────────────────────── public class CompanyHealthDto { public int Id { get; set; } public string CompanyName { get; set; } = ""; public string PrimaryContactEmail { get; set; } = ""; public string PlanDisplayName { get; set; } = ""; public SubscriptionStatus SubscriptionStatus { get; set; } public DateTime? SubscriptionEndDate { get; set; } public bool IsActive { get; set; } public bool IsComped { get; set; } public bool IsNeverActivated { get; set; } public DateTime CreatedAt { get; set; } public DateTime? LastLoginDate { get; set; } public int DaysSinceLastLogin { get; set; } // -1 = never public int JobsLast30Days { get; set; } public int JobsLast90Days { get; set; } public int TotalJobs { get; set; } public int TotalCustomers { get; set; } public int TotalQuotes { get; set; } public int HealthScore { get; set; } public ChurnRisk RiskLevel { get; set; } public List RiskSignals { get; set; } = new(); // Configuration health (setup gaps that break features) public CompanyConfigHealth ConfigHealth { get; set; } = new(); }