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