Initial commit
This commit is contained in:
@@ -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 0–100 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();
|
||||
}
|
||||
Reference in New Issue
Block a user