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,199 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class AiUsageReportController : Controller
{
private readonly ApplicationDbContext _context;
private readonly ILogger<AiUsageReportController> _logger;
public AiUsageReportController(ApplicationDbContext context, ILogger<AiUsageReportController> logger)
{
_context = context;
_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.
/// All queries use IgnoreQueryFilters() where needed to cross tenant boundaries.
/// </summary>
public async Task<IActionResult> Index()
{
try
{
var now = DateTime.UtcNow;
var todayStart = now.Date;
var last7Start = todayStart.AddDays(-7);
var last30Start = todayStart.AddDays(-30);
// Companies (non-deleted only)
var companies = await _context.Companies
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.Select(c => new { c.Id, c.CompanyName, c.SubscriptionPlan, c.IsActive })
.ToListAsync();
// Plan display names from SubscriptionPlanConfig
var planConfigs = await _context.Set<PowderCoating.Core.Entities.SubscriptionPlanConfig>()
.IgnoreQueryFilters()
.Where(p => !p.IsDeleted)
.Select(p => new { p.Plan, p.DisplayName })
.ToListAsync();
var planNames = planConfigs.ToDictionary(p => p.Plan, p => p.DisplayName);
// All-time usage grouped by company — the four count windows are computed in SQL
var usageByCompany = await _context.AiUsageLogs
.GroupBy(l => l.CompanyId)
.Select(g => new
{
CompanyId = g.Key,
Today = g.Count(l => l.CalledAt >= todayStart),
Last7Days = g.Count(l => l.CalledAt >= last7Start),
Last30Days = g.Count(l => l.CalledAt >= last30Start),
AllTime = g.Count()
})
.ToListAsync();
// Top feature per company over the last 30 days
var featureStats = await _context.AiUsageLogs
.Where(l => l.CalledAt >= last30Start)
.GroupBy(l => new { l.CompanyId, l.Feature })
.Select(g => new { g.Key.CompanyId, g.Key.Feature, Count = g.Count() })
.ToListAsync();
var topFeatureByCompany = featureStats
.GroupBy(f => f.CompanyId)
.ToDictionary(
g => g.Key,
g => g.OrderByDescending(f => f.Count).First().Feature);
// Feature breakdown per company (last 30 days)
var featureBreakdownByCompany = featureStats
.GroupBy(f => f.CompanyId)
.ToDictionary(
g => g.Key,
g => g.ToDictionary(f => f.Feature, f => f.Count));
// Total AI photos per company (all time, including deleted photos)
var photoCounts = await _context.QuotePhotos
.IgnoreQueryFilters()
.Where(p => p.IsAiAnalysisPhoto && !p.IsDeleted)
.GroupBy(p => p.CompanyId)
.Select(g => new { CompanyId = g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.CompanyId, x => x.Count);
// Build report rows
var usageDict = usageByCompany.ToDictionary(u => u.CompanyId);
var rows = companies.Select(c =>
{
usageDict.TryGetValue(c.Id, out var u);
photoCounts.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();
// Platform totals for summary cards
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
};
}