Files
PowderCoatingLogix/src/PowderCoating.Web/Controllers/AiUsageReportController.cs
T
spouliot 1cb7a8ca4a Phases 3 & 4: Complete data access architecture migration
Phase 3 — eliminated ApplicationDbContext from all non-exempt controllers,
routing all data access through IUnitOfWork. Added IPlainRepository<T> for
the four platform entities (Announcement, BannedIp, DashboardTip, ReleaseNote)
that intentionally don't extend BaseEntity and therefore can't use the
constrained IRepository<T>. Added permanent-exception comments to the 18
controllers that legitimately retain direct DbContext access (Identity infra,
cross-tenant platform ops, bulk streaming exports).

Phase 4 — added EnforceDataAccessArchitecture() to Program.cs, a startup
gate that reflects over every Controller subclass and throws at boot if any
non-exempt controller injects ApplicationDbContext. The app cannot start with
a violation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 09:17:29 -04:00

165 lines
6.4 KiB
C#

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class AiUsageReportController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly IAiUsageReportService _aiUsageReport;
private readonly ILogger<AiUsageReportController> _logger;
public AiUsageReportController(
IUnitOfWork unitOfWork,
IAiUsageReportService aiUsageReport,
ILogger<AiUsageReportController> logger)
{
_unitOfWork = unitOfWork;
_aiUsageReport = aiUsageReport;
_logger = logger;
}
/// <summary>
/// Platform-wide AI usage report. Shows per-company call counts, photo upload totals, top
/// feature used, and a usage tier so SuperAdmins can identify abusive or unusually heavy tenants.
/// Companies and plan configs come from IUnitOfWork; AiUsageLogs aggregations and photo counts
/// come from IAiUsageReportService (which runs SQL GROUP BY queries via ApplicationDbContext).
/// </summary>
public async Task<IActionResult> Index()
{
try
{
var companies = (await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true))
.Where(c => !c.IsDeleted)
.Select(c => new { c.Id, c.CompanyName, c.SubscriptionPlan, c.IsActive })
.ToList();
var planConfigs = await _unitOfWork.SubscriptionPlanConfigs.GetAllAsync();
var planNames = planConfigs.ToDictionary(p => p.Plan, p => p.DisplayName);
var data = await _aiUsageReport.GetReportDataAsync();
var topFeatureByCompany = data.FeatureStats
.GroupBy(f => f.CompanyId)
.ToDictionary(
g => g.Key,
g => g.OrderByDescending(f => f.Count).First().Feature);
var featureBreakdownByCompany = data.FeatureStats
.GroupBy(f => f.CompanyId)
.ToDictionary(
g => g.Key,
g => g.ToDictionary(f => f.Feature, f => f.Count));
var usageDict = data.UsageByCompany.ToDictionary(u => u.CompanyId);
var rows = companies.Select(c =>
{
usageDict.TryGetValue(c.Id, out var u);
data.PhotoCountsByCompany.TryGetValue(c.Id, out var photos);
topFeatureByCompany.TryGetValue(c.Id, out var topFeature);
featureBreakdownByCompany.TryGetValue(c.Id, out var breakdown);
planNames.TryGetValue(c.SubscriptionPlan, out var planName);
return new AiUsageReportRow
{
CompanyId = c.Id,
CompanyName = c.CompanyName,
Plan = planName ?? $"Plan {c.SubscriptionPlan}",
IsActive = c.IsActive,
Today = u?.Today ?? 0,
Last7Days = u?.Last7Days ?? 0,
Last30Days = u?.Last30Days ?? 0,
AllTime = u?.AllTime ?? 0,
PhotoCount = photos,
TopFeature = topFeature,
FeatureBreakdown = breakdown ?? []
};
})
.OrderByDescending(r => r.Last30Days)
.ThenByDescending(r => r.AllTime)
.ToList();
var vm = new AiUsageReportViewModel
{
Rows = rows,
TotalCallsLast30Days = rows.Sum(r => r.Last30Days),
TotalCallsToday = rows.Sum(r => r.Today),
CompaniesActiveToday = rows.Count(r => r.Today > 0),
TotalPhotosUploaded = rows.Sum(r => r.PhotoCount),
MostActiveCompany = rows.FirstOrDefault(r => r.Last30Days > 0)?.CompanyName ?? "—"
};
return View(vm);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error loading AI usage report");
return View(new AiUsageReportViewModel());
}
}
}
// ── View models ──────────────────────────────────────────────────────────────
public class AiUsageReportViewModel
{
public List<AiUsageReportRow> Rows { get; set; } = [];
public int TotalCallsLast30Days { get; set; }
public int TotalCallsToday { get; set; }
public int CompaniesActiveToday { get; set; }
public int TotalPhotosUploaded { get; set; }
public string MostActiveCompany { get; set; } = "—";
}
public class AiUsageReportRow
{
public int CompanyId { get; set; }
public string CompanyName { get; set; } = string.Empty;
public string Plan { get; set; } = string.Empty;
public bool IsActive { get; set; }
public int Today { get; set; }
public int Last7Days { get; set; }
public int Last30Days { get; set; }
public int AllTime { get; set; }
public int PhotoCount { get; set; }
public string? TopFeature { get; set; }
public Dictionary<string, int> FeatureBreakdown { get; set; } = [];
public string UsageTier => Last30Days switch
{
0 => "Inactive",
<= 10 => "Light",
<= 50 => "Regular",
<= 200 => "Heavy",
_ => "Power User"
};
public string TierBadgeClass => UsageTier switch
{
"Inactive" => "bg-secondary",
"Light" => "bg-success",
"Regular" => "bg-primary",
"Heavy" => "bg-warning text-dark",
"Power User" => "bg-danger",
_ => "bg-secondary"
};
public string FeatureDisplayName(string feature) => feature switch
{
"PhotoQuote" => "Photo Quote",
"HelpChat" => "Help Chat",
"ReceiptScan" => "Receipt Scan",
"AccountSuggest" => "Account Suggest",
"ArFollowUp" => "AR Follow-Up",
"FinancialSummary" => "Financial Summary",
"CashFlowForecast" => "Cash Flow",
"AnomalyDetection" => "Anomaly Detection",
_ => feature
};
}