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();
}