1cb7a8ca4a
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>
165 lines
6.4 KiB
C#
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
|
|
};
|
|
}
|