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>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PowderCoating.Infrastructure.Data;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
using PowderCoating.Core.Interfaces;
|
||||
using PowderCoating.Shared.Constants;
|
||||
|
||||
namespace PowderCoating.Web.Controllers;
|
||||
@@ -9,108 +9,74 @@ namespace PowderCoating.Web.Controllers;
|
||||
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
|
||||
public class AiUsageReportController : Controller
|
||||
{
|
||||
private readonly ApplicationDbContext _context;
|
||||
private readonly IUnitOfWork _unitOfWork;
|
||||
private readonly IAiUsageReportService _aiUsageReport;
|
||||
private readonly ILogger<AiUsageReportController> _logger;
|
||||
|
||||
public AiUsageReportController(ApplicationDbContext context, ILogger<AiUsageReportController> logger)
|
||||
public AiUsageReportController(
|
||||
IUnitOfWork unitOfWork,
|
||||
IAiUsageReportService aiUsageReport,
|
||||
ILogger<AiUsageReportController> logger)
|
||||
{
|
||||
_context = context;
|
||||
_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.
|
||||
/// All queries use IgnoreQueryFilters() where needed to cross tenant boundaries.
|
||||
/// 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 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()
|
||||
var companies = (await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true))
|
||||
.Where(c => !c.IsDeleted)
|
||||
.Select(c => new { c.Id, c.CompanyName, c.SubscriptionPlan, c.IsActive })
|
||||
.ToListAsync();
|
||||
.ToList();
|
||||
|
||||
// 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 planConfigs = await _unitOfWork.SubscriptionPlanConfigs.GetAllAsync();
|
||||
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();
|
||||
var data = await _aiUsageReport.GetReportDataAsync();
|
||||
|
||||
// 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
|
||||
var topFeatureByCompany = data.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
|
||||
var featureBreakdownByCompany = data.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 usageDict = data.UsageByCompany.ToDictionary(u => u.CompanyId);
|
||||
|
||||
var rows = companies.Select(c =>
|
||||
{
|
||||
usageDict.TryGetValue(c.Id, out var u);
|
||||
photoCounts.TryGetValue(c.Id, out var photos);
|
||||
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,
|
||||
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 ?? []
|
||||
};
|
||||
})
|
||||
@@ -118,15 +84,14 @@ public class AiUsageReportController : Controller
|
||||
.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 ?? "—"
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user