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,324 @@
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;
}
/// <summary>
/// Renders the Company Health dashboard showing a churn-risk score and engagement
/// signals for every tenant company (SuperAdmin only).
/// <para>
/// 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 <c>CompanyId</c> and materialises to a dictionary for
/// O(1) lookup when building the per-company <see cref="CompanyHealthDto"/>.
/// </para>
/// <para>
/// All queries use <c>IgnoreQueryFilters()</c> so deleted companies and their
/// data are visible to SuperAdmins. The <c>!c.IsDeleted</c> predicate on the
/// companies query is then applied manually to exclude companies that have been
/// permanently removed from the platform.
/// </para>
/// <para>
/// Health scoring and signal classification are delegated to
/// <see cref="ComputeHealth"/> and the result is used to assign a
/// <see cref="ChurnRisk"/> bucket. Summary counts (for the dashboard header KPI
/// cards) are computed from the full un-filtered list <em>before</em> applying the
/// user's risk/search filters, so the KPI cards always show platform-wide totals.
/// </para>
/// </summary>
public async Task<IActionResult> Index(string? risk, string? search, bool configIssuesOnly = false)
{
var now = DateTime.UtcNow;
var d30 = now.AddDays(-30);
var d90 = now.AddDays(-90);
// One query per signal — all keyed by CompanyId
var companies = await _db.Companies
.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.ToListAsync();
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) = ComputeHealth(c, daysSince, j30v, j90v, tjobs, now);
var neverActivated = tjobs == 0 && tcust == 0 && tquotes == 0
&& c.CreatedAt < now.AddDays(-7);
var riskLevel = neverActivated ? ChurnRisk.NeverActivated
: score >= 75 ? ChurnRisk.Healthy
: score >= 45 ? ChurnRisk.AtRisk
: ChurnRisk.Critical;
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;
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<ChurnRisk>(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);
}
// ── Health score algorithm ──────────────────────────────────────────────────
/// <summary>
/// Computes a 0100 health score and a list of human-readable risk signals for a
/// single company based on its subscription status, login recency, and job activity.
/// <para>
/// Scoring rules (penalties are cumulative, floor is 0):
/// <list type="bullet">
/// <item>Disabled account: score immediately set to 0, no further evaluation.</item>
/// <item>Subscription expired past the grace period: 50 pts.</item>
/// <item>Subscription within grace period: 30 pts.</item>
/// <item>Subscription expiring within 7 days: 20 pts; within 14 days: 10 pts.</item>
/// <item>Comped companies skip subscription checks entirely.</item>
/// <item>Never logged in: 30 pts; no login in 90+ days: 30; 60+d: 20; 30+d: 10.</item>
/// <item>No jobs ever: 20 pts; no jobs in last 90 days: 10; no jobs in 30d: 5.</item>
/// </list>
/// A <c>daysSinceLogin</c> value of 1 means "never logged in" and is distinct
/// from "logged in exactly 0 days ago" (i.e. today).
/// </para>
/// </summary>
private static (int score, List<string> signals) ComputeHealth(
PowderCoating.Core.Entities.Company c, int daysSinceLogin,
int j30, int j90, int totalJobs, DateTime now)
{
var score = 100;
var signals = new List<string>();
if (!c.IsActive)
{
signals.Add("Account disabled");
return (0, signals);
}
// Subscription health (skip for comped)
if (!c.IsComped && c.SubscriptionEndDate.HasValue)
{
var daysUntil = (int)(c.SubscriptionEndDate.Value.Date - now.Date).TotalDays;
if (daysUntil < -AppConstants.SubscriptionConstants.GracePeriodDays)
{
score -= 50;
signals.Add("Subscription expired");
}
else if (daysUntil < 0)
{
score -= 30;
signals.Add("In grace period");
}
else if (daysUntil <= 7)
{
score -= 20;
signals.Add($"Expires in {daysUntil}d");
}
else if (daysUntil <= 14)
{
score -= 10;
signals.Add($"Expires in {daysUntil}d");
}
}
// Login activity
if (daysSinceLogin == -1)
{
score -= 30;
signals.Add("Never logged in");
}
else if (daysSinceLogin >= 90)
{
score -= 30;
signals.Add($"No login {daysSinceLogin}d");
}
else if (daysSinceLogin >= 60)
{
score -= 20;
signals.Add($"No login {daysSinceLogin}d");
}
else if (daysSinceLogin >= 30)
{
score -= 10;
signals.Add($"No login {daysSinceLogin}d");
}
// Job activity
if (totalJobs == 0)
{
score -= 20;
signals.Add("No jobs ever");
}
else if (j90 == 0)
{
score -= 10;
signals.Add("No jobs in 90d");
}
else if (j30 == 0)
{
score -= 5;
signals.Add("No jobs in 30d");
}
return (Math.Max(0, score), signals);
}
}
// ── View models ────────────────────────────────────────────────────────────────
public enum ChurnRisk { Healthy, AtRisk, Critical, NeverActivated }
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<string> RiskSignals { get; set; } = new();
// Configuration health (setup gaps that break features)
public CompanyConfigHealth ConfigHealth { get; set; } = new();
}