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:
2026-04-28 09:17:29 -04:00
parent 90bc0d965f
commit 1cb7a8ca4a
72 changed files with 9060 additions and 2323 deletions
@@ -1,9 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using System.IO.Compression;
using System.Text;
@@ -12,14 +10,14 @@ namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public class AccountingExportController : Controller
{
private readonly ApplicationDbContext _context;
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly PowderCoating.Application.Interfaces.IAuditService _auditService;
public AccountingExportController(ApplicationDbContext context, ITenantContext tenantContext,
public AccountingExportController(IUnitOfWork unitOfWork, ITenantContext tenantContext,
PowderCoating.Application.Interfaces.IAuditService auditService)
{
_context = context;
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_auditService = auditService;
}
@@ -60,42 +58,33 @@ public class AccountingExportController : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> Export(DateTime startDate, DateTime endDate, string format)
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var start = startDate.Date;
var end = endDate.Date.AddDays(1).AddTicks(-1);
// ── Load data ─────────────────────────────────────────────────────────
var invoices = await _context.Invoices
.Include(i => i.InvoiceItems)
.Include(i => i.Payments)
.Include(i => i.Customer)
.Where(i => !i.IsDeleted && i.CompanyId == companyId
&& i.InvoiceDate >= start && i.InvoiceDate <= end)
var invoices = (await _unitOfWork.Invoices.FindAsync(
i => i.InvoiceDate >= start && i.InvoiceDate <= end,
false,
i => i.InvoiceItems,
i => i.Payments,
i => i.Customer))
.OrderBy(i => i.InvoiceDate)
.ToListAsync();
.ToList();
var expenses = await _context.Set<PowderCoating.Core.Entities.Expense>()
.Include(e => e.Vendor)
.Include(e => e.ExpenseAccount)
.Include(e => e.PaymentAccount)
.Where(e => !e.IsDeleted && e.CompanyId == companyId
&& e.Date >= start && e.Date <= end)
var expenses = (await _unitOfWork.Expenses.FindAsync(
e => e.Date >= start && e.Date <= end,
false,
e => e.Vendor,
e => e.ExpenseAccount,
e => e.PaymentAccount))
.OrderBy(e => e.Date)
.ToListAsync();
.ToList();
var bills = await _context.Set<PowderCoating.Core.Entities.Bill>()
.Include(b => b.Vendor)
.Include(b => b.LineItems).ThenInclude(l => l.Account)
.Include(b => b.Payments)
.Where(b => !b.IsDeleted && b.CompanyId == companyId
&& b.BillDate >= start && b.BillDate <= end)
.OrderBy(b => b.BillDate)
.ToListAsync();
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end);
var customers = await _context.Customers
.Where(c => !c.IsDeleted && c.CompanyId == companyId)
var customers = (await _unitOfWork.Customers.GetAllAsync())
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
.ToListAsync();
.ToList();
// ── Build ZIP ─────────────────────────────────────────────────────────
using var ms = new MemoryStream();
@@ -8,7 +8,6 @@ using PowderCoating.Application.Interfaces;
using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -19,7 +18,6 @@ public class AiQuickQuoteController : Controller
private readonly IUnitOfWork _unitOfWork;
private readonly IAiQuickQuoteService _aiService;
private readonly IPricingCalculationService _pricingService;
private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<AiQuickQuoteController> _logger;
@@ -27,14 +25,12 @@ public class AiQuickQuoteController : Controller
IUnitOfWork unitOfWork,
IAiQuickQuoteService aiService,
IPricingCalculationService pricingService,
ApplicationDbContext context,
UserManager<ApplicationUser> userManager,
ILogger<AiQuickQuoteController> logger)
{
_unitOfWork = unitOfWork;
_aiService = aiService;
_pricingService = pricingService;
_context = context;
_userManager = userManager;
_logger = logger;
}
@@ -106,9 +102,7 @@ public class AiQuickQuoteController : Controller
var walkIn = await GetOrCreateWalkInCustomerAsync(companyId);
// Draft status — nullable FK, gracefully absent if lookup not seeded
var draftStatus = await _context.QuoteStatusLookups
.Where(s => s.StatusCode == "DRAFT")
.FirstOrDefaultAsync();
var draftStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(s => s.StatusCode == "DRAFT");
var quoteNumber = await GenerateQuoteNumberAsync(companyId);
var now = DateTime.UtcNow;
@@ -300,21 +294,13 @@ public class AiQuickQuoteController : Controller
{
var now = DateTime.UtcNow;
var prefs = await _context.CompanyPreferences
.IgnoreQueryFilters()
.Where(p => p.CompanyId == companyId && !p.IsDeleted)
.Select(p => new { p.QuoteNumberPrefix })
.FirstOrDefaultAsync();
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
var quotePrefix = !string.IsNullOrWhiteSpace(prefs?.QuoteNumberPrefix) ? prefs.QuoteNumberPrefix : "QT";
var prefix = $"{quotePrefix}-{now:yy}{now:MM}";
var lastQuoteNumber = await _context.Quotes
.IgnoreQueryFilters()
.Where(q => q.CompanyId == companyId && q.QuoteNumber.StartsWith(prefix))
.OrderByDescending(q => q.QuoteNumber)
.Select(q => q.QuoteNumber)
.FirstOrDefaultAsync();
var lastQuoteNumber = await _unitOfWork.Quotes.GetLastQuoteNumberByPrefixAsync(companyId, prefix);
int nextNumber = 1;
if (lastQuoteNumber != null)
@@ -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);
@@ -1,9 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -11,12 +10,12 @@ namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class AnnouncementsController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IUnitOfWork _unitOfWork;
private readonly IInAppNotificationService _inApp;
public AnnouncementsController(ApplicationDbContext db, IInAppNotificationService inApp)
public AnnouncementsController(IUnitOfWork unitOfWork, IInAppNotificationService inApp)
{
_db = db;
_unitOfWork = unitOfWork;
_inApp = inApp;
}
@@ -25,18 +24,18 @@ public class AnnouncementsController : Controller
/// </summary>
public async Task<IActionResult> Index()
{
var announcements = await _db.Announcements
var announcements = (await _unitOfWork.Announcements.GetAllAsync())
.OrderByDescending(a => a.CreatedAt)
.ToListAsync();
.ToList();
return View(announcements);
}
/// <summary>
/// Shows the announcement creation form with sensible defaults: starts now, dismissible, and active.
/// </summary>
public IActionResult Create()
public async Task<IActionResult> Create()
{
PopulateDropdowns();
await PopulateDropdownsAsync();
return View(new Announcement { StartsAt = DateTime.Now, IsDismissible = true, IsActive = true });
}
@@ -46,7 +45,7 @@ public class AnnouncementsController : Controller
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Create(Announcement model)
{
if (!ModelState.IsValid) { PopulateDropdowns(); return View(model); }
if (!ModelState.IsValid) { await PopulateDropdownsAsync(); return View(model); }
model.CreatedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "";
model.CreatedByUserName = User.Identity?.Name ?? "SuperAdmin";
@@ -54,10 +53,9 @@ public class AnnouncementsController : Controller
model.StartsAt = model.StartsAt.ToUniversalTime();
if (model.ExpiresAt.HasValue) model.ExpiresAt = model.ExpiresAt.Value.ToUniversalTime();
_db.Announcements.Add(model);
await _db.SaveChangesAsync();
await _unitOfWork.Announcements.AddAsync(model);
await _unitOfWork.CompleteAsync();
// Dispatch as in-app notifications to targeted companies
await DispatchNotificationsAsync(model);
TempData["Success"] = "Announcement created and sent as notifications.";
@@ -69,9 +67,9 @@ public class AnnouncementsController : Controller
/// </summary>
public async Task<IActionResult> Edit(int id)
{
var announcement = await _db.Announcements.FindAsync(id);
var announcement = await _unitOfWork.Announcements.GetByIdAsync(id);
if (announcement == null) return NotFound();
PopulateDropdowns();
await PopulateDropdownsAsync();
return View(announcement);
}
@@ -81,9 +79,9 @@ public class AnnouncementsController : Controller
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, Announcement model)
{
if (!ModelState.IsValid) { PopulateDropdowns(); return View(model); }
if (!ModelState.IsValid) { await PopulateDropdownsAsync(); return View(model); }
var existing = await _db.Announcements.FindAsync(id);
var existing = await _unitOfWork.Announcements.GetByIdAsync(id);
if (existing == null) return NotFound();
existing.Title = model.Title;
@@ -98,7 +96,7 @@ public class AnnouncementsController : Controller
existing.IsActive = model.IsActive;
existing.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Announcement updated.";
return RedirectToAction(nameof(Index));
}
@@ -109,49 +107,42 @@ public class AnnouncementsController : Controller
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var announcement = await _db.Announcements.FindAsync(id);
var announcement = await _unitOfWork.Announcements.GetByIdAsync(id);
if (announcement == null) return NotFound();
_db.Announcements.Remove(announcement);
await _db.SaveChangesAsync();
await _unitOfWork.Announcements.DeleteAsync(announcement);
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Announcement deleted.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Fans out the announcement as in-app notifications to each matching company. IgnoreQueryFilters is required to reach all active companies regardless of tenant context (this runs as SuperAdmin). Filtering by Target/Plan/Company happens before the foreach so only relevant tenants receive the notification.
/// Fans out the announcement as in-app notifications to each matching company. IgnoreQueryFilters is required to reach all active companies regardless of tenant context. Filtering by Target/Plan/Company happens after the fetch so only relevant tenants receive the notification.
/// </summary>
private async Task DispatchNotificationsAsync(Announcement model)
{
IQueryable<PowderCoating.Core.Entities.Company> companyQuery = _db.Companies
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted && c.IsActive);
var companies = (await _unitOfWork.Companies.FindAsync(
c => !c.IsDeleted && c.IsActive, ignoreQueryFilters: true)).ToList();
if (model.Target == "Plan" && model.TargetPlan.HasValue)
companyQuery = companyQuery.Where(c => c.SubscriptionPlan == model.TargetPlan.Value);
companies = companies.Where(c => c.SubscriptionPlan == model.TargetPlan.Value).ToList();
else if (model.Target == "Company" && model.TargetCompanyId.HasValue)
companyQuery = companyQuery.Where(c => c.Id == model.TargetCompanyId.Value);
companies = companies.Where(c => c.Id == model.TargetCompanyId.Value).ToList();
var companyIds = await companyQuery.Select(c => c.Id).ToListAsync();
foreach (var companyId in companyIds)
{
await _inApp.CreateAsync(
companyId,
model.Title,
model.Message,
"Announcement");
}
foreach (var company in companies)
await _inApp.CreateAsync(company.Id, model.Title, model.Message, "Announcement");
}
/// <summary>
/// Loads company and plan lists into ViewBag for the Create/Edit form dropdowns. Uses AsNoTracking and IgnoreQueryFilters to bypass soft-delete and tenant filters so all companies are available for targeting.
/// Loads company and plan lists into ViewBag for the Create/Edit form dropdowns. Uses IgnoreQueryFilters to bypass soft-delete and tenant filters so all companies are available for targeting.
/// </summary>
private void PopulateDropdowns()
private async Task PopulateDropdownsAsync()
{
ViewBag.Companies = _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted).OrderBy(c => c.CompanyName)
.Select(c => new { c.Id, c.CompanyName }).ToList();
ViewBag.PlanConfigs = _db.SubscriptionPlanConfigs.AsNoTracking().IgnoreQueryFilters()
.Where(p => p.IsActive).OrderBy(p => p.SortOrder).ToList();
ViewBag.Companies = (await _unitOfWork.Companies.FindAsync(c => !c.IsDeleted, ignoreQueryFilters: true))
.OrderBy(c => c.CompanyName)
.Select(c => new { c.Id, c.CompanyName })
.ToList();
ViewBag.PlanConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(p => p.IsActive, ignoreQueryFilters: true))
.OrderBy(p => p.SortOrder)
.ToList();
}
}
@@ -14,6 +14,7 @@ namespace PowderCoating.Web.Controllers;
/// the application — they are append-only by design to maintain an unambiguous
/// trail of changes across all tenants.
/// </summary>
// Intentional exception: platform audit log with a long PK; append-only infrastructure table outside the business entity graph; same reasoning as SystemLogsController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class AuditLogController : Controller
{
@@ -1,9 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -15,28 +14,26 @@ namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class BannedIpsController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IUnitOfWork _unitOfWork;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<BannedIpsController> _logger;
public BannedIpsController(
ApplicationDbContext db,
IUnitOfWork unitOfWork,
UserManager<ApplicationUser> userManager,
ILogger<BannedIpsController> logger)
{
_db = db;
_unitOfWork = unitOfWork;
_userManager = userManager;
_logger = logger;
}
/// <summary>Lists all banned IPs, showing active and expired separately.</summary>
// GET: BannedIps
public async Task<IActionResult> Index()
{
var bans = await _db.BannedIps
var bans = (await _unitOfWork.BannedIps.GetAllAsync())
.OrderByDescending(b => b.BannedAt)
.ToListAsync();
.ToList();
return View(bans);
}
@@ -44,9 +41,7 @@ public class BannedIpsController : Controller
/// Adds a new IP ban. Rejects obviously invalid formats but doesn't require
/// a perfect regex — admins are trusted to enter valid IPs.
/// </summary>
// POST: BannedIps/Add
[HttpPost]
[ValidateAntiForgeryToken]
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Add(string ipAddress, string? reason, DateTime? expiresAt)
{
if (string.IsNullOrWhiteSpace(ipAddress))
@@ -57,15 +52,13 @@ public class BannedIpsController : Controller
ipAddress = ipAddress.Trim();
// Basic sanity check — must look like an IPv4 or IPv6 address
if (!System.Net.IPAddress.TryParse(ipAddress, out _))
{
TempData["Error"] = $"'{ipAddress}' is not a valid IP address.";
return RedirectToAction(nameof(Index));
}
// Don't duplicate an active ban for the same IP
var existing = await _db.BannedIps.FirstOrDefaultAsync(b => b.IpAddress == ipAddress && b.IsActive);
var existing = await _unitOfWork.BannedIps.FirstOrDefaultAsync(b => b.IpAddress == ipAddress && b.IsActive);
if (existing != null)
{
TempData["Error"] = $"{ipAddress} already has an active ban (added {existing.BannedAt:MMM dd, yyyy}).";
@@ -74,7 +67,7 @@ public class BannedIpsController : Controller
var currentUser = await _userManager.GetUserAsync(User);
_db.BannedIps.Add(new BannedIp
await _unitOfWork.BannedIps.AddAsync(new BannedIp
{
IpAddress = ipAddress,
Reason = reason?.Trim(),
@@ -83,8 +76,7 @@ public class BannedIpsController : Controller
ExpiresAt = expiresAt,
IsActive = true
});
await _db.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
_logger.LogWarning("IP {IP} banned by {Admin}. Reason: {Reason}", ipAddress, User.Identity?.Name, reason);
TempData["Success"] = $"{ipAddress} has been banned.";
@@ -92,12 +84,10 @@ public class BannedIpsController : Controller
}
/// <summary>Lifts a ban immediately by marking IsActive = false.</summary>
// POST: BannedIps/Lift/5
[HttpPost]
[ValidateAntiForgeryToken]
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Lift(int id)
{
var ban = await _db.BannedIps.FindAsync(id);
var ban = await _unitOfWork.BannedIps.GetByIdAsync(id);
if (ban == null)
{
TempData["Error"] = "Ban not found.";
@@ -105,7 +95,7 @@ public class BannedIpsController : Controller
}
ban.IsActive = false;
await _db.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
_logger.LogInformation("IP ban lifted for {IP} by {Admin}", ban.IpAddress, User.Identity?.Name);
TempData["Success"] = $"Ban on {ban.IpAddress} has been lifted.";
@@ -113,25 +103,21 @@ public class BannedIpsController : Controller
}
/// <summary>Permanently deletes a ban record.</summary>
// POST: BannedIps/Delete/5
[HttpPost]
[ValidateAntiForgeryToken]
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var ban = await _db.BannedIps.FindAsync(id);
var ban = await _unitOfWork.BannedIps.GetByIdAsync(id);
if (ban != null)
{
_db.BannedIps.Remove(ban);
await _db.SaveChangesAsync();
await _unitOfWork.BannedIps.DeleteAsync(ban);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("IP ban record deleted for {IP} by {Admin}", ban.IpAddress, User.Identity?.Name);
TempData["Success"] = $"Ban record for {ban.IpAddress} deleted.";
}
return RedirectToAction(nameof(Index));
}
/// <summary>Returns the requesting client's IP so the admin can pre-fill it quickly.</summary>
// GET: BannedIps/MyIp
public IActionResult MyIp()
{
return Json(new { ip = HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown" });
@@ -10,7 +10,6 @@ using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -22,7 +21,6 @@ public class BugReportController : Controller
private readonly IMapper _mapper;
private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _context;
private readonly IEmailService _emailService;
private readonly IAdminNotificationService _adminNotification;
private readonly IAzureBlobStorageService _blobService;
@@ -40,7 +38,6 @@ public class BugReportController : Controller
IMapper mapper,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager,
ApplicationDbContext context,
IEmailService emailService,
IAdminNotificationService adminNotification,
IAzureBlobStorageService blobService,
@@ -51,7 +48,6 @@ public class BugReportController : Controller
_mapper = mapper;
_tenantContext = tenantContext;
_userManager = userManager;
_context = context;
_emailService = emailService;
_adminNotification = adminNotification;
_blobService = blobService;
@@ -153,7 +149,7 @@ public class BugReportController : Controller
ContentType = file.ContentType,
FileSizeBytes = file.Length
};
_context.BugReportAttachments.Add(attachment);
await _unitOfWork.BugReportAttachments.AddAsync(attachment);
uploadedCount++;
}
else
@@ -164,7 +160,7 @@ public class BugReportController : Controller
}
if (uploadedCount > 0)
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
}
_logger.LogInformation("Bug report #{Id} submitted by {UserName} ({Company}): {Title} with {AttachmentCount} attachment(s)",
@@ -211,16 +207,13 @@ public class BugReportController : Controller
pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
var query = _context.BugReports
.AsNoTracking()
.IgnoreQueryFilters()
.Where(r => !r.IsDeleted)
.AsQueryable();
var allReports = (await _unitOfWork.BugReports.GetAllAsync(ignoreQueryFilters: true))
.AsEnumerable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var search = searchTerm.ToLower();
query = query.Where(r =>
allReports = allReports.Where(r =>
r.Title.ToLower().Contains(search) ||
r.Description.ToLower().Contains(search) ||
r.SubmittedByUserName.ToLower().Contains(search));
@@ -228,31 +221,31 @@ public class BugReportController : Controller
if (!string.IsNullOrWhiteSpace(statusFilter) &&
Enum.TryParse<BugReportStatus>(statusFilter, out var status))
query = query.Where(r => r.Status == status);
allReports = allReports.Where(r => r.Status == status);
if (!string.IsNullOrWhiteSpace(priorityFilter) &&
Enum.TryParse<BugReportPriority>(priorityFilter, out var priority))
query = query.Where(r => r.Priority == priority);
allReports = allReports.Where(r => r.Priority == priority);
query = (sortColumn, sortDirection == "asc") switch
allReports = (sortColumn, sortDirection == "asc") switch
{
("Title", true) => query.OrderBy(r => r.Title),
("Title", false) => query.OrderByDescending(r => r.Title),
("Status", true) => query.OrderBy(r => r.Status),
("Status", false) => query.OrderByDescending(r => r.Status),
("Priority", true) => query.OrderBy(r => r.Priority),
("Priority", false) => query.OrderByDescending(r => r.Priority),
("Submitted", true) => query.OrderBy(r => r.SubmittedByUserName),
("Submitted", false) => query.OrderByDescending(r => r.SubmittedByUserName),
(_, true) => query.OrderBy(r => r.CreatedAt),
_ => query.OrderByDescending(r => r.CreatedAt)
("Title", true) => allReports.OrderBy(r => r.Title),
("Title", false) => allReports.OrderByDescending(r => r.Title),
("Status", true) => allReports.OrderBy(r => r.Status),
("Status", false) => allReports.OrderByDescending(r => r.Status),
("Priority", true) => allReports.OrderBy(r => r.Priority),
("Priority", false) => allReports.OrderByDescending(r => r.Priority),
("Submitted", true) => allReports.OrderBy(r => r.SubmittedByUserName),
("Submitted", false) => allReports.OrderByDescending(r => r.SubmittedByUserName),
(_, true) => allReports.OrderBy(r => r.CreatedAt),
_ => allReports.OrderByDescending(r => r.CreatedAt)
};
var totalCount = await query.CountAsync();
var items = await query
var totalCount = allReports.Count();
var items = allReports
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
.ToList();
var dtos = _mapper.Map<List<BugReportDto>>(items);
@@ -291,12 +284,10 @@ public class BugReportController : Controller
var dto = _mapper.Map<EditBugReportDto>(bugReport);
var attachments = await _context.BugReportAttachments
.AsNoTracking()
.IgnoreQueryFilters()
.Where(a => a.BugReportId == id && !a.IsDeleted)
var attachments = (await _unitOfWork.BugReportAttachments.FindAsync(
a => a.BugReportId == id && !a.IsDeleted, ignoreQueryFilters: true))
.OrderBy(a => a.CreatedAt)
.ToListAsync();
.ToList();
dto.Attachments = _mapper.Map<List<BugReportAttachmentDto>>(attachments);
@@ -319,10 +310,7 @@ public class BugReportController : Controller
[HttpGet]
public async Task<IActionResult> Attachment(int id)
{
var attachment = await _context.BugReportAttachments
.AsNoTracking()
.IgnoreQueryFilters()
.FirstOrDefaultAsync(a => a.Id == id && !a.IsDeleted);
var attachment = await _unitOfWork.BugReportAttachments.GetByIdAsync(id, ignoreQueryFilters: true);
if (attachment == null)
return NotFound();
@@ -7,7 +7,7 @@ using PowderCoating.Application.DTOs.Company;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Extensions;
using System.Security.Claims;
@@ -24,7 +24,9 @@ public class CompaniesController : Controller
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ISeedDataService _seedDataService;
private readonly ApplicationDbContext _context;
private readonly ICompanyListService _companyList;
private readonly ICompanyDataPurgeService _companyPurge;
private readonly IAuditLogService _auditLog;
private readonly IInAppNotificationService _inApp;
private readonly ILogger<CompaniesController> _logger;
@@ -33,7 +35,9 @@ public class CompaniesController : Controller
IMapper mapper,
UserManager<ApplicationUser> userManager,
ISeedDataService seedDataService,
ApplicationDbContext context,
ICompanyListService companyList,
ICompanyDataPurgeService companyPurge,
IAuditLogService auditLog,
IInAppNotificationService inApp,
ILogger<CompaniesController> logger)
{
@@ -41,7 +45,9 @@ public class CompaniesController : Controller
_mapper = mapper;
_userManager = userManager;
_seedDataService = seedDataService;
_context = context;
_companyList = companyList;
_companyPurge = companyPurge;
_auditLog = auditLog;
_inApp = inApp;
_logger = logger;
}
@@ -67,88 +73,27 @@ public class CompaniesController : Controller
pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
var query = _context.Companies
.AsNoTracking()
.IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var s = searchTerm.ToLower();
query = query.Where(c =>
c.CompanyName.ToLower().Contains(s) ||
(c.CompanyCode != null && c.CompanyCode.ToLower().Contains(s)) ||
(c.PrimaryContactEmail != null && c.PrimaryContactEmail.ToLower().Contains(s)) ||
(c.Phone != null && c.Phone.ToLower().Contains(s)));
}
query = (sortColumn, sortDirection == "asc") switch
{
("CompanyName", true) => query.OrderBy(c => c.CompanyName),
("CompanyName", false) => query.OrderByDescending(c => c.CompanyName),
("Plan", true) => query.OrderBy(c => c.SubscriptionPlan),
("Plan", false) => query.OrderByDescending(c => c.SubscriptionPlan),
("Status", true) => query.OrderBy(c => c.IsActive),
("Status", false) => query.OrderByDescending(c => c.IsActive),
("Created", true) => query.OrderBy(c => c.CreatedAt),
("Created", false) => query.OrderByDescending(c => c.CreatedAt),
_ => query.OrderBy(c => c.CompanyName)
};
var totalCount = await query.CountAsync();
var companies = await query
.Include(c => c.Users)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
var (companies, totalCount) = await _companyList.GetPagedAsync(
searchTerm, sortColumn, sortDirection, pageNumber, pageSize);
var companyDtos = _mapper.Map<List<CompanyListDto>>(companies);
// Populate job/quote/customer counts efficiently via group queries
if (companyDtos.Any())
if (companyDtos.Count > 0)
{
var ids = companyDtos.Select(c => c.Id).ToList();
var jobCounts = await _context.Jobs.IgnoreQueryFilters()
.Where(j => ids.Contains(j.CompanyId) && !j.IsDeleted)
.GroupBy(j => j.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var quoteCounts = await _context.Quotes.IgnoreQueryFilters()
.Where(q => ids.Contains(q.CompanyId) && !q.IsDeleted)
.GroupBy(q => q.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var customerCounts = await _context.Customers.IgnoreQueryFilters()
.Where(c => ids.Contains(c.CompanyId) && !c.IsDeleted)
.GroupBy(c => c.CompanyId)
.Select(g => new { g.Key, Count = g.Count() })
.ToDictionaryAsync(x => x.Key, x => x.Count);
var wizardData = await _context.CompanyPreferences.IgnoreQueryFilters()
.Where(p => ids.Contains(p.CompanyId) && p.SetupWizardCompleted)
.Select(p => new
{
p.CompanyId,
p.SetupWizardCompletedAt,
p.SetupWizardCompletedByName
})
.ToDictionaryAsync(x => x.CompanyId);
var summary = await _companyList.GetCountSummaryAsync(ids);
foreach (var dto in companyDtos)
{
dto.JobCount = jobCounts.GetValueOrDefault(dto.Id, 0);
dto.QuoteCount = quoteCounts.GetValueOrDefault(dto.Id, 0);
dto.CustomerCount = customerCounts.GetValueOrDefault(dto.Id, 0);
dto.JobCount = summary.JobCounts.GetValueOrDefault(dto.Id, 0);
dto.QuoteCount = summary.QuoteCounts.GetValueOrDefault(dto.Id, 0);
dto.CustomerCount = summary.CustomerCounts.GetValueOrDefault(dto.Id, 0);
if (wizardData.TryGetValue(dto.Id, out var w))
if (summary.WizardInfo.TryGetValue(dto.Id, out var w))
{
dto.WizardCompleted = true;
dto.WizardCompletedAt = w.SetupWizardCompletedAt;
dto.WizardCompletedByName = w.SetupWizardCompletedByName;
dto.WizardCompletedAt = w.CompletedAt;
dto.WizardCompletedByName = w.CompletedByName;
}
}
}
@@ -380,8 +325,6 @@ public class CompaniesController : Controller
}
else
{
// If user creation failed, we should consider rolling back company creation
// For now, log the error and inform the user
_logger.LogError("Failed to create admin user for company {CompanyName}: {Errors}",
company.CompanyName, string.Join(", ", result.Errors.Select(e => e.Description)));
@@ -441,14 +384,10 @@ public class CompaniesController : Controller
public async Task<IActionResult> Edit(int id, UpdateCompanyDto model)
{
if (id != model.Id)
{
return NotFound();
}
if (!ModelState.IsValid)
{
return View(model);
}
try
{
@@ -473,9 +412,7 @@ public class CompaniesController : Controller
}
}
// Update company properties
_mapper.Map(model, company);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyName} updated successfully by {User}",
@@ -560,7 +497,6 @@ public class CompaniesController : Controller
var companyName = company.CompanyName;
var userCount = company.Users.Count;
// Soft-delete the company and deactivate all users
company.IsDeleted = true;
company.IsActive = false;
company.UpdatedAt = DateTime.UtcNow;
@@ -573,8 +509,7 @@ public class CompaniesController : Controller
await _unitOfWork.CompleteAsync();
// Write audit log
_context.AuditLogs.Add(new AuditLog
await _auditLog.LogAsync(new AuditLog
{
UserId = adminUserId,
UserName = adminName,
@@ -588,7 +523,6 @@ public class CompaniesController : Controller
Timestamp = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
await _context.SaveChangesAsync();
_logger.LogWarning("Company {CompanyName} (ID:{CompanyId}) soft-deleted by {User}",
companyName, id, adminName);
@@ -625,8 +559,7 @@ public class CompaniesController : Controller
return RedirectToAction(nameof(Index));
}
var company = await _context.Companies.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == id);
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
@@ -640,91 +573,25 @@ public class CompaniesController : Controller
try
{
// ── Tier 1: Leaf children (must go before their parents) ─────────────
// JobItem children
await _context.JobItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// QuoteItem children
await _context.QuoteItemPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteItemCoats.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// AnnouncementDismissals (no CompanyId — delete by user or company-targeted announcement)
// Load user IDs first — needed for announcement-dismissal cleanup in the purge service
var userIds = await _userManager.Users.IgnoreQueryFilters()
.Where(u => u.CompanyId == id).Select(u => u.Id).ToListAsync();
var announcementIds = await _context.Announcements
.Where(a => a.TargetCompanyId == id).Select(a => a.Id).ToListAsync();
await _context.AnnouncementDismissals.IgnoreQueryFilters()
.Where(x => userIds.Contains(x.UserId) || announcementIds.Contains(x.AnnouncementId))
.ExecuteDeleteAsync();
// ── Tier 2: Mid-level children ────────────────────────────────────────
await _context.JobItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobStatusHistory.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobPhotos.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobNotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobDailyPriorities.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CustomerNotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.MaintenanceRecords.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.BillLineItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.BillPayments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Payments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InvoiceItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobPrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuotePrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// Tiers 1-4: bulk delete all business data (service mirrors the original tier ordering)
await _companyPurge.DeleteAllBusinessDataAsync(id, userIds);
// ── Tier 3: Top-level company entities ───────────────────────────────
// Order matters: child-side of FK must be deleted before parent-side.
// Invoices/Appointments → Customers; Bills/Expenses → Vendors
await _context.Invoices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Appointments.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Jobs.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Quotes.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Customers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Bills.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Expenses.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Vendors.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Equipment.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.OvenCosts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CatalogItems.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Accounts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.NotificationLogs.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.NotificationTemplates.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// Announcements are platform-wide; only delete company-targeted ones (TargetCompanyId)
await _context.Announcements.Where(x => x.TargetCompanyId == id).ExecuteDeleteAsync();
await _context.BugReports.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.ShopWorkers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PrepServices.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// ── Tier 4: Company configs and lookup tables ─────────────────────────
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobPriorityLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.AppointmentStatusLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.AppointmentTypeLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PricingTiers.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CompanyOperatingCosts.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CompanyPreferences.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// ── Tier 5: Users (via Identity to cascade AspNetUser* tables) ────────
// Tier 5: delete Identity users so AspNetUser* tables cascade correctly
var users = await _userManager.Users.IgnoreQueryFilters()
.Where(u => u.CompanyId == id).ToListAsync();
var userCount = users.Count;
foreach (var user in users)
await _userManager.DeleteAsync(user);
// ── Tier 6: Company record ────────────────────────────────────────────
await _context.Companies.IgnoreQueryFilters().Where(c => c.Id == id).ExecuteDeleteAsync();
// Tier 6: delete company record
await _unitOfWork.Companies.DeleteAsync(company);
await _unitOfWork.CompleteAsync();
// Write audit log (use platform default company context — no companyId since it's gone)
_context.AuditLogs.Add(new AuditLog
await _auditLog.LogAsync(new AuditLog
{
UserId = adminUserId,
UserName = adminName,
@@ -738,7 +605,6 @@ public class CompaniesController : Controller
Timestamp = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
await _context.SaveChangesAsync();
_logger.LogWarning("Company {CompanyName} (ID:{CompanyId}) HARD DELETED by {User}. {UserCount} users removed.",
companyName, id, adminName, userCount);
@@ -764,8 +630,6 @@ public class CompaniesController : Controller
/// to record the action. This operation is irreversible.
/// </summary>
// POST: Companies/ResetData/5
// Permanently hard-deletes all business data for a company while keeping the company record,
// its users, operating costs, preferences, and lookup tables intact.
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResetData(int id, string confirmation)
@@ -776,8 +640,7 @@ public class CompaniesController : Controller
return RedirectToAction(nameof(Details), new { id });
}
var company = await _context.Companies.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == id);
var company = await _unitOfWork.Companies.GetByIdAsync(id, ignoreQueryFilters: true);
if (company == null)
{
@@ -791,94 +654,9 @@ public class CompaniesController : Controller
try
{
// ── Tier 0: Grandchildren ─────────────────────────────────────────────
await _context.JobTemplateItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobTemplateItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteItemPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteItemCoats .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.GiftCertificateRedemptions .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CreditMemoApplications .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.OvenBatchItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _companyPurge.ResetBusinessDataAsync(id);
// AnnouncementDismissals for company-targeted announcements
var announcementIds = await _context.Announcements
.Where(a => a.TargetCompanyId == id).Select(a => a.Id).ToListAsync();
if (announcementIds.Any())
await _context.AnnouncementDismissals.IgnoreQueryFilters()
.Where(x => announcementIds.Contains(x.AnnouncementId))
.ExecuteDeleteAsync();
// ── Tier 1: Children ──────────────────────────────────────────────────
await _context.JobTemplateItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobStatusHistory .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobChangeHistories .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobPhotos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobNotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobDailyPriorities .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobTimeEntries .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobPrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.ReworkRecords .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuoteChangeHistories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuotePrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.QuotePhotos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CustomerNotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryTransactions.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.MaintenanceRecords .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.BillLineItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.BillPayments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Payments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Deposits .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InvoiceItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PurchaseOrderItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.AiItemPredictions .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PowderUsageLogs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.ShopWorkerRoleCosts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.OvenBatches .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Refunds .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CreditMemos .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.GiftCertificates .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// ── Tier 2: Top-level business entities ──────────────────────────────
// Order matters: child-side of FK must be deleted before parent-side.
// Invoices/Appointments → Customers; Bills/PurchaseOrders/Expenses → Vendors
await _context.Invoices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Appointments .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Jobs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.JobTemplates .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Quotes .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Customers .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Bills .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PurchaseOrders .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Expenses .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Vendors .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CatalogItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryItems .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Equipment .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.OvenCosts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.Accounts .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.NotificationLogs .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.ShopWorkers .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.PrepServices .IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.CatalogCategories.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
await _context.InventoryCategoryLookups.IgnoreQueryFilters().Where(x => x.CompanyId == id).ExecuteDeleteAsync();
// Company-targeted announcements only (platform-wide announcements are left alone)
await _context.Announcements.Where(x => x.TargetCompanyId == id).ExecuteDeleteAsync();
// Reset QB migration wizard progress
var prefs = await _context.CompanyPreferences.IgnoreQueryFilters()
.FirstOrDefaultAsync(p => p.CompanyId == id);
if (prefs?.QbMigrationStateJson != null)
{
prefs.QbMigrationStateJson = null;
prefs.UpdatedAt = DateTime.UtcNow;
}
// Audit log
_context.AuditLogs.Add(new AuditLog
await _auditLog.LogAsync(new AuditLog
{
UserId = adminUserId,
UserName = adminName,
@@ -892,7 +670,6 @@ public class CompaniesController : Controller
Timestamp = DateTime.UtcNow,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
await _context.SaveChangesAsync();
_logger.LogWarning(
"Company {CompanyName} (ID:{CompanyId}) ALL BUSINESS DATA RESET by {User}.",
@@ -960,9 +737,7 @@ public class CompaniesController : Controller
{
var company = await _unitOfWork.Companies.GetByIdAsync(model.CompanyId, ignoreQueryFilters: true);
if (company != null)
{
model.CompanyName = company.CompanyName;
}
return View(model);
}
@@ -976,7 +751,6 @@ public class CompaniesController : Controller
return RedirectToAction(nameof(Index));
}
// Check if user already exists
var existingUser = await _userManager.FindByEmailAsync(model.Email);
if (existingUser != null)
{
@@ -985,7 +759,6 @@ public class CompaniesController : Controller
return View(model);
}
// Create admin user for the company
var adminUser = new ApplicationUser
{
UserName = model.Email,
@@ -1018,9 +791,7 @@ public class CompaniesController : Controller
else
{
foreach (var error in result.Errors)
{
ModelState.AddModelError("", error.Description);
}
model.CompanyName = company.CompanyName;
return View(model);
}
@@ -1054,21 +825,13 @@ public class CompaniesController : Controller
return NotFound(new { error = "User not found." });
// Use the viewed company's timezone so timestamps match the tenant's local time
var tz = await _context.Companies
.Where(c => c.Id == companyId)
.Select(c => c.TimeZone)
.FirstOrDefaultAsync();
var company = await _unitOfWork.Companies.GetByIdAsync(companyId, ignoreQueryFilters: true);
var tz = company?.TimeZone;
var logs = new List<dynamic>();
try
{
var rawLogs = await _context.AuditLogs
.AsNoTracking()
.Where(l => l.UserId == userId && l.EntityType == "ApplicationUser")
.OrderByDescending(l => l.Timestamp)
.Take(50)
.Select(l => new { l.Action, l.IpAddress, l.Timestamp, l.NewValues })
.ToListAsync();
var rawLogs = await _auditLog.GetUserActivityAsync(userId);
logs = rawLogs.Select(l => (dynamic)new
{
@@ -12,6 +12,7 @@ using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Security.Claims;
@@ -29,7 +30,7 @@ public class CompanySettingsController : Controller
private readonly ILookupCacheService _lookupCache;
private readonly IStripeConnectService _stripeConnect;
private readonly IConfiguration _configuration;
private readonly ApplicationDbContext _context;
private readonly IAuditLogService _auditLog;
private readonly UserManager<ApplicationUser> _userManager;
private readonly SignInManager<ApplicationUser> _signInManager;
@@ -42,7 +43,7 @@ public class CompanySettingsController : Controller
ILookupCacheService lookupCache,
IStripeConnectService stripeConnect,
IConfiguration configuration,
ApplicationDbContext context,
IAuditLogService auditLog,
UserManager<ApplicationUser> userManager,
SignInManager<ApplicationUser> signInManager)
{
@@ -54,7 +55,7 @@ public class CompanySettingsController : Controller
_lookupCache = lookupCache;
_stripeConnect = stripeConnect;
_configuration = configuration;
_context = context;
_auditLog = auditLog;
_userManager = userManager;
_signInManager = signInManager;
}
@@ -126,9 +127,7 @@ public class CompanySettingsController : Controller
var dto = _mapper.Map<CompanySettingsDto>(company);
// Populate AllowOnlinePayments from subscription plan config
var planConfig = await _context.Set<SubscriptionPlanConfig>()
.AsNoTracking()
.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
var planConfig = await _unitOfWork.SubscriptionPlanConfigs.FirstOrDefaultAsync(p => p.Plan == company.SubscriptionPlan);
dto.AllowOnlinePayments = planConfig?.AllowOnlinePayments ?? false;
// Flag whether Stripe Connect is configured (non-placeholder client ID)
@@ -2805,10 +2804,10 @@ public class CompanySettingsController : Controller
if (company == null) return NotFound();
var userCount = await _userManager.Users.CountAsync(u => u.CompanyId == companyId.Value);
var jobCount = await _context.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
var quoteCount = await _context.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
var custCount = await _context.Customers.CountAsync(c => c.CompanyId == companyId.Value);
var invCount = await _context.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
var jobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
var quoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
var custCount = await _unitOfWork.Customers.CountAsync(c => c.CompanyId == companyId.Value);
var invCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
ViewBag.CompanyName = company.CompanyName;
ViewBag.UserCount = userCount;
@@ -2859,10 +2858,10 @@ public class CompanySettingsController : Controller
// ── Gather counts for the audit snapshot ─────────────────────────
var userCount = company.Users.Count;
var jobCount = await _context.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
var quoteCount = await _context.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
var custCount = await _context.Customers.CountAsync(c => c.CompanyId == companyId.Value);
var invCount = await _context.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
var jobCount = await _unitOfWork.Jobs.CountAsync(j => j.CompanyId == companyId.Value);
var quoteCount = await _unitOfWork.Quotes.CountAsync(q => q.CompanyId == companyId.Value);
var custCount = await _unitOfWork.Customers.CountAsync(c => c.CompanyId == companyId.Value);
var invCount = await _unitOfWork.Invoices.CountAsync(i => i.CompanyId == companyId.Value);
// ── Soft-delete the company ───────────────────────────────────────
var now = DateTime.UtcNow;
@@ -2881,7 +2880,7 @@ public class CompanySettingsController : Controller
await _unitOfWork.CompleteAsync();
// ── Write audit log ───────────────────────────────────────────────
_context.AuditLogs.Add(new AuditLog
await _auditLog.LogAsync(new AuditLog
{
UserId = requestingUserId,
UserName = requestingUserName,
@@ -2899,7 +2898,6 @@ public class CompanySettingsController : Controller
Timestamp = now,
IpAddress = HttpContext.Connection.RemoteIpAddress?.ToString()
});
await _context.SaveChangesAsync();
_logger.LogWarning(
"Self-service account deletion: company {CompanyName} (ID:{CompanyId}) deleted by {User}. " +
@@ -9,7 +9,6 @@ using PowderCoating.Application.DTOs.User;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -25,7 +24,6 @@ public class CompanyUsersController : Controller
private readonly ILogger<CompanyUsersController> _logger;
private readonly IUnitOfWork _unitOfWork;
private readonly ISubscriptionService _subscriptionService;
private readonly ApplicationDbContext _context;
private readonly IEmailService _emailService;
public CompanyUsersController(
@@ -34,7 +32,6 @@ public class CompanyUsersController : Controller
ILogger<CompanyUsersController> logger,
IUnitOfWork unitOfWork,
ISubscriptionService subscriptionService,
ApplicationDbContext context,
IEmailService emailService)
{
_userManager = userManager;
@@ -42,7 +39,6 @@ public class CompanyUsersController : Controller
_logger = logger;
_unitOfWork = unitOfWork;
_subscriptionService = subscriptionService;
_context = context;
_emailService = emailService;
}
@@ -372,8 +368,8 @@ public class CompanyUsersController : Controller
CompanyId = companyId!.Value
};
await _context.ShopWorkers.AddAsync(shopWorker);
await _context.SaveChangesAsync();
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
}
@@ -639,9 +635,8 @@ public class CompanyUsersController : Controller
{
// Search by oldEmail so we find the record even when the email just changed
var lookupEmail = emailChanged ? oldEmail : user.Email;
var existingShopWorker = await _context.ShopWorkers
.Where(sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId)
.ToListAsync();
var existingShopWorker = (await _unitOfWork.ShopWorkers.FindAsync(
sw => sw.Email == lookupEmail && sw.CompanyId == user.CompanyId)).ToList();
if (!existingShopWorker.Any())
{
@@ -656,8 +651,8 @@ public class CompanyUsersController : Controller
CompanyId = user.CompanyId
};
await _context.ShopWorkers.AddAsync(shopWorker);
await _context.SaveChangesAsync();
await _unitOfWork.ShopWorkers.AddAsync(shopWorker);
await _unitOfWork.CompleteAsync();
_logger.LogInformation("ShopWorker record created for user {Email}", user.Email);
}
@@ -684,7 +679,7 @@ public class CompanyUsersController : Controller
shopWorker.Phone = user.PhoneNumber;
if (shopWorkerDirty)
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
}
}
@@ -1,12 +1,11 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Dashboard;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus
using PowderCoating.Core.Enums; // Still needed for MaintenanceStatus, EquipmentStatus, PaymentMethod
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Shared.Constants;
using InvoiceStatus = PowderCoating.Core.Enums.InvoiceStatus;
@@ -17,7 +16,9 @@ public class DashboardController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<DashboardController> _logger;
private readonly ApplicationDbContext _context;
private readonly IDashboardReadService _dashboardRead;
private readonly ITenantContext _tenantContext;
private readonly ICompanyConfigHealthService _configHealth;
private static readonly string[] CompletedStatusCodes =
[
@@ -39,14 +40,16 @@ public class DashboardController : Controller
"QUALITY_CHECK"
];
private readonly ITenantContext _tenantContext;
private readonly ICompanyConfigHealthService _configHealth;
public DashboardController(IUnitOfWork unitOfWork, ILogger<DashboardController> logger, ApplicationDbContext context, ITenantContext tenantContext, ICompanyConfigHealthService configHealth)
public DashboardController(
IUnitOfWork unitOfWork,
ILogger<DashboardController> logger,
IDashboardReadService dashboardRead,
ITenantContext tenantContext,
ICompanyConfigHealthService configHealth)
{
_unitOfWork = unitOfWork;
_logger = logger;
_context = context;
_dashboardRead = dashboardRead;
_tenantContext = tenantContext;
_configHealth = configHealth;
}
@@ -66,23 +69,14 @@ public class DashboardController : Controller
try
{
var today = DateTime.Today;
var startOfMonth = new DateTime(today.Year, today.Month, 1);
var endOfMonth = startOfMonth.AddMonths(1).AddDays(-1);
var lookAheadDate = today.AddDays(7); // Changed to 7 days for expiring quotes
var lookAheadDate = today.AddDays(7);
// Active jobs — filter completed/cancelled statuses at database level
var activeJobs = await _context.Jobs
.Include(j => j.Customer)
.Include(j => j.AssignedUser)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority)
.Where(j => !CompletedStatusCodes.Contains(j.JobStatus.StatusCode))
.ToListAsync();
var data = await _dashboardRead.GetIndexDataAsync(today);
var tomorrow = today.AddDays(1);
// Today's Jobs
var todaysJobsFiltered = activeJobs
// ---------------------------------------------------------------
// Job panels — in-memory split of the pre-fetched activeJobs list
// ---------------------------------------------------------------
var todaysJobsFiltered = data.ActiveJobs
.Where(j => (j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today) ||
(j.DueDate.HasValue && j.DueDate.Value.Date == today));
var todaysJobsCount = todaysJobsFiltered.Count();
@@ -93,8 +87,7 @@ public class DashboardController : Controller
.Select(MapJobDto)
.ToList();
// Overdue Jobs
var overdueJobsFiltered = activeJobs
var overdueJobsFiltered = data.ActiveJobs
.Where(j => j.DueDate.HasValue && j.DueDate.Value.Date < today);
var overdueJobsCount = overdueJobsFiltered.Count();
var overdueJobs = overdueJobsFiltered
@@ -104,8 +97,7 @@ public class DashboardController : Controller
.Select(MapJobDto)
.ToList();
// In-Progress Jobs
var inProgressJobs = activeJobs
var inProgressJobs = data.ActiveJobs
.Where(j => InProgressStatusCodes.Contains(j.JobStatus.StatusCode))
.OrderBy(j => j.JobPriority.DisplayOrder)
.ThenBy(j => j.ScheduledDate)
@@ -113,26 +105,11 @@ public class DashboardController : Controller
.Select(MapJobDto)
.ToList();
// Monthly Revenue — aggregate at database level (no need to load all jobs)
var monthlyRevenue = await _context.Jobs
.Include(j => j.JobStatus)
.Where(j => CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.UpdatedAt >= startOfMonth
&& j.UpdatedAt <= endOfMonth)
.SumAsync(j => j.FinalPrice);
// Today's Appointments — filter at database level
var todaysAppointmentsRaw = await _context.Appointments
.Include(a => a.Customer)
.Include(a => a.AppointmentType)
.Include(a => a.AppointmentStatus)
.Include(a => a.AssignedUser)
.Where(a => a.ScheduledStartTime >= today && a.ScheduledStartTime < tomorrow
&& a.AppointmentStatus.StatusCode != "CANCELLED")
.OrderBy(a => a.ScheduledStartTime)
.ToListAsync();
var todaysAppointmentsCount = todaysAppointmentsRaw.Count;
var todaysAppointments = todaysAppointmentsRaw
// ---------------------------------------------------------------
// Appointments
// ---------------------------------------------------------------
var todaysAppointmentsCount = data.TodaysAppointments.Count;
var todaysAppointments = data.TodaysAppointments
.Take(10)
.Select(a => new DashboardAppointmentDto
{
@@ -150,9 +127,11 @@ public class DashboardController : Controller
AssignedWorkerName = a.AssignedUser?.FullName
}).ToList();
// ---------------------------------------------------------------
// Low stock items
// ---------------------------------------------------------------
var lowStockAll = await _unitOfWork.InventoryItems.FindAsync(
i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
var lowStockCount = lowStockAll.Count();
var lowStockItems = lowStockAll
.OrderBy(i => i.QuantityOnHand)
@@ -168,21 +147,10 @@ public class DashboardController : Controller
UnitOfMeasure = i.UnitOfMeasure
}).ToList();
// Maintenance records — filter to pending/overdue at database level
var upcomingMaintenance = await _context.MaintenanceRecords
.Include(m => m.Equipment)
.Include(m => m.AssignedUser)
.Where(m => (m.Status == MaintenanceStatus.Scheduled
|| m.Status == MaintenanceStatus.InProgress
|| m.Status == MaintenanceStatus.Overdue)
&& (m.Status == MaintenanceStatus.Overdue || m.ScheduledDate <= lookAheadDate))
.OrderBy(m => m.Status == MaintenanceStatus.Overdue ? 0 : 1)
.ThenByDescending(m => m.Priority)
.ThenBy(m => m.ScheduledDate)
.Take(10)
.ToListAsync();
var upcomingMaintenanceDtos = upcomingMaintenance
// ---------------------------------------------------------------
// Maintenance
// ---------------------------------------------------------------
var upcomingMaintenanceDtos = data.UpcomingMaintenance
.Select(m => new DashboardMaintenanceDto
{
Id = m.Id,
@@ -195,14 +163,10 @@ public class DashboardController : Controller
AssignedWorkerName = m.AssignedUser?.FullName
}).ToList();
// Pending Quotes — filter to SENT status at database level
var pendingQuotesData = await _context.Quotes
.Include(q => q.Customer)
.Include(q => q.QuoteStatus)
.Where(q => q.QuoteStatus.StatusCode == "SENT")
.ToListAsync();
var pendingQuotes = pendingQuotesData
// ---------------------------------------------------------------
// Quotes
// ---------------------------------------------------------------
var pendingQuotes = data.PendingQuotes
.OrderBy(q => q.ExpirationDate)
.Take(10)
.Select(q => new DashboardQuoteDto
@@ -221,10 +185,9 @@ public class DashboardController : Controller
StatusDisplayName = q.QuoteStatus.DisplayName
}).ToList();
var pendingQuoteValue = pendingQuotesData.Sum(q => q.Total);
var pendingQuoteValue = data.PendingQuotes.Sum(q => q.Total);
// Expiring Quotes (next 7 days) - filter at database level
var expiringQuotes = pendingQuotesData
var expiringQuotes = data.PendingQuotes
.Where(q => q.ExpirationDate.HasValue
&& q.ExpirationDate.Value.Date >= today
&& q.ExpirationDate.Value.Date <= lookAheadDate)
@@ -246,33 +209,17 @@ public class DashboardController : Controller
StatusDisplayName = q.QuoteStatus.DisplayName
}).ToList();
// Active Customers
// ---------------------------------------------------------------
// Active customers
// ---------------------------------------------------------------
var activeCustomersCount = await _unitOfWork.Customers.CountAsync(c => c.IsActive);
// ---------------------------------------------------------------
// Financial data — Invoices & Payments
// Invoices & AR aging
// ---------------------------------------------------------------
var openStatuses = new[] { InvoiceStatus.Sent, InvoiceStatus.PartiallyPaid, InvoiceStatus.Overdue };
var outstandingAr = data.OpenInvoices.Sum(i => i.BalanceDue);
// Open invoices only — filter at database level
var openInvoices = await _context.Invoices
.Include(i => i.Customer)
.Where(i => openStatuses.Contains(i.Status))
.ToListAsync();
var outstandingAr = openInvoices.Sum(i => i.BalanceDue);
// Invoiced this month — aggregate at database level
var invoicedThisMonth = await _context.Invoices
.Where(i => i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.Status != InvoiceStatus.WrittenOff
&& i.InvoiceDate >= startOfMonth
&& i.InvoiceDate <= endOfMonth)
.SumAsync(i => i.Total);
// Overdue invoices: open and past due date
var overdueInvoicesList = openInvoices
var overdueInvoicesList = data.OpenInvoices
.Where(i => i.DueDate.HasValue && i.DueDate.Value.Date < today)
.OrderBy(i => i.DueDate)
.ToList();
@@ -295,9 +242,9 @@ public class DashboardController : Controller
})
.ToList();
// AR Aging bucket open invoices by days past due
// AR Aging buckets
decimal agingCurrent = 0, aging1To30 = 0, aging31To60 = 0, aging61To90 = 0, agingOver90 = 0;
foreach (var inv in openInvoices)
foreach (var inv in data.OpenInvoices)
{
if (!inv.DueDate.HasValue || inv.DueDate.Value.Date >= today)
{
@@ -313,17 +260,10 @@ public class DashboardController : Controller
}
}
// Payments this month — aggregate at database level
var collectedThisMonth = await _context.Payments
.Where(p => p.PaymentDate >= startOfMonth && p.PaymentDate <= endOfMonth)
.SumAsync(p => p.Amount);
// Recent payments — load only the 6 most recent
var recentPayments = (await _context.Payments
.Include(p => p.Invoice).ThenInclude(i => i!.Customer)
.OrderByDescending(p => p.PaymentDate)
.Take(6)
.ToListAsync())
// ---------------------------------------------------------------
// Payments
// ---------------------------------------------------------------
var recentPayments = data.RecentPayments
.Select(p => new DashboardPaymentDto
{
Id = p.Id,
@@ -335,44 +275,39 @@ public class DashboardController : Controller
PaymentDate = p.PaymentDate,
PaymentMethodDisplay = p.PaymentMethod switch
{
PowderCoating.Core.Enums.PaymentMethod.Cash => "Cash",
PowderCoating.Core.Enums.PaymentMethod.Check => "Check",
PowderCoating.Core.Enums.PaymentMethod.CreditDebitCard => "Card",
PowderCoating.Core.Enums.PaymentMethod.BankTransferACH => "ACH",
PowderCoating.Core.Enums.PaymentMethod.DigitalPayment => "Digital",
PaymentMethod.Cash => "Cash",
PaymentMethod.Check => "Check",
PaymentMethod.CreditDebitCard => "Card",
PaymentMethod.BankTransferACH => "ACH",
PaymentMethod.DigitalPayment => "Digital",
_ => "Other"
}
})
.ToList();
// Equipment Alerts - filter at database level
// ---------------------------------------------------------------
// Equipment alerts
// ---------------------------------------------------------------
var equipmentAlerts = (await _unitOfWork.Equipment.FindAsync(
e => e.Status == Core.Enums.EquipmentStatus.NeedsMaintenance ||
e.Status == Core.Enums.EquipmentStatus.OutOfService))
.OrderByDescending(e => e.Status == Core.Enums.EquipmentStatus.OutOfService ? 1 : 0)
e => e.Status == EquipmentStatus.NeedsMaintenance ||
e.Status == EquipmentStatus.OutOfService))
.OrderByDescending(e => e.Status == EquipmentStatus.OutOfService ? 1 : 0)
.Take(5)
.Select(e => new DashboardEquipmentAlertDto
{
Id = e.Id,
EquipmentName = e.EquipmentName,
EquipmentType = e.EquipmentType,
Issue = e.Status == Core.Enums.EquipmentStatus.OutOfService ? "Out of Service" : "Needs Maintenance",
Severity = e.Status == Core.Enums.EquipmentStatus.OutOfService ? "Critical" : "Warning",
Issue = e.Status == EquipmentStatus.OutOfService ? "Out of Service" : "Needs Maintenance",
Severity = e.Status == EquipmentStatus.OutOfService ? "Critical" : "Warning",
LastMaintenanceDate = e.LastMaintenanceDate,
NextMaintenanceDue = null // Equipment doesn't track next maintenance due date
NextMaintenanceDue = null
}).ToList();
// Recent Activity (last 10 quotes or jobs created in last 30 days)
var last30Days = today.AddDays(-30);
// Recent quotes — filter to last 30 days at database level
var recentQuotes = (await _context.Quotes
.Include(q => q.Customer)
.Include(q => q.QuoteStatus)
.Where(q => q.CreatedAt >= last30Days)
.OrderByDescending(q => q.CreatedAt)
.Take(5)
.ToListAsync())
// ---------------------------------------------------------------
// Recent activity
// ---------------------------------------------------------------
var recentQuoteDtos = data.RecentQuotes
.Select(q => new DashboardRecentActivityDto
{
Id = q.Id,
@@ -390,14 +325,7 @@ public class DashboardController : Controller
Amount = q.Total
});
// Recent jobs — filter to last 30 days at database level
var recentJobs = (await _context.Jobs
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Where(j => j.CreatedAt >= last30Days)
.OrderByDescending(j => j.CreatedAt)
.Take(5)
.ToListAsync())
var recentJobDtos = data.RecentJobs
.Select(j => new DashboardRecentActivityDto
{
Id = j.Id,
@@ -413,33 +341,15 @@ public class DashboardController : Controller
Amount = j.FinalPrice
});
var recentActivity = recentQuotes.Concat(recentJobs)
var recentActivity = recentQuoteDtos.Concat(recentJobDtos)
.OrderByDescending(a => a.ActivityDate)
.Take(10)
.ToList();
// === POWDER ORDERS NEEDED ===
var jobsNeedingPowder = await _context.Jobs
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.InventoryItem)
.ThenInclude(inv => inv!.PrimaryVendor)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.Vendor)
.Where(j => !j.IsDeleted
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.JobItems.Any(i => i.Coats.Any(c =>
!c.IsDeleted &&
!c.PowderOrdered &&
c.PowderToOrder > 0 &&
(c.InventoryItemId == null || c.InventoryItem!.QuantityOnHand < c.PowderToOrder))))
.ToListAsync();
// Flatten to individual coat lines that need ordering (with vendor info for grouping)
var powderFlat = jobsNeedingPowder
// ---------------------------------------------------------------
// Powder orders needed
// ---------------------------------------------------------------
var powderFlat = data.JobsNeedingPowder
.SelectMany(j => j.JobItems
.SelectMany(i => i.Coats
.Where(c => !c.IsDeleted && !c.PowderOrdered && c.PowderToOrder > 0
@@ -500,26 +410,10 @@ public class DashboardController : Controller
.OrderBy(g => g.VendorName)
.ToList();
// === POWDER ORDERS PLACED (ordered, awaiting receipt) ===
var jobsWithOrderedPowder = await _context.Jobs
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.InventoryItem)
.ThenInclude(inv => inv!.PrimaryVendor)
.Include(j => j.JobItems)
.ThenInclude(i => i.Coats)
.ThenInclude(c => c.Vendor)
.Where(j => !j.IsDeleted
&& !CompletedStatusCodes.Contains(j.JobStatus.StatusCode)
&& j.JobItems.Any(i => i.Coats.Any(c =>
!c.IsDeleted &&
c.PowderOrdered &&
!c.PowderReceived)))
.ToListAsync();
var placedFlat = jobsWithOrderedPowder
// ---------------------------------------------------------------
// Powder orders placed
// ---------------------------------------------------------------
var placedFlat = data.JobsWithOrderedPowder
.SelectMany(j => j.JobItems
.SelectMany(i => i.Coats
.Where(c => !c.IsDeleted && c.PowderOrdered && !c.PowderReceived)
@@ -584,16 +478,10 @@ public class DashboardController : Controller
.OrderBy(g => g.VendorName)
.ToList();
// === BILLS DUE ===
var billsDueRaw = await _context.Bills
.Include(b => b.Vendor)
.Where(b => !b.IsDeleted &&
(b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid) &&
b.Total > b.AmountPaid)
.OrderBy(b => b.DueDate)
.Take(15)
.ToListAsync();
var billsDue = billsDueRaw.Select(b => new DashboardBillDto
// ---------------------------------------------------------------
// Bills due
// ---------------------------------------------------------------
var billsDue = data.BillsDue.Select(b => new DashboardBillDto
{
Id = b.Id,
BillNumber = b.BillNumber,
@@ -608,21 +496,21 @@ public class DashboardController : Controller
var vm = new DashboardViewModel
{
// Counts
ActiveJobsCount = activeJobs.Count(),
ActiveJobsCount = data.ActiveJobs.Count,
TodaysJobsCount = todaysJobsCount,
OverdueJobsCount = overdueJobsCount,
TodaysAppointmentsCount = todaysAppointmentsCount,
LowStockCount = lowStockCount,
PendingMaintenanceCount = upcomingMaintenance.Count,
PendingQuotesCount = pendingQuotesData.Count(),
PendingMaintenanceCount = data.UpcomingMaintenance.Count,
PendingQuotesCount = data.PendingQuotes.Count,
PendingQuoteValue = pendingQuoteValue,
MonthlyRevenue = monthlyRevenue,
MonthlyRevenue = data.MonthlyRevenue,
ActiveCustomersCount = activeCustomersCount,
// Financial KPIs
OutstandingAr = outstandingAr,
CollectedThisMonth = collectedThisMonth,
InvoicedThisMonth = invoicedThisMonth,
CollectedThisMonth = data.CollectedThisMonth,
InvoicedThisMonth = data.InvoicedThisMonth,
OverdueInvoicesCount = overdueInvoicesCount,
OverdueInvoicesAmount = overdueInvoicesAmount,
AgingCurrent = agingCurrent,
@@ -654,7 +542,9 @@ public class DashboardController : Controller
PowderOrdersNeeded = powderOrderGroups,
PowderOrdersNeededCount = powderFlat.Count,
PowderOrdersPlaced = powderPlacedGroups,
PowderOrdersPlacedCount = placedFlat.Count
PowderOrdersPlacedCount = placedFlat.Count,
TipOfTheDay = data.TipOfTheDay
};
// Dropdowns for the "Add Custom Powder to Inventory" modal
@@ -671,13 +561,6 @@ public class DashboardController : Controller
ViewBag.InventoryCategories = inventoryCategories;
ViewBag.VendorList = vendors;
// Random tip of the day
var tips = await _context.DashboardTips
.Where(t => t.IsActive)
.ToListAsync();
if (tips.Count > 0)
vm.TipOfTheDay = tips[Random.Shared.Next(tips.Count)].TipText;
// Config health check — surface setup gaps to company admins
var currentCompanyId = _tenantContext.GetCurrentCompanyId();
if (currentCompanyId.HasValue)
@@ -705,11 +588,7 @@ public class DashboardController : Controller
{
try
{
var coat = await _context.JobItemCoats
.Include(c => c.JobItem).ThenInclude(i => i.Job).ThenInclude(j => j.Customer)
.Include(c => c.Vendor)
.Include(c => c.InventoryItem).ThenInclude(i => i!.PrimaryVendor)
.FirstOrDefaultAsync(c => c.Id == coatId);
var coat = await _unitOfWork.JobItemCoats.LoadForOrderMarkingAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat not found." });
@@ -722,7 +601,7 @@ public class DashboardController : Controller
coat.PowderOrdered = true;
coat.PowderOrderedAt = DateTime.UtcNow;
coat.PowderOrderedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
var vendor = coat.Vendor ?? coat.InventoryItem?.PrimaryVendor;
var job = coat.JobItem?.Job;
@@ -761,9 +640,9 @@ public class DashboardController : Controller
/// Records receipt of a powder shipment against an existing powder order. Sets
/// <c>PowderReceived</c>, <c>PowderReceivedLbs</c>, and <c>PowderReceivedAt</c> on the coat,
/// and — when the coat is linked to an inventory item — increases <c>QuantityOnHand</c> and
/// writes a <c>Purchase</c> <see cref="PowderCoating.Core.Entities.InventoryTransaction"/> so
/// the stock movement is fully traceable. Company ownership is verified through the parent job
/// because <c>JobItemCoat</c> carries no <c>CompanyId</c> of its own.
/// writes a <c>Purchase</c> <see cref="InventoryTransaction"/> so the stock movement is fully
/// traceable. Company ownership is verified through the parent job because <c>JobItemCoat</c>
/// carries no <c>CompanyId</c> of its own.
/// </summary>
[HttpPost]
[ValidateAntiForgeryToken]
@@ -771,9 +650,8 @@ public class DashboardController : Controller
{
try
{
var coat = await _context.JobItemCoats
.Include(c => c.InventoryItem)
.FirstOrDefaultAsync(c => c.Id == coatId);
// Load coat with inventory item for the stock update
var coat = await _unitOfWork.JobItemCoats.LoadWithInventoryAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat record not found." });
@@ -781,29 +659,25 @@ public class DashboardController : Controller
if (lbsReceived <= 0)
return Json(new { success = false, message = "Quantity received must be greater than zero." });
// Verify ownership — JobItemCoat has no CompanyId, check via parent job
// (We need the job for company check; load it if not already included)
// Verify ownership — JobItemCoat has no CompanyId, check via parent job.
// If JobItem/Job wasn't populated by the initial load, bring in the chain via a second
// query; EF Core identity-map fixup will propagate the navigation to the tracked coat.
var coatJobCompanyId = coat.JobItem?.Job?.CompanyId;
if (coatJobCompanyId == null)
{
// Reload with parent chain if not included
var coatWithJob = await _context.JobItemCoats
.Include(c => c.JobItem).ThenInclude(i => i.Job)
.FirstOrDefaultAsync(c => c.Id == coatId);
coatJobCompanyId = coatWithJob?.JobItem?.Job?.CompanyId;
await _unitOfWork.JobItemCoats.LoadWithJobChainAsync(coatId);
coatJobCompanyId = coat.JobItem?.Job?.CompanyId;
}
if (!_tenantContext.IsSuperAdmin() && coatJobCompanyId != _tenantContext.GetCurrentCompanyId())
return Json(new { success = false, message = "Access denied." });
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
// Mark coat as received
coat.PowderReceived = true;
coat.PowderReceivedAt = DateTime.UtcNow;
coat.PowderReceivedByUserId = userId;
coat.PowderReceivedLbs = lbsReceived;
// Update inventory if this coat is linked to an inventory item
if (coat.InventoryItemId.HasValue && coat.InventoryItem != null)
{
var item = coat.InventoryItem;
@@ -813,26 +687,24 @@ public class DashboardController : Controller
if (coat.PowderCostPerLb.HasValue)
item.LastPurchasePrice = coat.PowderCostPerLb.Value;
// Record purchase transaction
var transaction = new PowderCoating.Core.Entities.InventoryTransaction
var transaction = new InventoryTransaction
{
CompanyId = item.CompanyId,
InventoryItemId = item.Id,
TransactionType = PowderCoating.Core.Enums.InventoryTransactionType.Purchase,
TransactionType = InventoryTransactionType.Purchase,
Quantity = lbsReceived,
UnitCost = coat.PowderCostPerLb ?? item.UnitCost,
TotalCost = lbsReceived * (coat.PowderCostPerLb ?? item.UnitCost),
TransactionDate = DateTime.UtcNow,
Reference = coat.JobItem != null ? null : null, // loaded below if needed
Notes = $"Received {lbsReceived:N2} lbs for job order",
BalanceAfter = previousBalance + lbsReceived,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_context.Set<PowderCoating.Core.Entities.InventoryTransaction>().Add(transaction);
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
}
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
return Json(new { success = true, updatedInventory = coat.InventoryItemId.HasValue });
}
@@ -864,7 +736,7 @@ public class DashboardController : Controller
{
try
{
var coat = await _context.JobItemCoats.FindAsync(coatId);
var coat = await _unitOfWork.JobItemCoats.GetByIdAsync(coatId);
if (coat == null)
return Json(new { success = false, message = "Coat record not found." });
@@ -877,16 +749,13 @@ public class DashboardController : Controller
if (lbsReceived <= 0)
return Json(new { success = false, message = "Quantity received must be greater than zero." });
// Resolve company id from tenant context
var companyId = (await _unitOfWork.InventoryItems.GetAllAsync()).FirstOrDefault()?.CompanyId ?? 0;
// More reliably get CompanyId from the job chain
var jobItem = await _context.JobItems.Include(i => i.Job).FirstOrDefaultAsync(i => i.Coats.Any(c => c.Id == coatId));
if (jobItem?.Job != null)
companyId = jobItem.Job.CompanyId;
// Resolve company id from the job chain; fall back to tenant context
var jobItem = await _unitOfWork.JobItems.FirstOrDefaultAsync(
i => i.Coats.Any(c => c.Id == coatId), false, i => i.Job);
var companyId = jobItem?.Job?.CompanyId ?? _tenantContext.GetCurrentCompanyId() ?? 0;
// Check SKU uniqueness
var existingSku = await _context.InventoryItems.AnyAsync(i => i.SKU == sku.Trim());
if (existingSku)
if (await _unitOfWork.InventoryItems.AnyAsync(i => i.SKU == sku.Trim()))
return Json(new { success = false, message = $"SKU '{sku}' already exists in inventory." });
// Determine category display name for legacy field
@@ -925,10 +794,10 @@ public class DashboardController : Controller
UpdatedAt = DateTime.UtcNow,
};
_context.InventoryItems.Add(inventoryItem);
await _context.SaveChangesAsync();
await _unitOfWork.InventoryItems.AddAsync(inventoryItem);
await _unitOfWork.CompleteAsync(); // flush to get inventoryItem.Id
// Record opening stock transaction
// Opening stock transaction
var transaction = new InventoryTransaction
{
CompanyId = companyId,
@@ -943,7 +812,7 @@ public class DashboardController : Controller
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
};
_context.Set<InventoryTransaction>().Add(transaction);
await _unitOfWork.InventoryTransactions.AddAsync(transaction);
// Mark coat as received and link to the new inventory item
var userId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
@@ -953,19 +822,12 @@ public class DashboardController : Controller
coat.PowderReceivedLbs = lbsReceived;
coat.InventoryItemId = inventoryItem.Id;
// Scan for other active job coats using the same custom powder and link them
var candidateCoats = await _context.JobItemCoats
.Include(c => c.JobItem)
.Where(c => !c.IsDeleted
&& c.Id != coatId
&& c.InventoryItemId == null
&& c.JobItem.CompanyId == companyId)
.ToListAsync();
// Scan for sibling coats with the same custom powder and link them to the new item
var candidateCoats = await _unitOfWork.JobItemCoats.GetCandidateCoatsForLinkingAsync(coatId, companyId);
int linkedCount = 0;
foreach (var other in candidateCoats)
{
// Match by color code first (most specific), then fall back to color name
bool colorMatch = !string.IsNullOrWhiteSpace(colorCode)
? string.Equals(other.ColorCode?.Trim(), colorCode.Trim(), StringComparison.OrdinalIgnoreCase)
: !string.IsNullOrWhiteSpace(colorName) &&
@@ -973,7 +835,6 @@ public class DashboardController : Controller
if (!colorMatch) continue;
// If both coats have a vendor set, they must agree
if (primaryVendorId.HasValue && other.VendorId.HasValue && other.VendorId != primaryVendorId)
continue;
@@ -985,7 +846,7 @@ public class DashboardController : Controller
_logger.LogInformation("Linked {Count} additional coat(s) to new inventory item {ItemId} ({SKU})",
linkedCount, inventoryItem.Id, inventoryItem.SKU);
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
return Json(new { success = true, itemName = inventoryItem.Name, sku = inventoryItem.SKU, linkedCount });
}
@@ -1014,13 +875,10 @@ public class DashboardController : Controller
var allCompanies = await _unitOfWork.Companies.GetAllAsync(ignoreQueryFilters: true);
var companies = allCompanies.Where(c => !c.IsDeleted).ToList();
var totalUsers = await _context.Users
.Where(u => u.CompanyId > 0)
.CountAsync();
var totalUsers = await _dashboardRead.GetTotalUserCountAsync();
var graceCutoff = today.AddDays(-AppConstants.SubscriptionConstants.GracePeriodDays);
// Load plan configs from DB so plan display names and distribution are DB-driven
var planConfigs = (await _unitOfWork.SubscriptionPlanConfigs.FindAsync(
c => c.IsActive, ignoreQueryFilters: true))
.OrderBy(c => c.SortOrder)
@@ -1029,7 +887,6 @@ public class DashboardController : Controller
var planLookup = planConfigs.ToDictionary(c => c.Plan, c => c.DisplayName);
string PlanName(int plan) => planLookup.TryGetValue(plan, out var name) ? name : plan.ToString();
// Companies needing attention: expired (past grace) or in grace period
var companyAlerts = companies
.Where(c => c.SubscriptionEndDate.HasValue && c.SubscriptionEndDate.Value.Date < today)
.OrderBy(c => c.SubscriptionEndDate)
@@ -1066,7 +923,6 @@ public class DashboardController : Controller
})
.ToList();
// Build plan distribution from DB config (sorted by SortOrder)
var planDistribution = planConfigs.ToDictionary(
c => c.Plan,
c => (c.DisplayName, companies.Count(comp => comp.SubscriptionPlan == c.Plan)));
@@ -1,8 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -18,39 +17,37 @@ namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class DashboardTipsController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IUnitOfWork _unitOfWork;
public DashboardTipsController(ApplicationDbContext db)
public DashboardTipsController(IUnitOfWork unitOfWork)
{
_db = db;
_unitOfWork = unitOfWork;
}
/// <summary>
/// Returns a paginated, optionally filtered list of all dashboard tips.
/// Active tips are sorted first (then by newest Id) to make the currently
/// live pool easy to review at a glance. ViewBag includes both the filtered
/// count and the global active/total counts for the header summary cards.
/// live pool easy to review at a glance.
/// </summary>
// GET: /DashboardTips
public async Task<IActionResult> Index(string? search, bool? activeOnly, int page = 1)
{
const int pageSize = 25;
var query = _db.DashboardTips.AsQueryable();
var all = (await _unitOfWork.DashboardTips.GetAllAsync()).ToList();
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(t => t.TipText.Contains(search));
all = all.Where(t => t.TipText.Contains(search, StringComparison.OrdinalIgnoreCase)).ToList();
if (activeOnly == true)
query = query.Where(t => t.IsActive);
all = all.Where(t => t.IsActive).ToList();
var total = await query.CountAsync();
var tips = await query
var total = all.Count;
var tips = all
.OrderByDescending(t => t.IsActive)
.ThenByDescending(t => t.Id)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync();
.ToList();
ViewBag.Search = search;
ViewBag.ActiveOnly = activeOnly ?? false;
@@ -58,23 +55,19 @@ public class DashboardTipsController : Controller
ViewBag.PageSize = pageSize;
ViewBag.Total = total;
ViewBag.TotalPages = (int)Math.Ceiling(total / (double)pageSize);
ViewBag.ActiveCount = await _db.DashboardTips.CountAsync(t => t.IsActive);
ViewBag.TotalCount = await _db.DashboardTips.CountAsync();
ViewBag.ActiveCount = await _unitOfWork.DashboardTips.CountAsync(t => t.IsActive);
ViewBag.TotalCount = await _unitOfWork.DashboardTips.CountAsync();
return View(tips);
}
/// <summary>Returns the Create form with an empty <see cref="DashboardTip"/> model.</summary>
// GET: /DashboardTips/Create
public IActionResult Create() => View(new DashboardTip());
/// <summary>
/// Persists a new dashboard tip. Text is trimmed before saving to prevent
/// whitespace-only entries from appearing as blank tiles on the dashboard.
/// Model validation is done manually (rather than relying solely on
/// <c>[Required]</c> attributes) to ensure a meaningful error message is shown.
/// </summary>
// POST: /DashboardTips/Create
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Create(DashboardTip model)
{
@@ -84,37 +77,33 @@ public class DashboardTipsController : Controller
return View(model);
}
_db.DashboardTips.Add(new DashboardTip
await _unitOfWork.DashboardTips.AddAsync(new DashboardTip
{
TipText = model.TipText.Trim(),
IsActive = model.IsActive,
CreatedAt = DateTime.UtcNow
});
await _db.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Tip added successfully.";
return RedirectToAction(nameof(Index));
}
/// <summary>Returns the Edit form for an existing tip, or 404 if not found.</summary>
// GET: /DashboardTips/Edit/5
public async Task<IActionResult> Edit(int id)
{
var tip = await _db.DashboardTips.FindAsync(id);
var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
if (tip == null) return NotFound();
return View(tip);
}
/// <summary>
/// Updates text and active flag for an existing tip. Returns the tracked entity
/// (not the posted model) to the view on validation failure so the form shows
/// the database version rather than potentially mangled posted data.
/// Updates text and active flag for an existing tip.
/// </summary>
// POST: /DashboardTips/Edit/5
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(int id, DashboardTip model)
{
var tip = await _db.DashboardTips.FindAsync(id);
var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
if (tip == null) return NotFound();
if (string.IsNullOrWhiteSpace(model.TipText))
@@ -125,27 +114,25 @@ public class DashboardTipsController : Controller
tip.TipText = model.TipText.Trim();
tip.IsActive = model.IsActive;
await _db.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Tip updated.";
return RedirectToAction(nameof(Index));
}
/// <summary>
/// Permanently (hard) deletes a dashboard tip. Tips are platform metadata so
/// Permanently (hard) deletes a dashboard tip. Tips are platform metadata so
/// they do not use soft delete — they have no foreign-key relationships to
/// tenant data and nothing references a deleted tip's Id. A missing Id is
/// silently ignored to keep the action idempotent.
/// tenant data and nothing references a deleted tip's Id.
/// </summary>
// POST: /DashboardTips/Delete/5
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> Delete(int id)
{
var tip = await _db.DashboardTips.FindAsync(id);
var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
if (tip != null)
{
_db.DashboardTips.Remove(tip);
await _db.SaveChangesAsync();
await _unitOfWork.DashboardTips.DeleteAsync(tip);
await _unitOfWork.CompleteAsync();
TempData["Success"] = "Tip deleted.";
}
return RedirectToAction(nameof(Index));
@@ -153,19 +140,15 @@ public class DashboardTipsController : Controller
/// <summary>
/// Flips the <c>IsActive</c> flag on a tip without a full edit round-trip.
/// This lets operators quickly remove a tip from the rotation (deactivate)
/// without deleting it, preserving the ability to reactivate it later.
/// A missing Id is silently ignored to keep the action idempotent.
/// </summary>
// POST: /DashboardTips/ToggleActive/5
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> ToggleActive(int id)
{
var tip = await _db.DashboardTips.FindAsync(id);
var tip = await _unitOfWork.DashboardTips.GetByIdAsync(id);
if (tip != null)
{
tip.IsActive = !tip.IsActive;
await _db.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
}
return RedirectToAction(nameof(Index));
}
@@ -7,7 +7,6 @@ using PowderCoating.Application.DTOs.Company;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
@@ -21,18 +20,15 @@ public class DepositsController : Controller
private readonly IUnitOfWork _unitOfWork;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<DepositsController> _logger;
private readonly ApplicationDbContext _context;
public DepositsController(
IUnitOfWork unitOfWork,
UserManager<ApplicationUser> userManager,
ILogger<DepositsController> logger,
ApplicationDbContext context)
ILogger<DepositsController> logger)
{
_unitOfWork = unitOfWork;
_userManager = userManager;
_logger = logger;
_context = context;
}
// -----------------------------------------------------------------------
@@ -220,11 +216,11 @@ public class DepositsController : Controller
{
var prefix = $"DEP-{DateTime.UtcNow:yy}{DateTime.UtcNow.Month:D2}-";
var existing = await _context.Set<Deposit>()
.IgnoreQueryFilters()
.Where(d => d.CompanyId == companyId && d.ReceiptNumber.StartsWith(prefix))
var existing = (await _unitOfWork.Deposits.FindAsync(
d => d.CompanyId == companyId && d.ReceiptNumber.StartsWith(prefix),
ignoreQueryFilters: true))
.Select(d => d.ReceiptNumber)
.ToListAsync();
.ToList();
var maxNum = 0;
foreach (var num in existing)
@@ -12,6 +12,7 @@ using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
// Intentional exception: cross-tenant fan-out querying ASP.NET Identity Users table with company joins; Identity entities live outside IUnitOfWork. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class EmailBroadcastController : Controller
{
@@ -14,7 +14,6 @@ using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.Controllers;
@@ -25,7 +24,6 @@ public class ExpensesController : Controller
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<ExpensesController> _logger;
private readonly ApplicationDbContext _context;
private readonly IAzureBlobStorageService _blobStorage;
private readonly StorageSettings _storageSettings;
private readonly IAccountBalanceService _accountBalanceService;
@@ -40,7 +38,6 @@ public class ExpensesController : Controller
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<ExpensesController> logger,
ApplicationDbContext context,
IAzureBlobStorageService blobStorage,
IOptions<StorageSettings> storageSettings,
IAccountBalanceService accountBalanceService,
@@ -51,7 +48,6 @@ public class ExpensesController : Controller
_mapper = mapper;
_userManager = userManager;
_logger = logger;
_context = context;
_blobStorage = blobStorage;
_storageSettings = storageSettings.Value;
_accountBalanceService = accountBalanceService;
@@ -80,28 +76,25 @@ public class ExpensesController : Controller
[NonAction]
public async Task<IActionResult> IndexLegacy(string? search, int? accountId, DateTime? from, DateTime? to, int page = 1, int pageSize = 25)
{
var query = _context.Expenses
.Include(e => e.Vendor)
.Include(e => e.ExpenseAccount)
.Include(e => e.PaymentAccount)
.Include(e => e.Job)
.Where(e => !e.IsDeleted);
var allExpenses = (await _unitOfWork.Expenses.GetAllAsync(
false, e => e.Vendor, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Job))
.AsEnumerable();
if (!string.IsNullOrEmpty(search))
query = query.Where(e => e.ExpenseNumber.Contains(search) ||
e.Memo!.Contains(search) ||
allExpenses = allExpenses.Where(e => e.ExpenseNumber.Contains(search) ||
(e.Memo != null && e.Memo.Contains(search)) ||
(e.Vendor != null && e.Vendor.CompanyName.Contains(search)));
if (accountId.HasValue)
query = query.Where(e => e.ExpenseAccountId == accountId.Value);
allExpenses = allExpenses.Where(e => e.ExpenseAccountId == accountId.Value);
if (from.HasValue)
query = query.Where(e => e.Date >= from.Value);
allExpenses = allExpenses.Where(e => e.Date >= from.Value);
if (to.HasValue)
query = query.Where(e => e.Date <= to.Value);
allExpenses = allExpenses.Where(e => e.Date <= to.Value);
var expenses = await query.OrderByDescending(e => e.CreatedAt).ToListAsync();
var expenses = allExpenses.OrderByDescending(e => e.CreatedAt).ToList();
var dtos = _mapper.Map<List<ExpenseListDto>>(expenses);
ViewBag.Search = search;
@@ -110,11 +103,11 @@ public class ExpensesController : Controller
ViewBag.To = to?.ToString("yyyy-MM-dd");
ViewBag.TotalAmount = dtos.Sum(e => e.Amount);
var expenseAccounts = await _context.Accounts
.Where(a => !a.IsDeleted && a.IsActive &&
(a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods))
var expenseAccounts = (await _unitOfWork.Accounts.FindAsync(
a => a.IsActive &&
(a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods)))
.OrderBy(a => a.AccountNumber)
.ToListAsync();
.ToList();
ViewBag.AccountFilter = expenseAccounts
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
@@ -297,12 +290,8 @@ public class ExpensesController : Controller
{
if (id == null) return NotFound();
var expense = await _context.Expenses
.Include(e => e.Vendor)
.Include(e => e.ExpenseAccount)
.Include(e => e.PaymentAccount)
.Include(e => e.Job)
.FirstOrDefaultAsync(e => e.Id == id && !e.IsDeleted);
var expense = await _unitOfWork.Expenses.GetByIdAsync(
id.Value, false, e => e.Vendor, e => e.ExpenseAccount, e => e.PaymentAccount, e => e.Job);
if (expense == null) return NotFound();
return View(_mapper.Map<ExpenseDto>(expense));
@@ -445,12 +434,11 @@ public class ExpensesController : Controller
private async Task<string> GenerateExpenseNumberAsync()
{
var prefix = $"EXP-{DateTime.Now:yyMM}-";
var last = await _context.Expenses
.IgnoreQueryFilters()
.Where(e => e.ExpenseNumber.StartsWith(prefix))
var last = (await _unitOfWork.Expenses.FindAsync(
e => e.ExpenseNumber.StartsWith(prefix), ignoreQueryFilters: true))
.OrderByDescending(e => e.ExpenseNumber)
.Select(e => e.ExpenseNumber)
.FirstOrDefaultAsync();
.FirstOrDefault();
int next = 1;
if (last != null && int.TryParse(last[prefix.Length..], out int num))
@@ -1,8 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Web.Extensions;
namespace PowderCoating.Web.Controllers;
@@ -10,12 +8,12 @@ namespace PowderCoating.Web.Controllers;
[Authorize]
public class InAppNotificationsController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenant;
public InAppNotificationsController(ApplicationDbContext db, ITenantContext tenant)
public InAppNotificationsController(IUnitOfWork unitOfWork, ITenantContext tenant)
{
_db = db;
_unitOfWork = unitOfWork;
_tenant = tenant;
}
@@ -27,23 +25,15 @@ public class InAppNotificationsController : Controller
pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 ? pageSize : 25;
IQueryable<PowderCoating.Core.Entities.InAppNotification> query;
var all = _tenant.IsPlatformAdmin()
? (await _unitOfWork.InAppNotifications.FindAsync(
n => !n.IsDeleted && n.CompanyId == 0, ignoreQueryFilters: true)).ToList()
: (await _unitOfWork.InAppNotifications.GetAllAsync()).ToList();
if (_tenant.IsPlatformAdmin())
{
query = _db.InAppNotifications
.IgnoreQueryFilters()
.Where(n => !n.IsDeleted && n.CompanyId == 0);
}
else
{
query = _db.InAppNotifications.AsQueryable();
}
var totalCount = all.Count;
var totalCount = await query.CountAsync();
var items = await query
.AsNoTracking()
var tz = ViewBag.CompanyTimeZone as string;
var items = all
.OrderByDescending(n => n.CreatedAt)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
@@ -58,7 +48,7 @@ public class InAppNotificationsController : Controller
n.ReadAt,
CreatedAt = n.CreatedAt
})
.ToListAsync();
.ToList();
ViewBag.TotalCount = totalCount;
ViewBag.PageNumber = pageNumber;
@@ -68,31 +58,22 @@ public class InAppNotificationsController : Controller
}
/// <summary>
/// AJAX endpoint that returns the 20 most recent notifications (read and unread) for the bell dropdown. The response includes a count of unread items so the badge can be updated without a separate round-trip.
/// AJAX endpoint that returns the 20 most recent notifications (read and unread) for the bell dropdown.
/// </summary>
[HttpGet]
public async Task<IActionResult> Recent()
{
IQueryable<PowderCoating.Core.Entities.InAppNotification> query;
if (_tenant.IsPlatformAdmin())
{
query = _db.InAppNotifications
.IgnoreQueryFilters()
.Where(n => !n.IsDeleted && n.CompanyId == 0);
}
else
{
query = _db.InAppNotifications.AsQueryable();
}
var all = _tenant.IsPlatformAdmin()
? (await _unitOfWork.InAppNotifications.FindAsync(
n => !n.IsDeleted && n.CompanyId == 0, ignoreQueryFilters: true)).ToList()
: (await _unitOfWork.InAppNotifications.GetAllAsync()).ToList();
var tz = ViewBag.CompanyTimeZone as string;
var items = await query
.AsNoTracking()
var items = all
.OrderByDescending(n => n.CreatedAt)
.Take(20)
.Select(n => new { n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.IsRead, n.CreatedAt })
.ToListAsync();
.ToList();
var unreadCount = items.Count(n => !n.IsRead);
return Json(new { count = unreadCount, items = items.Select(n => new {
@@ -102,34 +83,19 @@ public class InAppNotificationsController : Controller
}
/// <summary>
/// AJAX endpoint that returns only unread notifications (up to 20) plus an unread count for the bell badge. Used by the initial page load poll; after that the bell relies on Recent to show history.
/// AJAX endpoint that returns only unread notifications (up to 20) plus an unread count for the bell badge.
/// </summary>
[HttpGet]
public async Task<IActionResult> Unread()
{
IQueryable<PowderCoating.Core.Entities.InAppNotification> query;
if (_tenant.IsPlatformAdmin())
{
// SuperAdmins see only platform-level notifications (CompanyId = 0)
query = _db.InAppNotifications
.IgnoreQueryFilters()
.Where(n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead);
}
else
{
// Regular users see their company's notifications (global filter handles tenant isolation)
query = _db.InAppNotifications.Where(n => !n.IsRead);
}
var items = _tenant.IsPlatformAdmin()
? (await _unitOfWork.InAppNotifications.FindAsync(
n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true))
.OrderByDescending(n => n.CreatedAt).Take(20).ToList()
: (await _unitOfWork.InAppNotifications.FindAsync(n => !n.IsRead))
.OrderByDescending(n => n.CreatedAt).Take(20).ToList();
var tz = ViewBag.CompanyTimeZone as string;
var items = await query
.AsNoTracking()
.OrderByDescending(n => n.CreatedAt)
.Take(20)
.Select(n => new { n.Id, n.Title, n.Message, n.Link, n.NotificationType, n.CreatedAt })
.ToListAsync();
return Json(new { count = items.Count, items = items.Select(n => new {
n.Id, n.Title, n.Message, n.Link, n.NotificationType,
CreatedAt = n.CreatedAt.Tz(tz).ToString("MMM d, h:mm tt")
@@ -137,44 +103,38 @@ public class InAppNotificationsController : Controller
}
/// <summary>
/// Marks a single notification as read and records the timestamp. Tenant isolation is enforced manually for SuperAdmins (CompanyId 0 filter) because IgnoreQueryFilters bypasses the global company filter.
/// Marks a single notification as read. Tenant isolation is enforced manually for SuperAdmins (CompanyId 0 filter) because IgnoreQueryFilters bypasses the global company filter.
/// </summary>
[HttpPost]
public async Task<IActionResult> MarkRead(int id)
{
var notification = _tenant.IsPlatformAdmin()
? await _db.InAppNotifications.IgnoreQueryFilters().FirstOrDefaultAsync(n => n.Id == id && n.CompanyId == 0 && !n.IsDeleted)
: await _db.InAppNotifications.FirstOrDefaultAsync(n => n.Id == id);
? await _unitOfWork.InAppNotifications.FirstOrDefaultAsync(
n => n.Id == id && n.CompanyId == 0 && !n.IsDeleted, ignoreQueryFilters: true)
: await _unitOfWork.InAppNotifications.GetByIdAsync(id);
if (notification == null) return NotFound();
notification.IsRead = true;
notification.ReadAt = DateTime.UtcNow;
notification.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
return Json(new { success = true });
}
/// <summary>
/// Marks every unread notification as read for the current user's scope in a single SaveChanges call for efficiency. Returns the count of items marked so the UI can update the badge without refetching.
/// Marks every unread notification as read for the current user's scope in a single SaveChanges call.
/// </summary>
[HttpPost]
public async Task<IActionResult> MarkAllRead()
{
var now = DateTime.UtcNow;
List<PowderCoating.Core.Entities.InAppNotification> unread;
if (_tenant.IsPlatformAdmin())
{
unread = await _db.InAppNotifications.IgnoreQueryFilters()
.Where(n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead)
.ToListAsync();
}
else
{
unread = await _db.InAppNotifications.Where(n => !n.IsRead).ToListAsync();
}
var unread = _tenant.IsPlatformAdmin()
? (await _unitOfWork.InAppNotifications.FindAsync(
n => !n.IsDeleted && n.CompanyId == 0 && !n.IsRead, ignoreQueryFilters: true)).ToList()
: (await _unitOfWork.InAppNotifications.FindAsync(n => !n.IsRead)).ToList();
foreach (var n in unread)
{
@@ -183,7 +143,7 @@ public class InAppNotificationsController : Controller
n.UpdatedAt = now;
}
await _db.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
return Json(new { success = true, count = unread.Count });
}
}
@@ -11,8 +11,6 @@ using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using Microsoft.AspNetCore.Identity;
using PowderCoating.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using QRCoder;
using System.Drawing;
using System.Drawing.Imaging;
@@ -29,7 +27,6 @@ public class InventoryController : Controller
private readonly IMeasurementConversionService _measurementService;
private readonly IInventoryAiLookupService _aiLookupService;
private readonly ISubscriptionService _subscriptionService;
private readonly ApplicationDbContext _context;
private readonly UserManager<ApplicationUser> _userManager;
public InventoryController(
@@ -40,7 +37,6 @@ public class InventoryController : Controller
IMeasurementConversionService measurementService,
IInventoryAiLookupService aiLookupService,
ISubscriptionService subscriptionService,
ApplicationDbContext context,
UserManager<ApplicationUser> userManager)
{
_unitOfWork = unitOfWork;
@@ -50,7 +46,6 @@ public class InventoryController : Controller
_measurementService = measurementService;
_aiLookupService = aiLookupService;
_subscriptionService = subscriptionService;
_context = context;
_userManager = userManager;
}
@@ -166,12 +161,12 @@ public class InventoryController : Controller
TotalCount = totalCount
};
// Push stats and category list to the database rather than loading all rows
var statsBase = _context.InventoryItems;
ViewBag.Categories = await statsBase.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToListAsync();
ViewBag.StatsLowStockCount = await statsBase.CountAsync(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
ViewBag.StatsActiveCount = await statsBase.CountAsync(i => i.IsActive);
ViewBag.StatsTotalValue = await statsBase.SumAsync(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
// Load all items once to compute sidebar stats and category list in memory
var allItems = (await _unitOfWork.InventoryItems.GetAllAsync()).ToList();
ViewBag.Categories = allItems.Select(i => i.Category).Where(c => c != null).Distinct().OrderBy(c => c).ToList();
ViewBag.StatsLowStockCount = allItems.Count(i => i.IsActive && i.QuantityOnHand <= i.ReorderPoint);
ViewBag.StatsActiveCount = allItems.Count(i => i.IsActive);
ViewBag.StatsTotalValue = allItems.Sum(i => (decimal?)i.QuantityOnHand * i.UnitCost) ?? 0m;
// Set ViewBag for sorting and filters
ViewBag.SearchTerm = searchTerm;
@@ -560,20 +555,8 @@ public class InventoryController : Controller
if (string.IsNullOrEmpty(colorName) && string.IsNullOrEmpty(name))
return Json(new { success = true, photos = Array.Empty<object>(), totalCount = 0, page, pageSize });
IQueryable<JobPhoto> query = _context.JobPhotos
.AsNoTracking()
.Include(p => p.Job).ThenInclude(j => j.Customer)
.Where(p => !p.IsDeleted && p.Tags != null && p.Tags != "");
if (!string.IsNullOrEmpty(colorName) && !string.IsNullOrEmpty(name) && colorName != name)
query = query.Where(p => p.Tags!.ToLower().Contains(colorName) || p.Tags!.ToLower().Contains(name));
else if (!string.IsNullOrEmpty(colorName))
query = query.Where(p => p.Tags!.ToLower().Contains(colorName));
else
query = query.Where(p => p.Tags!.ToLower().Contains(name!));
// Exact tag match (avoid "Black" matching "Gloss Black Semi-Gloss")
var allMatches = await query.OrderByDescending(p => p.UploadedDate).ToListAsync();
var allMatches = await _unitOfWork.JobPhotos.GetTaggedPhotosAsync(colorName, name);
var searchTerms = new[] { colorName, name }
.Where(s => !string.IsNullOrEmpty(s))
@@ -620,15 +603,7 @@ public class InventoryController : Controller
{
try
{
var photos = await _context.JobPhotos
.AsNoTracking()
.Include(p => p.Job).ThenInclude(j => j.Customer)
.Include(p => p.Job).ThenInclude(j => j.JobItems).ThenInclude(ji => ji.Coats)
.Where(p => !p.IsDeleted &&
p.Job != null &&
p.Job.JobItems.Any(ji => ji.Coats.Any(c => c.InventoryItemId == id)))
.OrderByDescending(p => p.UploadedDate)
.ToListAsync();
var photos = await _unitOfWork.JobPhotos.GetPhotosByPowderItemAsync(id);
var totalCount = photos.Count;
var paged = photos
@@ -728,12 +703,12 @@ public class InventoryController : Controller
{
try
{
var allCoatings = await _context.InventoryItems
.AsNoTracking()
.Include(i => i.InventoryCategory)
.Where(i => !i.IsDeleted && i.InventoryCategory != null && i.InventoryCategory.IsCoating)
var allCoatings = (await _unitOfWork.InventoryItems.FindAsync(
i => i.InventoryCategory != null && i.InventoryCategory.IsCoating,
false,
i => i.InventoryCategory))
.OrderBy(i => i.Manufacturer).ThenBy(i => i.ColorName).ThenBy(i => i.Name)
.ToListAsync();
.ToList();
// Distinct manufacturer list for filter dropdown
ViewBag.Manufacturers = allCoatings
@@ -955,25 +930,39 @@ public class InventoryController : Controller
var userId = _userManager.GetUserId(User);
var myJobs = await _context.Jobs
.AsNoTracking()
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Where(j => !j.IsDeleted && !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId)
var myJobs = (await _unitOfWork.Jobs.FindAsync(
j => !j.JobStatus.IsTerminalStatus && j.AssignedUserId == userId,
false,
j => j.Customer,
j => j.JobStatus))
.OrderBy(j => j.JobNumber)
.Select(j => new ScanJobOption { Id = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer != null ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) : "No Customer" })
.ToListAsync();
.Select(j => new ScanJobOption
{
Id = j.Id,
JobNumber = j.JobNumber,
CustomerName = j.Customer != null
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
: "No Customer"
})
.ToList();
var myJobIds = myJobs.Select(j => j.Id).ToHashSet();
var otherJobs = await _context.Jobs
.AsNoTracking()
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Where(j => !j.IsDeleted && !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id))
var otherJobs = (await _unitOfWork.Jobs.FindAsync(
j => !j.JobStatus.IsTerminalStatus && !myJobIds.Contains(j.Id),
false,
j => j.Customer,
j => j.JobStatus))
.OrderByDescending(j => j.CreatedAt)
.Take(100)
.Select(j => new ScanJobOption { Id = j.Id, JobNumber = j.JobNumber, CustomerName = j.Customer != null ? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName) : "No Customer" })
.ToListAsync();
.Select(j => new ScanJobOption
{
Id = j.Id,
JobNumber = j.JobNumber,
CustomerName = j.Customer != null
? (j.Customer.CompanyName ?? j.Customer.ContactFirstName + " " + j.Customer.ContactLastName)
: "No Customer"
})
.ToList();
ViewBag.ItemDto = _mapper.Map<InventoryItemDto>(item);
ViewBag.MyJobs = myJobs;
@@ -1169,29 +1158,8 @@ public class InventoryController : Controller
.ToList();
// Build transactions query
var txnQuery = _context.InventoryTransactions
.AsNoTracking()
.Include(t => t.InventoryItem)
.Include(t => t.PurchaseOrder)
.Include(t => t.Job)
.Where(t => !t.IsDeleted);
if (inventoryItemId.HasValue)
txnQuery = txnQuery.Where(t => t.InventoryItemId == inventoryItemId.Value);
if (dateFrom.HasValue)
txnQuery = txnQuery.Where(t => t.TransactionDate >= dateFrom.Value);
if (dateTo.HasValue)
txnQuery = txnQuery.Where(t => t.TransactionDate < dateTo.Value.AddDays(1));
if (!string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<InventoryTransactionType>(typeFilter, out var parsedType))
txnQuery = txnQuery.Where(t => t.TransactionType == parsedType);
var transactions = await txnQuery
.OrderByDescending(t => t.TransactionDate)
.Take(500)
.ToListAsync();
InventoryTransactionType? parsedType = !string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<InventoryTransactionType>(typeFilter, out var pt) ? pt : null;
var transactions = await _unitOfWork.InventoryTransactions.GetForLedgerAsync(inventoryItemId, dateFrom, dateTo, parsedType);
// Resolve JobId for legacy JobUsage transactions that stored job number in Reference but not JobId
var unresolvedRefs = transactions
@@ -1202,36 +1170,12 @@ public class InventoryController : Controller
var jobRefLookup = new Dictionary<string, (int Id, string JobNumber)>();
if (unresolvedRefs.Any())
{
var matched = await _context.Jobs
.AsNoTracking()
.Where(j => unresolvedRefs.Contains(j.JobNumber))
.Select(j => new { j.Id, j.JobNumber })
.ToListAsync();
var matched = await _unitOfWork.Jobs.FindAsync(j => unresolvedRefs.Contains(j.JobNumber));
jobRefLookup = matched.ToDictionary(j => j.JobNumber, j => (j.Id, j.JobNumber));
}
// Build powder usage logs query
var usageQuery = _context.PowderUsageLogs
.AsNoTracking()
.Include(u => u.Job).ThenInclude(j => j.Customer)
.Include(u => u.InventoryItem)
.Include(u => u.JobItemCoat)
.Where(u => !u.IsDeleted);
if (inventoryItemId.HasValue)
usageQuery = usageQuery.Where(u => u.InventoryItemId == inventoryItemId.Value);
if (dateFrom.HasValue)
usageQuery = usageQuery.Where(u => u.RecordedAt >= dateFrom.Value);
if (dateTo.HasValue)
usageQuery = usageQuery.Where(u => u.RecordedAt < dateTo.Value.AddDays(1));
// Exclude JobUsage type from transactions when showing usage tab (avoid double-counting display)
var usageLogs = await usageQuery
.OrderByDescending(u => u.RecordedAt)
.Take(500)
.ToListAsync();
// Powder usage logs with dynamic date + item filters
var usageLogs = await _unitOfWork.PowderUsageLogs.GetForLedgerAsync(inventoryItemId, dateFrom, dateTo);
InventoryItem? selectedItem = null;
if (inventoryItemId.HasValue)
@@ -1,12 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using PowderCoating.Shared.Constants;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.Controllers;
@@ -23,35 +20,28 @@ public class JobTemplatesController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly ApplicationDbContext _context;
public JobTemplatesController(
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
ApplicationDbContext context)
ITenantContext tenantContext)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_context = context;
}
/// <summary>
/// Displays all non-deleted job templates for the current company, ordered by name, with their
/// linked customer and item counts. Uses the direct <c>_context</c> query (bypassing
/// <c>IUnitOfWork</c>) to leverage EF Core's filtered includes (<c>.Include(t => t.Items)</c>)
/// which are not exposed through the generic repository pattern.
/// linked customer and item counts. Multi-tenancy and soft-delete scoping are handled by global
/// query filters; the typed repository provides the ThenInclude chain for items.
/// </summary>
public async Task<IActionResult> Index()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var templates = await _context.JobTemplates
.Include(t => t.Customer)
.Include(t => t.Items)
.Where(t => !t.IsDeleted && t.CompanyId == companyId)
.OrderBy(t => t.Name)
.ToListAsync();
var templates = await _unitOfWork.JobTemplates.GetAllAsync(
false,
t => t.Customer,
t => t.Items);
templates = templates.OrderBy(t => t.Name).ToList();
return View(templates);
}
@@ -59,22 +49,12 @@ public class JobTemplatesController : Controller
/// Shows the full template detail including all non-deleted items, each with their coats
/// (including the linked inventory item for color/powder info) and prep services (including the
/// prep service entity for the service name). Soft-deleted items, coats, and prep services are
/// excluded via EF filtered includes so the view reflects the current active configuration.
/// excluded via filtered includes in the typed repository.
/// </summary>
public async Task<IActionResult> Details(int id)
{
var template = await _context.JobTemplates
.Include(t => t.Customer)
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.ThenInclude(c => c.InventoryItem)
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
.ThenInclude(p => p.PrepService)
.FirstOrDefaultAsync(t => t.Id == id && !t.IsDeleted);
var template = await _unitOfWork.JobTemplates.LoadForDetailsAsync(id);
if (template == null) return NotFound();
return View(template);
}
@@ -85,9 +65,7 @@ public class JobTemplatesController : Controller
/// </summary>
public async Task<IActionResult> Edit(int id)
{
var template = await _context.JobTemplates
.FirstOrDefaultAsync(t => t.Id == id && !t.IsDeleted);
var template = await _unitOfWork.JobTemplates.GetByIdAsync(id);
if (template == null) return NotFound();
await PopulateCustomerDropdown(template.CustomerId);
@@ -150,12 +128,7 @@ public class JobTemplatesController : Controller
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var job = await _context.Jobs
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
.FirstOrDefaultAsync(j => j.Id == jobId && !j.IsDeleted);
var job = await _unitOfWork.Jobs.LoadForTemplateSnapshotAsync(jobId);
if (job == null) return NotFound();
@@ -254,18 +227,7 @@ public class JobTemplatesController : Controller
[HttpGet]
public async Task<IActionResult> GetTemplatesJson()
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var templates = await _context.JobTemplates
.Include(t => t.Customer)
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
.Include(t => t.Items.Where(i => !i.IsDeleted))
.ThenInclude(i => i.PrepServices.Where(p => !p.IsDeleted))
.ThenInclude(p => p.PrepService)
.Where(t => !t.IsDeleted && t.CompanyId == companyId && t.IsActive)
.OrderBy(t => t.Name)
.ToListAsync();
var templates = await _unitOfWork.JobTemplates.GetAllActiveWithFullIncludesAsync();
var result = templates.Select(t => new
{
@@ -9,7 +9,6 @@ using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Hubs;
@@ -19,7 +18,6 @@ namespace PowderCoating.Web.Controllers;
public class JobsPriorityController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ApplicationDbContext _context;
private readonly ILogger<JobsPriorityController> _logger;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ITenantContext _tenantContext;
@@ -27,14 +25,12 @@ public class JobsPriorityController : Controller
public JobsPriorityController(
IUnitOfWork unitOfWork,
ApplicationDbContext context,
ILogger<JobsPriorityController> logger,
UserManager<ApplicationUser> userManager,
ITenantContext tenantContext,
IHubContext<ShopHub> shopHub)
{
_unitOfWork = unitOfWork;
_context = context;
_logger = logger;
_userManager = userManager;
_tenantContext = tenantContext;
@@ -63,13 +59,7 @@ public class JobsPriorityController : Controller
var today = date?.Date ?? DateTime.Today;
// Get all jobs scheduled for today with related data
var jobs = await _context.Jobs
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Include(j => j.JobPriority)
.Include(j => j.AssignedUser)
.Where(j => j.ScheduledDate.HasValue && j.ScheduledDate.Value.Date == today && !j.IsDeleted)
.ToListAsync();
var jobs = await _unitOfWork.Jobs.GetScheduledJobsForDateAsync(today);
// Get existing priority records for today
var existingPriorities = await _unitOfWork.JobDailyPriorities
@@ -108,15 +98,14 @@ public class JobsPriorityController : Controller
.ToListAsync();
// Get maintenance records scheduled for today (Scheduled or InProgress)
var maintenanceItems = await _context.MaintenanceRecords
.Include(m => m.Equipment)
.Include(m => m.AssignedUser)
.Where(m => m.ScheduledDate.Date == today && !m.IsDeleted &&
(m.Status == MaintenanceStatus.Scheduled ||
m.Status == MaintenanceStatus.InProgress))
var maintenanceItems = (await _unitOfWork.MaintenanceRecords.FindAsync(
m => m.ScheduledDate.Date == today &&
(m.Status == MaintenanceStatus.Scheduled || m.Status == MaintenanceStatus.InProgress),
false,
m => m.Equipment, m => m.AssignedUser))
.OrderByDescending(m => (int)m.Priority)
.ThenBy(m => m.ScheduledDate)
.ToListAsync();
.ToList();
ViewBag.ScheduledDate = today;
ViewBag.MaintenanceItems = maintenanceItems;
@@ -378,14 +367,10 @@ public class JobsPriorityController : Controller
{
try
{
var record = await _context.MaintenanceRecords.FindAsync(maintenanceId);
var record = await _unitOfWork.MaintenanceRecords.GetByIdAsync(maintenanceId);
if (record == null || record.IsDeleted)
return Json(new { success = false, message = "Maintenance record not found" });
// FindAsync bypasses global query filters — verify company ownership explicitly
if (!_tenantContext.IsSuperAdmin() && record.CompanyId != _tenantContext.GetCurrentCompanyId())
return Json(new { success = false, message = "Access denied." });
string workerName = "Unassigned";
if (!string.IsNullOrEmpty(workerId))
{
@@ -402,7 +387,7 @@ public class JobsPriorityController : Controller
}
record.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
return Json(new { success = true, message = "Worker assigned successfully", workerName });
}
@@ -1,10 +1,9 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Notification;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -14,31 +13,23 @@ namespace PowderCoating.Web.Controllers;
/// <c>CanManageJobs</c> policy (Managers and above within a company).
/// The platform-wide equivalent for SuperAdmins lives in
/// <see cref="PlatformNotificationsController"/>.
/// Uses <see cref="ApplicationDbContext"/> directly to enable LINQ projections
/// that avoid loading full message bodies into memory on the list page.
/// </summary>
[Authorize(Policy = AppConstants.Policies.CanManageJobs)]
public class NotificationLogsController : Controller
{
private readonly ApplicationDbContext _context;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<NotificationLogsController> _logger;
public NotificationLogsController(ApplicationDbContext context, ILogger<NotificationLogsController> logger)
public NotificationLogsController(IUnitOfWork unitOfWork, ILogger<NotificationLogsController> logger)
{
_context = context;
_unitOfWork = unitOfWork;
_logger = logger;
}
/// <summary>
/// Displays a paginated, filterable list of notification log entries for the
/// current company. Supports filtering by free-text search, channel (Email/SMS),
/// delivery status, notification type, and an optional job context.
/// <para>
/// The <c>pageSize</c> is validated against an allowlist (10/25/50/100) and
/// defaults to 25 to prevent callers from requesting arbitrarily large result sets.
/// Sorting defaults to most-recent-first (<c>SentAt DESC</c>) because operators
/// almost always want to see the latest delivery attempts first.
/// </para>
/// Displays a paginated, filterable list of notification log entries for the current company.
/// Supports filtering by free-text search, channel, delivery status, notification type, and
/// an optional job context. Page size is validated against an allowlist (10/25/50/100).
/// </summary>
// GET: /NotificationLogs
public async Task<IActionResult> Index(
@@ -55,74 +46,35 @@ public class NotificationLogsController : Controller
pageNumber = Math.Max(1, pageNumber);
pageSize = pageSize is 10 or 25 or 50 or 100 ? pageSize : 25;
var query = _context.NotificationLogs
.AsNoTracking()
.Include(n => n.Customer)
.Include(n => n.Job)
.Include(n => n.Quote)
.AsQueryable();
NotificationChannel? channel = Enum.TryParse<NotificationChannel>(channelFilter, out var ch) ? ch : null;
NotificationStatus? status = Enum.TryParse<NotificationStatus>(statusFilter, out var st) ? st : null;
NotificationType? type = Enum.TryParse<NotificationType>(typeFilter, out var ty) ? ty : null;
// Filters
if (jobId.HasValue)
query = query.Where(n => n.JobId == jobId.Value);
var (logs, totalCount) = await _unitOfWork.NotificationLogs.GetPagedFilteredAsync(
pageNumber, pageSize, searchTerm, channel, status, type, jobId,
sortColumn ?? "SentAt", sortDirection);
if (!string.IsNullOrWhiteSpace(searchTerm))
var items = logs.Select(n => new NotificationLogDto
{
var search = searchTerm.ToLower();
query = query.Where(n =>
n.RecipientName.ToLower().Contains(search) ||
n.Recipient.ToLower().Contains(search) ||
(n.Subject != null && n.Subject.ToLower().Contains(search)) ||
(n.Job != null && n.Job.JobNumber.ToLower().Contains(search)) ||
(n.Quote != null && n.Quote.QuoteNumber.ToLower().Contains(search)));
}
if (!string.IsNullOrWhiteSpace(channelFilter) && Enum.TryParse<NotificationChannel>(channelFilter, out var channel))
query = query.Where(n => n.Channel == channel);
if (!string.IsNullOrWhiteSpace(statusFilter) && Enum.TryParse<NotificationStatus>(statusFilter, out var status))
query = query.Where(n => n.Status == status);
if (!string.IsNullOrWhiteSpace(typeFilter) && Enum.TryParse<NotificationType>(typeFilter, out var type))
query = query.Where(n => n.NotificationType == type);
// Sorting
query = (sortColumn ?? "SentAt") switch
{
"RecipientName" => sortDirection == "asc" ? query.OrderBy(n => n.RecipientName) : query.OrderByDescending(n => n.RecipientName),
"Channel" => sortDirection == "asc" ? query.OrderBy(n => n.Channel) : query.OrderByDescending(n => n.Channel),
"Status" => sortDirection == "asc" ? query.OrderBy(n => n.Status) : query.OrderByDescending(n => n.Status),
"Type" => sortDirection == "asc" ? query.OrderBy(n => n.NotificationType) : query.OrderByDescending(n => n.NotificationType),
_ => sortDirection == "asc" ? query.OrderBy(n => n.SentAt) : query.OrderByDescending(n => n.SentAt)
};
var totalCount = await query.CountAsync();
var items = await query
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.Select(n => new NotificationLogDto
{
Id = n.Id,
Channel = n.Channel,
NotificationType = n.NotificationType,
Status = n.Status,
RecipientName = n.RecipientName,
Recipient = n.Recipient,
Subject = n.Subject,
Message = n.Message,
ErrorMessage = n.ErrorMessage,
SentAt = n.SentAt,
CustomerId = n.CustomerId,
JobId = n.JobId,
QuoteId = n.QuoteId,
JobNumber = n.Job != null ? n.Job.JobNumber : null,
QuoteNumber = n.Quote != null ? n.Quote.QuoteNumber : null,
CustomerName = n.Customer != null
? (n.Customer.CompanyName ?? $"{n.Customer.ContactFirstName} {n.Customer.ContactLastName}".Trim())
: null
})
.ToListAsync();
Id = n.Id,
Channel = n.Channel,
NotificationType = n.NotificationType,
Status = n.Status,
RecipientName = n.RecipientName,
Recipient = n.Recipient,
Subject = n.Subject,
Message = n.Message,
ErrorMessage = n.ErrorMessage,
SentAt = n.SentAt,
CustomerId = n.CustomerId,
JobId = n.JobId,
QuoteId = n.QuoteId,
JobNumber = n.Job?.JobNumber,
QuoteNumber = n.Quote?.QuoteNumber,
CustomerName = n.Customer != null
? (n.Customer.CompanyName ?? $"{n.Customer.ContactFirstName} {n.Customer.ContactLastName}".Trim())
: null
}).ToList();
var pagedResult = new PagedResult<NotificationLogDto>
{
@@ -144,22 +96,17 @@ public class NotificationLogsController : Controller
}
/// <summary>
/// Displays the full details of a single notification log entry including the
/// complete message body and any error message, which are omitted from the list
/// view to keep response sizes small. Loads related Customer, Job, and Quote
/// navigation properties to resolve display names.
/// Displays the full details of a single notification log entry including the complete message
/// body and any error message, which are omitted from the list view. Loads related Customer,
/// Job, and Quote navigation properties to resolve display names.
/// </summary>
// GET: /NotificationLogs/Details/5
public async Task<IActionResult> Details(int id)
{
try
{
var log = await _context.NotificationLogs
.AsNoTracking()
.Include(n => n.Customer)
.Include(n => n.Job)
.Include(n => n.Quote)
.FirstOrDefaultAsync(n => n.Id == id);
var log = await _unitOfWork.NotificationLogs.GetByIdAsync(
id, false, n => n.Customer, n => n.Job, n => n.Quote);
if (log == null) return NotFound();
@@ -20,6 +20,7 @@ namespace PowderCoating.Web.Controllers;
/// matches automatically on localhost, dev, staging, and production without any
/// environment-specific configuration.
/// </summary>
// Intentional exception: WebAuthn/FIDO2 identity infrastructure. UserPasskeys is an ASP.NET Identity concern not exposed through IUnitOfWork; the anonymous login path has no tenant context; FIDO2 async callbacks capture _db by closure. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
[Route("[controller]/[action]")]
public class PasskeyController : Controller
{
@@ -1,8 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -10,28 +9,21 @@ namespace PowderCoating.Web.Controllers;
/// <summary>
/// SuperAdmin-only cross-company notification log viewer.
/// The company-scoped version lives in NotificationLogsController (CanManageJobs policy).
/// Uses IgnoreQueryFilters throughout to bypass the multi-tenancy global filter and see
/// logs from all companies simultaneously.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class PlatformNotificationsController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IUnitOfWork _unitOfWork;
public PlatformNotificationsController(ApplicationDbContext db) => _db = db;
public PlatformNotificationsController(IUnitOfWork unitOfWork) => _unitOfWork = unitOfWork;
/// <summary>
/// Renders a paginated, filterable cross-company notification log view visible
/// only to SuperAdmins. Unlike the tenant-scoped
/// <see cref="NotificationLogsController.Index"/>, this action uses
/// <c>IgnoreQueryFilters()</c> to bypass the multi-tenancy global filter and
/// see logs from all companies simultaneously.
/// <para>
/// Company names are resolved in a single follow-up query after paging (not via
/// a JOIN) because company data lives outside the notification-log table's usual
/// query scope, and a separate query keeps the main sort/filter logic clean.
/// Summary counts (total, failed, last 24 h) are computed as independent queries
/// against the full unfiltered dataset so the summary reflects platform-wide
/// health regardless of the active filters.
/// </para>
/// Renders a paginated, filterable cross-company notification log view visible only to SuperAdmins.
/// All filter/sort/paging is applied in-memory after a single filtered repository fetch.
/// Company names are resolved in a follow-up batch query keyed on the page's distinct company IDs.
/// Summary counts reflect the full unfiltered dataset so the health cards are accurate regardless of active filters.
/// </summary>
public async Task<IActionResult> Index(
int? companyId,
@@ -47,41 +39,38 @@ public class PlatformNotificationsController : Controller
pageSize = pageSize is 25 or 50 or 100 ? pageSize : 50;
page = Math.Max(1, page);
var query = _db.NotificationLogs
.AsNoTracking()
.IgnoreQueryFilters()
.Where(n => !n.IsDeleted)
.AsQueryable();
// Load all non-deleted logs across all tenants, then filter in-memory.
var all = (await _unitOfWork.NotificationLogs.FindAsync(n => !n.IsDeleted, ignoreQueryFilters: true))
.AsEnumerable();
if (companyId.HasValue)
query = query.Where(n => n.CompanyId == companyId);
all = all.Where(n => n.CompanyId == companyId);
if (!string.IsNullOrWhiteSpace(type) && Enum.TryParse<NotificationType>(type, out var typeEnum))
query = query.Where(n => n.NotificationType == typeEnum);
all = all.Where(n => n.NotificationType == typeEnum);
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<NotificationStatus>(status, out var statusEnum))
query = query.Where(n => n.Status == statusEnum);
all = all.Where(n => n.Status == statusEnum);
if (!string.IsNullOrWhiteSpace(channel) && Enum.TryParse<NotificationChannel>(channel, out var channelEnum))
query = query.Where(n => n.Channel == channelEnum);
all = all.Where(n => n.Channel == channelEnum);
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(n =>
n.RecipientName.Contains(search) ||
n.Recipient.Contains(search) ||
(n.Subject != null && n.Subject.Contains(search)));
all = all.Where(n =>
n.RecipientName.Contains(search, StringComparison.OrdinalIgnoreCase) ||
n.Recipient.Contains(search, StringComparison.OrdinalIgnoreCase) ||
(n.Subject != null && n.Subject.Contains(search, StringComparison.OrdinalIgnoreCase)));
if (from.HasValue)
query = query.Where(n => n.SentAt >= from.Value.Date);
all = all.Where(n => n.SentAt >= from.Value.Date);
if (to.HasValue)
query = query.Where(n => n.SentAt < to.Value.Date.AddDays(1));
all = all.Where(n => n.SentAt < to.Value.Date.AddDays(1));
query = query.OrderByDescending(n => n.SentAt);
var filtered = all.OrderByDescending(n => n.SentAt).ToList();
var totalCount = filtered.Count;
var totalCount = await query.CountAsync();
var items = await query
var items = filtered
.Skip((page - 1) * pageSize)
.Take(pageSize)
.Select(n => new PlatformNotificationRow
@@ -97,30 +86,26 @@ public class PlatformNotificationsController : Controller
ErrorMessage = n.ErrorMessage,
SentAt = n.SentAt
})
.ToListAsync();
.ToList();
// Resolve company names for the rows in one query
// Resolve company names for the page's rows in one query
var cids = items.Select(i => i.CompanyId).Distinct().ToList();
var companyNames = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => cids.Contains(c.Id))
.ToDictionaryAsync(c => c.Id, c => c.CompanyName);
var companyNames = (await _unitOfWork.Companies.FindAsync(c => cids.Contains(c.Id), ignoreQueryFilters: true))
.ToDictionary(c => c.Id, c => c.CompanyName);
foreach (var item in items)
item.CompanyName = companyNames.GetValueOrDefault(item.CompanyId);
// Sidebar company list for filter dropdown
var companies = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => !c.IsDeleted)
var companies = (await _unitOfWork.Companies.FindAsync(c => !c.IsDeleted, ignoreQueryFilters: true))
.OrderBy(c => c.CompanyName)
.Select(c => new { c.Id, c.CompanyName })
.ToListAsync();
.ToList();
// Summary counts
ViewBag.TotalCount = await _db.NotificationLogs.IgnoreQueryFilters().CountAsync(n => !n.IsDeleted);
ViewBag.FailedCount = await _db.NotificationLogs.IgnoreQueryFilters()
.CountAsync(n => !n.IsDeleted && n.Status == NotificationStatus.Failed);
ViewBag.Last24hCount = await _db.NotificationLogs.IgnoreQueryFilters()
.CountAsync(n => !n.IsDeleted && n.SentAt >= DateTime.UtcNow.AddHours(-24));
// Summary counts from the unfiltered dataset
var allLogs = await _unitOfWork.NotificationLogs.FindAsync(n => !n.IsDeleted, ignoreQueryFilters: true);
ViewBag.TotalCount = allLogs.Count();
ViewBag.FailedCount = allLogs.Count(n => n.Status == NotificationStatus.Failed);
ViewBag.Last24hCount = allLogs.Count(n => n.SentAt >= DateTime.UtcNow.AddHours(-24));
ViewBag.Companies = companies;
ViewBag.CompanyIdFilter = companyId;
@@ -139,23 +124,17 @@ public class PlatformNotificationsController : Controller
}
/// <summary>
/// Returns the full notification log entry for the given <paramref name="id"/>,
/// including the complete message body and error details. The owning company name
/// is resolved separately (rather than via navigation property) because company
/// records are in a different query-filter context.
/// Returns the full notification log entry for the given id, including the complete message body and error details.
/// </summary>
public async Task<IActionResult> Details(int id)
{
var log = await _db.NotificationLogs.AsNoTracking().IgnoreQueryFilters()
.FirstOrDefaultAsync(n => n.Id == id);
var log = await _unitOfWork.NotificationLogs.FirstOrDefaultAsync(n => n.Id == id, ignoreQueryFilters: true);
if (log == null) return NotFound();
string? companyName = null;
if (log.CompanyId > 0)
companyName = await _db.Companies.AsNoTracking().IgnoreQueryFilters()
.Where(c => c.Id == log.CompanyId)
.Select(c => c.CompanyName)
.FirstOrDefaultAsync();
companyName = (await _unitOfWork.Companies.FirstOrDefaultAsync(
c => c.Id == log.CompanyId, ignoreQueryFilters: true))?.CompanyName;
ViewBag.CompanyName = companyName;
return View(log);
@@ -1,11 +1,10 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.Hubs;
using PowderCoating.Web.ViewModels;
@@ -22,7 +21,7 @@ namespace PowderCoating.Web.Controllers;
[EnableRateLimiting(AppConstants.RateLimitPolicies.Public)]
public class QuoteApprovalController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IUnitOfWork _unitOfWork;
private readonly INotificationService _notifications;
private readonly IInAppNotificationService _inApp;
private readonly IStripeConnectService _stripeConnect;
@@ -31,7 +30,7 @@ public class QuoteApprovalController : Controller
private readonly IHubContext<NotificationHub> _hub;
public QuoteApprovalController(
ApplicationDbContext db,
IUnitOfWork unitOfWork,
INotificationService notifications,
IInAppNotificationService inApp,
IStripeConnectService stripeConnect,
@@ -39,7 +38,7 @@ public class QuoteApprovalController : Controller
IConfiguration configuration,
IHubContext<NotificationHub> hub)
{
_db = db;
_unitOfWork = unitOfWork;
_notifications = notifications;
_inApp = inApp;
_stripeConnect = stripeConnect;
@@ -50,11 +49,8 @@ public class QuoteApprovalController : Controller
/// <summary>
/// Renders the main customer-facing approval page showing quote line items, totals, and
/// Approve/Decline buttons. The <c>[ActionName("View")]</c> attribute overrides the method name
/// so the route is <c>/quote-approval/{token}</c> without exposing the internal method name.
/// All token validation (expiry, already-acted) is centralised in <see cref="ValidateTokenAsync"/>.
/// Approve/Decline buttons.
/// </summary>
// GET /quote-approval/{token}
[HttpGet("{token}")]
[ActionName("View")]
public async Task<IActionResult> ShowApprovalPage(string token)
@@ -67,19 +63,15 @@ public class QuoteApprovalController : Controller
}
/// <summary>
/// Shows the contact-details confirmation step for prospect (non-customer) quotes. Prospects
/// have no <c>CustomerId</c> on the quote, so we collect their contact information before
/// finalising the approval. Quotes already linked to a customer skip this step and go directly
/// to <see cref="ApproveInternal"/> — a customer's details are already on file.
/// Shows the contact-details confirmation step for prospect (non-customer) quotes.
/// Quotes already linked to a customer skip this step and go directly to ApproveInternal.
/// </summary>
// GET /quote-approval/{token}/confirm-details
[HttpGet("{token}/confirm-details")]
public async Task<IActionResult> ConfirmDetails(string token)
{
var (quote, errorResult) = await ValidateTokenAsync(token);
if (errorResult != null) return errorResult;
// Only prospects need to fill in details; linked customers go straight through.
if (quote!.CustomerId.HasValue)
return await ApproveInternal(token, quote);
@@ -88,13 +80,10 @@ public class QuoteApprovalController : Controller
}
/// <summary>
/// Handles the contact-detail form submission from prospects. Requires at minimum a name and
/// either an email or phone number — this minimal validation mirrors the fields required to
/// later convert the quote to a customer record. On success, persists the prospect contact
/// details to the quote and delegates to <see cref="ApproveInternal"/> so the approval and
/// audit trail are written in a single path shared with the customer flow.
/// Handles the contact-detail form submission from prospects. Persists contact details to the
/// quote then delegates to ApproveInternal so the approval and audit trail are written in a single
/// path shared with the customer flow.
/// </summary>
// POST /quote-approval/{token}/confirm-details
[HttpPost("{token}/confirm-details")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SubmitDetails(string token,
@@ -110,7 +99,6 @@ public class QuoteApprovalController : Controller
var (quote, errorResult) = await ValidateTokenAsync(token);
if (errorResult != null) return errorResult;
// Require at minimum a name and either email or phone
if (string.IsNullOrWhiteSpace(contactName) ||
(string.IsNullOrWhiteSpace(email) && string.IsNullOrWhiteSpace(phone)))
{
@@ -127,7 +115,6 @@ public class QuoteApprovalController : Controller
return base.View("ConfirmDetails", model);
}
// Update prospect fields on the quote
quote!.ProspectContactName = contactName?.Trim();
quote.ProspectEmail = email?.Trim();
quote.ProspectPhone = phone?.Trim();
@@ -137,18 +124,15 @@ public class QuoteApprovalController : Controller
quote.ProspectState = state?.Trim();
quote.ProspectZipCode = zipCode?.Trim();
quote.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
return await ApproveInternal(token, quote);
}
/// <summary>
/// Entry point for the Approve button on the approval page. For prospect quotes, redirects to
/// the contact-details form rather than approving immediately. For customer-linked quotes,
/// delegates directly to <see cref="ApproveInternal"/>. This two-path design keeps the main
/// approval page simple (one Approve button) while still collecting required prospect data.
/// Entry point for the Approve button. For prospect quotes, redirects to the contact-details form.
/// For customer-linked quotes, delegates directly to ApproveInternal.
/// </summary>
// POST /quote-approval/{token}/approve
[HttpPost("{token}/approve")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Approve(string token)
@@ -156,7 +140,6 @@ public class QuoteApprovalController : Controller
var (quote, errorResult) = await ValidateTokenAsync(token);
if (errorResult != null) return errorResult;
// Prospect quotes collect contact details first
if (!quote!.CustomerId.HasValue)
{
var model = await BuildViewModelAsync(quote, token);
@@ -167,22 +150,15 @@ public class QuoteApprovalController : Controller
}
/// <summary>
/// Core approval logic shared by both the customer path (<see cref="Approve"/>) and the prospect
/// path (<see cref="SubmitDetails"/>). Sets the quote status to the company's designated
/// <c>IsApprovedStatus</c> lookup entry, records <c>ApprovalTokenUsedAt</c> to prevent token
/// reuse, clears any prior decline reason (customers can re-approve after declining), and writes
/// a <c>QuoteChangeHistory</c> audit entry with <c>ChangedByUserId = null</c> to indicate the
/// action was performed by the customer rather than a staff member. After persisting, a SignalR
/// push notifies logged-in staff in real time. If the quote requires a deposit and the company
/// has Stripe Connect active, a time-limited deposit payment link token (7 days) is generated
/// and saved so the confirmation page can surface a "Pay deposit online" button. Prospect quotes
/// skip the deposit link because there is no <c>Customer</c> row to attach a <c>Deposit</c> to.
/// Core approval logic shared by the customer path and the prospect path. Sets the quote status,
/// records ApprovalTokenUsedAt, writes an audit entry, pushes a SignalR notification to staff,
/// and optionally generates a deposit payment link token if Stripe Connect is active.
/// </summary>
private async Task<IActionResult> ApproveInternal(string token, Quote quote)
{
var approvedStatus = await _db.QuoteStatusLookups
.IgnoreQueryFilters()
.FirstOrDefaultAsync(s => s.CompanyId == quote.CompanyId && s.IsApprovedStatus && !s.IsDeleted);
var approvedStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(
s => s.CompanyId == quote.CompanyId && s.IsApprovedStatus && !s.IsDeleted,
ignoreQueryFilters: true);
var oldStatusName = quote.QuoteStatus?.DisplayName ?? "Unknown";
@@ -199,9 +175,9 @@ public class QuoteApprovalController : Controller
quote.DeclineReason = null;
}
await _db.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
var approveEntry = new PowderCoating.Core.Entities.QuoteChangeHistory
var approveEntry = new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = null,
@@ -215,8 +191,8 @@ public class QuoteApprovalController : Controller
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow
};
_db.QuoteChangeHistories.Add(approveEntry);
await _db.SaveChangesAsync();
await _unitOfWork.QuoteChangeHistories.AddAsync(approveEntry);
await _unitOfWork.CompleteAsync();
await _hub.Clients.Group($"company-{quote.CompanyId}").SendAsync("QuoteActedByCustomer", new
{
@@ -246,20 +222,19 @@ public class QuoteApprovalController : Controller
_logger.LogWarning(ex, "Failed to send approval notification for quote {QuoteId}", quote.Id);
}
// Generate deposit payment link if this quote requires a deposit and the company has Stripe Connect
if (quote.RequiresDeposit
&& quote.DepositPercent > 0
&& quote.CustomerId.HasValue // prospects don't have a Customer row to attach a Deposit to
&& quote.CustomerId.HasValue
&& quote.DepositAmountPaid <= 0)
{
var company = await _db.Companies.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId && !c.IsDeleted);
var company = await _unitOfWork.Companies.FirstOrDefaultAsync(
c => c.Id == quote.CompanyId && !c.IsDeleted);
if (company?.StripeConnectStatus == StripeConnectStatus.Active)
{
quote.DepositPaymentLinkToken = Guid.NewGuid().ToString("N");
quote.DepositPaymentLinkExpiresAt = DateTime.UtcNow.AddDays(7);
await _db.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
}
}
@@ -267,15 +242,9 @@ public class QuoteApprovalController : Controller
}
/// <summary>
/// Handles the customer declining a quote. Requires a non-empty reason (enforced with a
/// field-level error, not ModelState) so staff always know why a quote was rejected. The
/// decline reason is truncated to 1000 characters before persistence to avoid oversized inputs.
/// The customer's IP is recorded (<c>DeclinedByIp</c>) for audit purposes. A SignalR event and
/// in-app notification are pushed to staff. The token is marked used (<c>ApprovalTokenUsedAt</c>)
/// so the customer cannot approve after declining via the same link — they must request a new
/// link from the shop.
/// Handles the customer declining a quote. Records the decline reason, marks the token used,
/// pushes a SignalR notification and in-app notification to staff.
/// </summary>
// POST /quote-approval/{token}/decline
[HttpPost("{token}/decline")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Decline(string token, [FromForm] string reason)
@@ -290,13 +259,12 @@ public class QuoteApprovalController : Controller
return base.View("ApprovalPage", model);
}
// Find the rejected status for this company (by flag, or fall back to StatusCode)
var rejectedStatus = await _db.QuoteStatusLookups
.IgnoreQueryFilters()
.FirstOrDefaultAsync(s => s.CompanyId == quote!.CompanyId && s.IsRejectedStatus && !s.IsDeleted)
?? await _db.QuoteStatusLookups
.IgnoreQueryFilters()
.FirstOrDefaultAsync(s => s.CompanyId == quote!.CompanyId && s.StatusCode == "REJECTED" && !s.IsDeleted);
var rejectedStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(
s => s.CompanyId == quote!.CompanyId && s.IsRejectedStatus && !s.IsDeleted,
ignoreQueryFilters: true)
?? await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(
s => s.CompanyId == quote!.CompanyId && s.StatusCode == "REJECTED" && !s.IsDeleted,
ignoreQueryFilters: true);
var oldDeclineStatusName = quote!.QuoteStatus?.DisplayName ?? "Unknown";
@@ -308,10 +276,9 @@ public class QuoteApprovalController : Controller
quote.DeclinedByIp = HttpContext.Connection.RemoteIpAddress?.ToString();
quote.ApprovalTokenUsedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
// Audit log — decline
var declineEntry = new PowderCoating.Core.Entities.QuoteChangeHistory
var declineEntry = new QuoteChangeHistory
{
QuoteId = quote.Id,
ChangedByUserId = null,
@@ -323,10 +290,9 @@ public class QuoteApprovalController : Controller
CompanyId = quote.CompanyId,
CreatedAt = DateTime.UtcNow
};
_db.QuoteChangeHistories.Add(declineEntry);
await _db.SaveChangesAsync();
await _unitOfWork.QuoteChangeHistories.AddAsync(declineEntry);
await _unitOfWork.CompleteAsync();
// Push real-time toast to any logged-in company users
await _hub.Clients.Group($"company-{quote.CompanyId}").SendAsync("QuoteActedByCustomer", new
{
approved = false,
@@ -359,40 +325,30 @@ public class QuoteApprovalController : Controller
}
/// <summary>
/// Renders the post-action confirmation page shown after the customer approves or declines.
/// Does NOT re-validate the token against the used/expired guards in
/// <see cref="ValidateTokenAsync"/> — by this point the token is already marked used, so those
/// guards would incorrectly redirect to AlreadyActed. Instead, it only checks that the quote
/// exists. The <c>action</c> query-string value ("approved" / "declined") is set by the
/// redirect from <see cref="ApproveInternal"/> or <see cref="Decline"/> and drives the
/// confirmation message in the view. The deposit link token is only surfaced if it is present
/// and not yet expired.
/// Renders the post-action confirmation page. Does NOT re-validate the token against the
/// used/expired guards — by this point the token is already marked used.
/// </summary>
// GET /quote-approval/{token}/confirmation
[HttpGet("{token}/confirmation")]
public async Task<IActionResult> Confirmation(string token, [FromQuery] string action)
{
var quote = await _db.Quotes
.IgnoreQueryFilters()
.Include(q => q.Customer)
.FirstOrDefaultAsync(q => q.ApprovalToken == token && !q.IsDeleted);
var quote = await _unitOfWork.Quotes.FirstOrDefaultAsync(
q => q.ApprovalToken == token && !q.IsDeleted,
ignoreQueryFilters: true,
q => q.Customer);
if (quote == null)
return base.View("InvalidToken");
var company = await _db.Companies
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId);
var company = await _unitOfWork.Companies.FirstOrDefaultAsync(
c => c.Id == quote.CompanyId, ignoreQueryFilters: true);
var prefs = await _db.CompanyPreferences
.IgnoreQueryFilters()
.FirstOrDefaultAsync(p => p.CompanyId == quote.CompanyId && !p.IsDeleted);
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == quote.CompanyId && !p.IsDeleted, ignoreQueryFilters: true);
var depositAmount = quote.RequiresDeposit && quote.DepositPercent > 0
? Math.Round(quote.Total * (quote.DepositPercent / 100m), 2)
: 0m;
// Only surface the deposit link if it's valid and not expired
var depositToken = (!string.IsNullOrEmpty(quote.DepositPaymentLinkToken)
&& quote.DepositPaymentLinkExpiresAt > DateTime.UtcNow)
? quote.DepositPaymentLinkToken
@@ -424,26 +380,16 @@ public class QuoteApprovalController : Controller
// -----------------------------------------------------------------------
/// <summary>
/// Validates the approval token and returns the quote if it is still actionable, or an
/// <c>IActionResult</c> error view if not. Checks in order: token exists, not expired, not
/// already used (<c>ApprovalTokenUsedAt != null</c>), and not already in a terminal status
/// (approved, rejected, or converted). The terminal-status check is a belt-and-suspenders guard
/// for cases where the status was changed by staff inside the app after the token was issued but
/// before the customer clicked. Uses <c>IgnoreQueryFilters</c> because the customer has no
/// tenant context. Loads <c>QuoteItems</c>, <c>QuoteStatus</c>, and <c>Customer</c> eagerly to
/// avoid N+1 queries in <see cref="BuildViewModelAsync"/>.
/// Validates the approval token and returns the quote if still actionable. Uses
/// IQuoteRepository.GetByApprovalTokenAsync which loads with IgnoreQueryFilters (the portal is
/// unauthenticated — no tenant context exists on the request).
/// </summary>
private async Task<(Quote? quote, IActionResult? errorResult)> ValidateTokenAsync(string token)
{
if (string.IsNullOrWhiteSpace(token))
return (null, base.View("InvalidToken"));
var quote = await _db.Quotes
.IgnoreQueryFilters()
.Include(q => q.QuoteItems)
.Include(q => q.QuoteStatus)
.Include(q => q.Customer)
.FirstOrDefaultAsync(q => q.ApprovalToken == token && !q.IsDeleted);
var quote = await _unitOfWork.Quotes.GetByApprovalTokenAsync(token);
if (quote == null)
return (null, base.View("InvalidToken"));
@@ -462,7 +408,6 @@ public class QuoteApprovalController : Controller
return (null, base.View("AlreadyActed", actedModel));
}
// Also check terminal status
if (quote.QuoteStatus != null &&
(quote.QuoteStatus.IsApprovedStatus || quote.QuoteStatus.IsRejectedStatus || quote.QuoteStatus.IsConvertedStatus))
{
@@ -476,22 +421,16 @@ public class QuoteApprovalController : Controller
}
/// <summary>
/// Builds the <c>QuoteApprovalViewModel</c> from a validated quote. Fetches company details and
/// preferences (e.g. from-email address for the contact section) separately because they are
/// not eagerly loaded by <see cref="ValidateTokenAsync"/>. Soft-deleted line items are filtered
/// out so the customer only sees active items. Also maps all prospect contact fields so the
/// <c>ConfirmDetails</c> view can pre-populate them if the prospect has previously started and
/// returned to the approval page.
/// Builds the QuoteApprovalViewModel from a validated quote. Fetches company details and
/// preferences separately because they are not eagerly loaded by ValidateTokenAsync.
/// </summary>
private async Task<QuoteApprovalViewModel> BuildViewModelAsync(Quote quote, string token)
{
var company = await _db.Companies
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.Id == quote.CompanyId);
var company = await _unitOfWork.Companies.FirstOrDefaultAsync(
c => c.Id == quote.CompanyId, ignoreQueryFilters: true);
var prefs = await _db.CompanyPreferences
.IgnoreQueryFilters()
.FirstOrDefaultAsync(p => p.CompanyId == quote.CompanyId && !p.IsDeleted);
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == quote.CompanyId && !p.IsDeleted, ignoreQueryFilters: true);
var items = (quote.QuoteItems ?? new List<QuoteItem>())
.Where(i => !i.IsDeleted)
@@ -537,9 +476,7 @@ public class QuoteApprovalController : Controller
}
/// <summary>
/// Resolves the display name for the customer or prospect on a quote. Prefers the company name
/// for commercial customers, falls back to contact first/last name, then to prospect fields, and
/// finally to the generic "Valued Customer" sentinel so the view always has something to show.
/// Resolves the display name for the customer or prospect on a quote.
/// </summary>
private static string GetCustomerName(Quote quote)
{
@@ -1,9 +1,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -19,17 +18,16 @@ namespace PowderCoating.Web.Controllers;
/// </summary>
public class ReleaseNotesController : Controller
{
private readonly ApplicationDbContext _db;
private readonly IUnitOfWork _unitOfWork;
private readonly IInAppNotificationService _inApp;
public ReleaseNotesController(ApplicationDbContext db, IInAppNotificationService inApp)
public ReleaseNotesController(IUnitOfWork unitOfWork, IInAppNotificationService inApp)
{
_db = db;
_unitOfWork = unitOfWork;
_inApp = inApp;
}
// ── Public: Changelog ────────────────────────────────────────────────────
// Visible to all authenticated users
/// <summary>
/// Renders the public changelog — shows only published release notes ordered
@@ -39,54 +37,39 @@ public class ReleaseNotesController : Controller
[Authorize]
public async Task<IActionResult> Index()
{
var notes = await _db.ReleaseNotes
.AsNoTracking()
.Where(r => r.IsPublished)
var notes = (await _unitOfWork.ReleaseNotes.FindAsync(r => r.IsPublished))
.OrderByDescending(r => r.ReleasedAt)
.ThenByDescending(r => r.Id)
.ToListAsync();
.ToList();
return View(notes);
}
// ── SuperAdmin: Manage ───────────────────────────────────────────────────
/// <summary>
/// Returns the SuperAdmin management list of all release notes (published and
/// draft alike), ordered newest-first. Unlike <see cref="Index"/> there is no
/// <c>IsPublished</c> filter here so admins can see and edit drafts.
/// Returns the SuperAdmin management list of all release notes (published and draft alike), ordered newest-first.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> Manage()
{
var notes = await _db.ReleaseNotes
.AsNoTracking()
var notes = (await _unitOfWork.ReleaseNotes.GetAllAsync())
.OrderByDescending(r => r.ReleasedAt)
.ThenByDescending(r => r.Id)
.ToListAsync();
.ToList();
return View(notes);
}
/// <summary>
/// Returns the Create form pre-populated with today's UTC date and the "Feature"
/// tag as sensible defaults for new entries, reducing data-entry friction.
/// Returns the Create form pre-populated with today's UTC date and the "Feature" tag as sensible defaults.
/// </summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public IActionResult Create()
{
return View(new ReleaseNote
{
ReleasedAt = DateTime.UtcNow,
Tag = "Feature"
});
return View(new ReleaseNote { ReleasedAt = DateTime.UtcNow, Tag = "Feature" });
}
/// <summary>
/// Persists a new release note and captures the creating SuperAdmin's identity
/// (<c>CreatedByUserId</c> / <c>CreatedByUserName</c>) for audit purposes.
/// New notes start unpublished by default unless the form explicitly sets
/// <c>IsPublished = true</c>, giving authors a chance to review before going live.
/// Persists a new release note. New notes start unpublished unless the form explicitly sets IsPublished = true.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
@@ -99,8 +82,8 @@ public class ReleaseNotesController : Controller
model.CreatedByUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
model.CreatedByUserName = User.Identity?.Name;
_db.ReleaseNotes.Add(model);
await _db.SaveChangesAsync();
await _unitOfWork.ReleaseNotes.AddAsync(model);
await _unitOfWork.CompleteAsync();
if (model.IsPublished)
await NotifyAllTenantsAsync(model);
@@ -109,22 +92,18 @@ public class ReleaseNotesController : Controller
return RedirectToAction(nameof(Manage));
}
/// <summary>
/// Returns the Edit form loaded from the database by primary key.
/// </summary>
/// <summary>Returns the Edit form loaded from the database by primary key.</summary>
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> Edit(int id)
{
var note = await _db.ReleaseNotes.FindAsync(id);
var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id);
if (note == null) return NotFound();
return View(note);
}
/// <summary>
/// Applies the edited values to the tracked entity. Uses explicit field
/// mapping (rather than <c>_db.Entry(model).State = Modified</c>) to prevent
/// over-posting attacks and to ensure audit fields like <c>CreatedAt</c> and
/// <c>CreatedByUserId</c> are never overwritten.
/// Applies the edited values to the tracked entity using explicit field mapping to prevent
/// over-posting attacks and ensure audit fields are never overwritten.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
@@ -134,40 +113,37 @@ public class ReleaseNotesController : Controller
if (!ModelState.IsValid)
return View(model);
var note = await _db.ReleaseNotes.FindAsync(id);
var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id);
if (note == null) return NotFound();
note.Version = model.Version;
note.Title = model.Title;
note.Body = model.Body;
note.Tag = model.Tag;
note.IsPublished= model.IsPublished;
note.ReleasedAt = model.ReleasedAt;
note.UpdatedAt = DateTime.UtcNow;
note.Version = model.Version;
note.Title = model.Title;
note.Body = model.Body;
note.Tag = model.Tag;
note.IsPublished = model.IsPublished;
note.ReleasedAt = model.ReleasedAt;
note.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Release note v{note.Version} updated.";
return RedirectToAction(nameof(Manage));
}
/// <summary>
/// Toggles the published state of a release note. Publishing makes the note
/// immediately visible to all authenticated users via <see cref="Index"/>;
/// un-publishing hides it without permanently deleting it so it can be revised
/// and re-published later.
/// Toggles the published state of a release note. Publishing fires an in-app notification to all tenants.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> TogglePublish(int id)
{
var note = await _db.ReleaseNotes.FindAsync(id);
var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id);
if (note == null) return NotFound();
var wasPublished = note.IsPublished;
note.IsPublished = !note.IsPublished;
note.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
if (note.IsPublished && !wasPublished)
await NotifyAllTenantsAsync(note);
@@ -179,29 +155,24 @@ public class ReleaseNotesController : Controller
}
/// <summary>
/// Permanently (hard) deletes a release note. This is intentional — release notes
/// are platform metadata, not business data, so they do not use soft delete.
/// Use <see cref="TogglePublish"/> to hide a note without permanent removal.
/// Permanently (hard) deletes a release note. Release notes are platform metadata and do not use soft delete.
/// </summary>
[HttpPost, ValidateAntiForgeryToken]
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public async Task<IActionResult> Delete(int id)
{
var note = await _db.ReleaseNotes.FindAsync(id);
var note = await _unitOfWork.ReleaseNotes.GetByIdAsync(id);
if (note == null) return NotFound();
_db.ReleaseNotes.Remove(note);
await _db.SaveChangesAsync();
await _unitOfWork.ReleaseNotes.DeleteAsync(note);
await _unitOfWork.CompleteAsync();
TempData["Success"] = $"Release note v{note.Version} deleted.";
return RedirectToAction(nameof(Manage));
}
/// <summary>
/// Fans out a "What's New" in-app notification to every active tenant company when
/// a release note transitions to published. Notification fires exactly once per
/// publish event — re-publishing after unpublishing will send a second notification,
/// which is intentional (the content may have changed).
/// Fans out a "What's New" in-app notification to every active tenant company when a release note is published.
/// </summary>
private Task NotifyAllTenantsAsync(ReleaseNote note)
{
@@ -9,7 +9,6 @@ using PowderCoating.Application.Interfaces;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Entities;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using PowderCoating.Web.ViewModels.Reports;
@@ -20,17 +19,19 @@ public class ReportsController : Controller
{
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<ReportsController> _logger;
private readonly ApplicationDbContext _context;
private readonly IFinancialReportService _financialReports;
private readonly IOperationalReportService _operationalReports;
private readonly IPdfService _pdfService;
private readonly UserManager<ApplicationUser> _userManager;
private readonly IAccountingAiService _accountingAi;
private readonly IAiUsageLogger _usageLogger;
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, ApplicationDbContext context, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger)
public ReportsController(IUnitOfWork unitOfWork, ILogger<ReportsController> logger, IFinancialReportService financialReports, IOperationalReportService operationalReports, IPdfService pdfService, UserManager<ApplicationUser> userManager, IAccountingAiService accountingAi, IAiUsageLogger usageLogger)
{
_unitOfWork = unitOfWork;
_logger = logger;
_context = context;
_financialReports = financialReports;
_operationalReports = operationalReports;
_pdfService = pdfService;
_userManager = userManager;
_accountingAi = accountingAi;
@@ -493,11 +494,7 @@ public class ReportsController : Controller
.ToList();
// === EXPENSE / AP ANALYTICS ===
var allBills = await _context.Bills
.Include(b => b.Vendor)
.Include(b => b.Payments.Where(p => !p.IsDeleted))
.Where(b => !b.IsDeleted && b.Status != BillStatus.Voided)
.ToListAsync();
var allBills = await _operationalReports.GetActiveBillsAsync();
var totalBilled = allBills.Sum(b => b.Total);
var totalBillsPaid = allBills.Sum(b => b.AmountPaid);
@@ -536,10 +533,7 @@ public class ReportsController : Controller
.ToList();
// Expenses by account
var allExpenses = await _context.Expenses
.Include(e => e.ExpenseAccount)
.Where(e => !e.IsDeleted)
.ToListAsync();
var allExpenses = await _operationalReports.GetAllExpensesAsync();
var expensesByAccount = allExpenses
.Where(e => e.ExpenseAccount != null)
@@ -664,10 +658,7 @@ public class ReportsController : Controller
.ToList();
// === JOB CYCLE TIME ===
var allStatusHistory = await _context.JobStatusHistory
.Include(h => h.FromStatus)
.Include(h => h.ToStatus)
.ToListAsync();
var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
var historyByJob = allStatusHistory
.GroupBy(h => h.JobId)
@@ -1002,102 +993,10 @@ public class ReportsController : Controller
public async Task<IActionResult> ProfitAndLoss(DateTime? from, DateTime? to)
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
var toDate = (to ?? DateTime.Today).Date;
var toEnd = toDate.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync();
// ── Revenue: InvoiceItems posted to revenue accounts ──────────────────
var revenueByAccount = await _context.InvoiceItems
.Where(ii => ii.RevenueAccountId != null
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd)
.GroupBy(ii => ii.RevenueAccountId!.Value)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) })
.ToListAsync();
// Unlinked invoice totals (items without a revenue account) → lump into a default "Sales" bucket
var unlinkedRevenue = await _context.InvoiceItems
.Where(ii => ii.RevenueAccountId == null
&& ii.Invoice.Status != InvoiceStatus.Draft
&& ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
var revenueAccounts = await _context.Accounts
.Where(a => a.AccountType == AccountType.Revenue && a.IsActive)
.ToDictionaryAsync(a => a.Id);
var revenueLines = revenueByAccount
.Where(r => revenueAccounts.ContainsKey(r.AccountId))
.Select(r => new FinancialReportLine
{
AccountId = r.AccountId,
AccountNumber = revenueAccounts[r.AccountId].AccountNumber,
AccountName = revenueAccounts[r.AccountId].Name,
Amount = r.Amount
})
.OrderBy(l => l.AccountNumber)
.ToList();
if (unlinkedRevenue > 0)
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
// ── COGS & Expenses: Expenses + BillLineItems by account type ─────────
// Direct expenses
var directByAccount = await _context.Expenses
.Where(e => e.Date >= fromDate && e.Date <= toEnd)
.GroupBy(e => e.ExpenseAccountId)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) })
.ToListAsync();
// Bill line items (skip lines with no account — QB-imported without account mapping)
var billLinesByAccount = await _context.BillLineItems
.Where(bli => bli.AccountId != null
&& bli.Bill.Status != BillStatus.Draft
&& bli.Bill.Status != BillStatus.Voided
&& bli.Bill.BillDate >= fromDate && bli.Bill.BillDate <= toEnd)
.GroupBy(bli => bli.AccountId!.Value)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) })
.ToListAsync();
// Merge the two expense sources per account
var expenseAmounts = new Dictionary<int, decimal>();
foreach (var e in directByAccount)
expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
foreach (var b in billLinesByAccount)
expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
var expAccounts = await _context.Accounts
.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive)
.ToDictionaryAsync(a => a.Id);
var cogsLines = new List<FinancialReportLine>();
var expenseLines = new List<FinancialReportLine>();
foreach (var (accountId, amount) in expenseAmounts.OrderBy(kv => expAccounts.ContainsKey(kv.Key) ? expAccounts[kv.Key].AccountNumber : "999"))
{
if (!expAccounts.TryGetValue(accountId, out var acct)) continue;
var line = new FinancialReportLine { AccountId = accountId, AccountNumber = acct.AccountNumber, AccountName = acct.Name, Amount = amount };
if (acct.AccountType == AccountType.CostOfGoods) cogsLines.Add(line);
else expenseLines.Add(line);
}
var dto = new ProfitAndLossDto
{
From = fromDate,
To = toDate,
CompanyName = companyName,
RevenueLines = revenueLines,
TotalRevenue = revenueLines.Sum(l => l.Amount),
CogsLines = cogsLines,
TotalCogs = cogsLines.Sum(l => l.Amount),
ExpenseLines = expenseLines,
TotalExpenses = expenseLines.Sum(l => l.Amount),
};
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
var toDate = (to ?? DateTime.Today).Date;
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var dto = await _financialReports.GetProfitAndLossAsync(companyId, fromDate, toDate);
return View(dto);
}
@@ -1116,157 +1015,9 @@ public class ReportsController : Controller
public async Task<IActionResult> BalanceSheet(DateTime? asOf)
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var asOfDate = (asOf ?? DateTime.Today).Date;
var asOfEnd = asOfDate.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync();
// ── Pre-compute balance contributions per account (batch queries) ──────
// Asset: payments deposited INTO account (DEBIT) — exclude voided/written-off invoices
var depositsByAcct = await _context.Payments
.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff)
.GroupBy(p => p.DepositAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
// Asset: expenses paid FROM account (CREDIT)
var expFromByAcct = await _context.Expenses
.Where(e => e.Date <= asOfEnd)
.GroupBy(e => e.PaymentAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
// Asset: bill payments FROM account (CREDIT)
var bpFromByAcct = await _context.BillPayments
.Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.BankAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
// Liability: bills posted to AP account (CREDIT)
var billsByApAcct = await _context.Bills
.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
.GroupBy(b => b.APAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
// Liability: bill payments reducing AP (DEBIT)
var bpByApAcct = await _context.BillPayments
.Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.Bill.APAccountId)
.Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
// Liability: sales tax payable (CREDIT)
var taxByAcct = await _context.Invoices
.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0
&& i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate <= asOfEnd)
.GroupBy(i => i.SalesTaxAccountId!.Value)
.Select(g => new { Id = g.Key, Amount = g.Sum(i => i.TaxAmount) })
.ToDictionaryAsync(g => g.Id, g => g.Amount);
// AR total (used for AR sub-type accounts)
var arDebits = await _context.Invoices.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd).SumAsync(i => (decimal?)i.Total) ?? 0;
var arCredits = await _context.Payments.Where(p => p.PaymentDate <= asOfEnd
&& p.Invoice.Status != InvoiceStatus.Voided
&& p.Invoice.Status != InvoiceStatus.WrittenOff).SumAsync(p => (decimal?)p.Amount) ?? 0;
// ── Retained earnings = net P&L from inception ────────────────────────
var lifetimeRevenue = await _context.InvoiceItems
.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
var lifetimeCogs = await _context.Expenses.Where(e => e.Date <= asOfEnd).Include(e => e.ExpenseAccount).SumAsync(e => (decimal?)e.Amount) ?? 0;
var lifetimeBillCosts = await _context.BillLineItems.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd).SumAsync(bli => (decimal?)bli.Amount) ?? 0;
var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
// ── Compute balance for each account ──────────────────────────────────
decimal ComputeBalance(Account a)
{
bool normalDebit = a.AccountType == AccountType.Asset;
decimal debits = 0, credits = 0;
if (a.AccountSubType == AccountSubType.AccountsReceivable)
{
debits = arDebits; credits = arCredits;
}
else if (a.AccountSubType == AccountSubType.AccountsPayable)
{
credits = billsByApAcct.GetValueOrDefault(a.Id);
debits = bpByApAcct.GetValueOrDefault(a.Id);
}
else
{
debits += depositsByAcct.GetValueOrDefault(a.Id);
credits += expFromByAcct.GetValueOrDefault(a.Id);
credits += bpFromByAcct.GetValueOrDefault(a.Id);
credits += taxByAcct.GetValueOrDefault(a.Id);
}
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOfDate)
? a.OpeningBalance : 0;
decimal net = normalDebit ? debits - credits : credits - debits;
return opening + net;
}
FinancialReportLine ToLine(Account a) => new()
{
AccountId = a.Id,
AccountNumber = a.AccountNumber,
AccountName = a.Name,
Amount = ComputeBalance(a)
};
// Load accounts by type
var accounts = await _context.Accounts.Where(a => a.IsActive).OrderBy(a => a.AccountNumber).ToListAsync();
var assetAccts = accounts.Where(a => a.AccountType == AccountType.Asset).ToList();
var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList();
var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList();
var currentAssets = assetAccts
.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings
or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset)
.Select(ToLine).ToList();
var fixedAssets = assetAccts
.Where(a => a.AccountSubType == AccountSubType.FixedAsset)
.Select(ToLine).ToList();
var otherAssets = assetAccts
.Where(a => a.AccountSubType == AccountSubType.OtherAsset)
.Select(ToLine).ToList();
var currentLiabilities = liabilityAccts
.Where(a => a.AccountSubType is AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability)
.Select(ToLine).ToList();
var longTermLiabilities = liabilityAccts
.Where(a => a.AccountSubType == AccountSubType.LongTermLiability)
.Select(ToLine).ToList();
var equityLines = equityAccts.Select(ToLine).ToList();
var totalAssets = currentAssets.Sum(l => l.Amount) + fixedAssets.Sum(l => l.Amount) + otherAssets.Sum(l => l.Amount);
var totalLiabilities = currentLiabilities.Sum(l => l.Amount) + longTermLiabilities.Sum(l => l.Amount);
var totalEquity = equityLines.Sum(l => l.Amount) + retainedEarnings;
var dto = new BalanceSheetDto
{
AsOf = asOfDate,
CompanyName = companyName,
CurrentAssets = currentAssets,
FixedAssets = fixedAssets,
OtherAssets = otherAssets,
TotalAssets = totalAssets,
CurrentLiabilities = currentLiabilities,
LongTermLiabilities = longTermLiabilities,
TotalLiabilities = totalLiabilities,
EquityLines = equityLines,
RetainedEarnings = retainedEarnings,
TotalEquity = totalEquity,
};
var asOfDate = (asOf ?? DateTime.Today).Date;
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var dto = await _financialReports.GetBalanceSheetAsync(companyId, asOfDate);
return View(dto);
}
@@ -1282,85 +1033,9 @@ public class ReportsController : Controller
public async Task<IActionResult> ArAging(DateTime? asOf)
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var asOfDate = (asOf ?? DateTime.Today).Date;
var asOfEnd = asOfDate.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync();
var openInvoices = await _context.Invoices
.Include(i => i.Customer)
.Where(i => i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.Status != InvoiceStatus.Paid
&& i.InvoiceDate <= asOfEnd
&& (i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed) > 0)
.OrderBy(i => i.Customer!.CompanyName)
.ThenBy(i => i.DueDate)
.ToListAsync();
var customerGroups = openInvoices
.GroupBy(i => new { i.CustomerId, i.Customer!.CompanyName, i.Customer.ContactFirstName, i.Customer.ContactLastName, i.Customer.IsCommercial });
static string AgingBucket(int daysOverdue) => daysOverdue switch
{
<= 0 => "current",
<= 30 => "1-30",
<= 60 => "31-60",
<= 90 => "61-90",
_ => "90+"
};
var customers = new List<ArAgingCustomerDto>();
foreach (var grp in customerGroups)
{
var customerName = grp.Key.IsCommercial
? grp.Key.CompanyName
: $"{grp.Key.ContactFirstName} {grp.Key.ContactLastName}".Trim();
var custDto = new ArAgingCustomerDto { CustomerId = grp.Key.CustomerId, CustomerName = customerName };
foreach (var inv in grp)
{
var balance = inv.BalanceDue;
var daysOverdue = inv.DueDate.HasValue ? (int)(asOfDate - inv.DueDate.Value.Date).TotalDays : 0;
var bucket = AgingBucket(daysOverdue);
custDto.Invoices.Add(new ArAgingInvoiceDto
{
InvoiceId = inv.Id,
InvoiceNumber = inv.InvoiceNumber,
InvoiceDate = inv.InvoiceDate,
DueDate = inv.DueDate,
BalanceDue = balance,
DaysOverdue = daysOverdue
});
switch (bucket)
{
case "current": custDto.TotalCurrent += balance; break;
case "1-30": custDto.Total1to30 += balance; break;
case "31-60": custDto.Total31to60 += balance; break;
case "61-90": custDto.Total61to90 += balance; break;
default: custDto.TotalOver90 += balance; break;
}
}
customers.Add(custDto);
}
var dto = new ArAgingReportDto
{
AsOf = asOfDate,
CompanyName = companyName,
Customers = customers.OrderByDescending(c => c.TotalBalance).ToList(),
TotalCurrent = customers.Sum(c => c.TotalCurrent),
Total1to30 = customers.Sum(c => c.Total1to30),
Total31to60 = customers.Sum(c => c.Total31to60),
Total61to90 = customers.Sum(c => c.Total61to90),
TotalOver90 = customers.Sum(c => c.TotalOver90),
};
var asOfDate = (asOf ?? DateTime.Today).Date;
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var dto = await _financialReports.GetArAgingAsync(companyId, asOfDate);
return View(dto);
}
@@ -1375,90 +1050,10 @@ public class ReportsController : Controller
// GET: /Reports/SalesAndIncome
public async Task<IActionResult> SalesAndIncome(DateTime? from, DateTime? to)
{
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
var toDate = (to ?? DateTime.Today).Date;
var toEnd = toDate.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync();
var invoices = await _context.Invoices
.Include(i => i.Customer)
.Include(i => i.Payments)
.Where(i => i.Status != InvoiceStatus.Draft
&& i.Status != InvoiceStatus.Voided
&& i.InvoiceDate >= fromDate && i.InvoiceDate <= toEnd)
.OrderBy(i => i.InvoiceDate)
.ToListAsync();
// Payments collected within the period (may differ from invoice dates)
var collectedInPeriod = await _context.Payments
.Where(p => p.PaymentDate >= fromDate && p.PaymentDate <= toEnd)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
// By customer
var byCustomer = invoices
.GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial
? i.Customer.CompanyName
: $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() })
.Select(g => new SalesByCustomerDto
{
CustomerId = g.Key.CustomerId,
CustomerName = g.Key.Name,
InvoiceCount = g.Count(),
TotalInvoiced = g.Sum(i => i.Total),
TotalPaid = g.Sum(i => i.AmountPaid),
BalanceDue = g.Sum(i => i.BalanceDue),
})
.OrderByDescending(c => c.TotalInvoiced)
.ToList();
// By month
var byMonth = invoices
.GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
.Select(g => new SalesByMonthDto
{
Year = g.Key.Year,
Month = g.Key.Month,
Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"),
TotalInvoiced = g.Sum(i => i.Total),
TotalCollected = g.Sum(i => i.AmountPaid),
InvoiceCount = g.Count(),
})
.OrderBy(m => m.Year).ThenBy(m => m.Month)
.ToList();
// Invoice detail lines
var invoiceLines = invoices.Select(i => new SalesInvoiceLineDto
{
InvoiceId = i.Id,
InvoiceNumber = i.InvoiceNumber,
CustomerName = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(),
InvoiceDate = i.InvoiceDate,
DueDate = i.DueDate,
Status = i.Status.ToString(),
SubTotal = i.SubTotal,
TaxAmount = i.TaxAmount,
Total = i.Total,
AmountPaid = i.AmountPaid,
BalanceDue = i.BalanceDue,
}).ToList();
var dto = new SalesIncomeReportDto
{
From = fromDate,
To = toDate,
CompanyName = companyName,
TotalInvoiced = invoices.Sum(i => i.Total),
TotalCollected = collectedInPeriod,
TotalTax = invoices.Sum(i => i.TaxAmount),
TotalDiscount = invoices.Sum(i => i.DiscountAmount),
InvoiceCount = invoices.Count,
CustomerCount = invoices.Select(i => i.CustomerId).Distinct().Count(),
ByCustomer = byCustomer,
ByMonth = byMonth,
Invoices = invoiceLines,
};
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
var toDate = (to ?? DateTime.Today).Date;
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var dto = await _financialReports.GetSalesAndIncomeAsync(companyId, fromDate, toDate);
return View(dto);
}
@@ -1476,64 +1071,10 @@ public class ReportsController : Controller
public async Task<IActionResult> ProfitAndLossPdf(DateTime? from, DateTime? to, bool inline = false)
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
var toDate = (to ?? DateTime.Today).Date;
var toEnd = toDate.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync();
var revenueByAccount = await _context.InvoiceItems
.Where(ii => ii.RevenueAccountId != null
&& ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd)
.GroupBy(ii => ii.RevenueAccountId!.Value)
.Select(g => new { AccountId = g.Key, Amount = g.Sum(ii => ii.TotalPrice) })
.ToListAsync();
var unlinkedRevenue = await _context.InvoiceItems
.Where(ii => ii.RevenueAccountId == null
&& ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided
&& ii.Invoice.InvoiceDate >= fromDate && ii.Invoice.InvoiceDate <= toEnd)
.SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
var revenueAccounts = await _context.Accounts.Where(a => a.AccountType == AccountType.Revenue && a.IsActive).ToDictionaryAsync(a => a.Id);
var revenueLines = revenueByAccount
.Where(r => revenueAccounts.ContainsKey(r.AccountId))
.Select(r => new FinancialReportLine { AccountId = r.AccountId, AccountNumber = revenueAccounts[r.AccountId].AccountNumber, AccountName = revenueAccounts[r.AccountId].Name, Amount = r.Amount })
.OrderBy(l => l.AccountNumber).ToList();
if (unlinkedRevenue > 0)
revenueLines.Add(new FinancialReportLine { AccountNumber = "—", AccountName = "Other Sales (unclassified)", Amount = unlinkedRevenue });
var directByAccount = await _context.Expenses.Where(e => e.Date >= fromDate && e.Date <= toEnd)
.GroupBy(e => e.ExpenseAccountId).Select(g => new { AccountId = g.Key, Amount = g.Sum(e => e.Amount) }).ToListAsync();
var billLinesByAccount = await _context.BillLineItems
.Where(bli => bli.AccountId != null && bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate >= fromDate && bli.Bill.BillDate <= toEnd)
.GroupBy(bli => bli.AccountId!.Value).Select(g => new { AccountId = g.Key, Amount = g.Sum(bli => bli.Amount) }).ToListAsync();
var expenseAmounts = new Dictionary<int, decimal>();
foreach (var e in directByAccount) expenseAmounts[e.AccountId] = expenseAmounts.GetValueOrDefault(e.AccountId) + e.Amount;
foreach (var b in billLinesByAccount) expenseAmounts[b.AccountId] = expenseAmounts.GetValueOrDefault(b.AccountId) + b.Amount;
var expAccounts = await _context.Accounts.Where(a => (a.AccountType == AccountType.Expense || a.AccountType == AccountType.CostOfGoods) && a.IsActive).ToDictionaryAsync(a => a.Id);
var cogsLines = new List<FinancialReportLine>(); var expenseLines = new List<FinancialReportLine>();
foreach (var (accountId, amount) in expenseAmounts.OrderBy(kv => expAccounts.ContainsKey(kv.Key) ? expAccounts[kv.Key].AccountNumber : "999"))
{
if (!expAccounts.TryGetValue(accountId, out var acct)) continue;
var line = new FinancialReportLine { AccountId = accountId, AccountNumber = acct.AccountNumber, AccountName = acct.Name, Amount = amount };
if (acct.AccountType == AccountType.CostOfGoods) cogsLines.Add(line); else expenseLines.Add(line);
}
var dto = new ProfitAndLossDto
{
From = fromDate, To = toDate, CompanyName = companyName,
RevenueLines = revenueLines, TotalRevenue = revenueLines.Sum(l => l.Amount),
CogsLines = cogsLines, TotalCogs = cogsLines.Sum(l => l.Amount),
ExpenseLines = expenseLines, TotalExpenses = expenseLines.Sum(l => l.Amount),
};
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
var toDate = (to ?? DateTime.Today).Date;
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var dto = await _financialReports.GetProfitAndLossAsync(companyId, fromDate, toDate);
var pdfBytes = await _pdfService.GenerateProfitAndLossPdfAsync(dto);
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"ProfitAndLoss-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
}
@@ -1546,75 +1087,9 @@ public class ReportsController : Controller
public async Task<IActionResult> BalanceSheetPdf(DateTime? asOf, bool inline = false)
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var asOfDate = (asOf ?? DateTime.Today).Date;
var asOfEnd = asOfDate.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync();
var depositsByAcct = await _context.Payments.Where(p => p.PaymentDate <= asOfEnd && p.DepositAccountId != null)
.GroupBy(p => p.DepositAccountId!.Value).Select(g => new { Id = g.Key, Amount = g.Sum(p => p.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
var expFromByAcct = await _context.Expenses.Where(e => e.Date <= asOfEnd)
.GroupBy(e => e.PaymentAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(e => e.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
var bpFromByAcct = await _context.BillPayments.Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.BankAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
var billsByApAcct = await _context.Bills.Where(b => b.Status != BillStatus.Draft && b.Status != BillStatus.Voided && b.BillDate <= asOfEnd)
.GroupBy(b => b.APAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(b => b.Total) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
var bpByApAcct = await _context.BillPayments.Where(bp => bp.PaymentDate <= asOfEnd)
.GroupBy(bp => bp.Bill.APAccountId).Select(g => new { Id = g.Key, Amount = g.Sum(bp => bp.Amount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
var taxByAcct = await _context.Invoices.Where(i => i.SalesTaxAccountId != null && i.TaxAmount > 0 && i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd)
.GroupBy(i => i.SalesTaxAccountId!.Value).Select(g => new { Id = g.Key, Amount = g.Sum(i => i.TaxAmount) }).ToDictionaryAsync(g => g.Id, g => g.Amount);
var arDebits = await _context.Invoices.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided && i.InvoiceDate <= asOfEnd).SumAsync(i => (decimal?)i.Total) ?? 0;
var arCredits = await _context.Payments.Where(p => p.PaymentDate <= asOfEnd).SumAsync(p => (decimal?)p.Amount) ?? 0;
var lifetimeRevenue = await _context.InvoiceItems.Where(ii => ii.Invoice.Status != InvoiceStatus.Draft && ii.Invoice.Status != InvoiceStatus.Voided && ii.Invoice.InvoiceDate <= asOfEnd).SumAsync(ii => (decimal?)ii.TotalPrice) ?? 0;
var lifetimeCogs = await _context.Expenses.Where(e => e.Date <= asOfEnd).Include(e => e.ExpenseAccount).SumAsync(e => (decimal?)e.Amount) ?? 0;
var lifetimeBillCosts = await _context.BillLineItems.Where(bli => bli.Bill.Status != BillStatus.Draft && bli.Bill.Status != BillStatus.Voided && bli.Bill.BillDate <= asOfEnd).SumAsync(bli => (decimal?)bli.Amount) ?? 0;
var retainedEarnings = lifetimeRevenue - lifetimeCogs - lifetimeBillCosts;
var accounts = await _context.Accounts.Where(a => a.IsActive).OrderBy(a => a.AccountNumber).ToListAsync();
decimal ComputeBalance(Account a)
{
bool normalDebit = a.AccountType == AccountType.Asset;
decimal debits = 0, credits = 0;
if (a.AccountSubType == AccountSubType.AccountsReceivable) { debits = arDebits; credits = arCredits; }
else if (a.AccountSubType == AccountSubType.AccountsPayable) { credits = billsByApAcct.GetValueOrDefault(a.Id); debits = bpByApAcct.GetValueOrDefault(a.Id); }
else { debits += depositsByAcct.GetValueOrDefault(a.Id); credits += expFromByAcct.GetValueOrDefault(a.Id); credits += bpFromByAcct.GetValueOrDefault(a.Id); credits += taxByAcct.GetValueOrDefault(a.Id); }
decimal opening = (a.OpeningBalanceDate == null || a.OpeningBalanceDate.Value.Date <= asOfDate) ? a.OpeningBalance : 0;
decimal net = normalDebit ? debits - credits : credits - debits;
return opening + net;
}
FinancialReportLine ToLine(Account a) => new() { AccountId = a.Id, AccountNumber = a.AccountNumber, AccountName = a.Name, Amount = ComputeBalance(a) };
var assetAccts = accounts.Where(a => a.AccountType == AccountType.Asset).ToList();
var liabilityAccts = accounts.Where(a => a.AccountType == AccountType.Liability).ToList();
var equityAccts = accounts.Where(a => a.AccountType == AccountType.Equity).ToList();
var currentAssets = assetAccts.Where(a => a.AccountSubType is AccountSubType.Checking or AccountSubType.Savings or AccountSubType.AccountsReceivable or AccountSubType.Inventory or AccountSubType.OtherCurrentAsset).Select(ToLine).ToList();
var fixedAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.FixedAsset).Select(ToLine).ToList();
var otherAssets = assetAccts.Where(a => a.AccountSubType == AccountSubType.OtherAsset).Select(ToLine).ToList();
var currentLiabilities = liabilityAccts.Where(a => a.AccountSubType is AccountSubType.AccountsPayable or AccountSubType.CreditCard or AccountSubType.OtherCurrentLiability).Select(ToLine).ToList();
var longTermLiabilities = liabilityAccts.Where(a => a.AccountSubType == AccountSubType.LongTermLiability).Select(ToLine).ToList();
var equityLines = equityAccts.Select(ToLine).ToList();
var totalAssets = currentAssets.Sum(l => l.Amount) + fixedAssets.Sum(l => l.Amount) + otherAssets.Sum(l => l.Amount);
var totalLiabilities = currentLiabilities.Sum(l => l.Amount) + longTermLiabilities.Sum(l => l.Amount);
var totalEquity = equityLines.Sum(l => l.Amount) + retainedEarnings;
var dto = new BalanceSheetDto
{
AsOf = asOfDate, CompanyName = companyName,
CurrentAssets = currentAssets, FixedAssets = fixedAssets, OtherAssets = otherAssets, TotalAssets = totalAssets,
CurrentLiabilities = currentLiabilities, LongTermLiabilities = longTermLiabilities, TotalLiabilities = totalLiabilities,
EquityLines = equityLines, RetainedEarnings = retainedEarnings, TotalEquity = totalEquity,
};
var asOfDate = (asOf ?? DateTime.Today).Date;
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var dto = await _financialReports.GetBalanceSheetAsync(companyId, asOfDate);
var pdfBytes = await _pdfService.GenerateBalanceSheetPdfAsync(dto);
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"BalanceSheet-{asOfDate:yyyyMMdd}.pdf");
}
@@ -1627,46 +1102,9 @@ public class ReportsController : Controller
public async Task<IActionResult> ArAgingPdf(DateTime? asOf, bool inline = false)
{
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var asOfDate = (asOf ?? DateTime.Today).Date;
var asOfEnd = asOfDate.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync();
var openInvoices = await _context.Invoices
.Include(i => i.Customer)
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.Status != InvoiceStatus.Paid && i.InvoiceDate <= asOfEnd
&& (i.Total - i.AmountPaid - i.CreditApplied - i.GiftCertificateRedeemed) > 0)
.OrderBy(i => i.Customer!.CompanyName).ThenBy(i => i.DueDate)
.ToListAsync();
var customerGroups = openInvoices.GroupBy(i => new { i.CustomerId, i.Customer!.CompanyName, i.Customer.ContactFirstName, i.Customer.ContactLastName, i.Customer.IsCommercial });
static string AgingBucket(int d) => d switch { <= 0 => "current", <= 30 => "1-30", <= 60 => "31-60", <= 90 => "61-90", _ => "90+" };
var customers = new List<ArAgingCustomerDto>();
foreach (var grp in customerGroups)
{
var customerName = grp.Key.IsCommercial ? grp.Key.CompanyName : $"{grp.Key.ContactFirstName} {grp.Key.ContactLastName}".Trim();
var custDto = new ArAgingCustomerDto { CustomerId = grp.Key.CustomerId, CustomerName = customerName };
foreach (var inv in grp)
{
var balance = inv.BalanceDue;
var daysOverdue = inv.DueDate.HasValue ? (int)(asOfDate - inv.DueDate.Value.Date).TotalDays : 0;
custDto.Invoices.Add(new ArAgingInvoiceDto { InvoiceId = inv.Id, InvoiceNumber = inv.InvoiceNumber, InvoiceDate = inv.InvoiceDate, DueDate = inv.DueDate, BalanceDue = balance, DaysOverdue = daysOverdue });
switch (AgingBucket(daysOverdue)) { case "current": custDto.TotalCurrent += balance; break; case "1-30": custDto.Total1to30 += balance; break; case "31-60": custDto.Total31to60 += balance; break; case "61-90": custDto.Total61to90 += balance; break; default: custDto.TotalOver90 += balance; break; }
}
customers.Add(custDto);
}
var dto = new ArAgingReportDto
{
AsOf = asOfDate, CompanyName = companyName,
Customers = customers.OrderByDescending(c => c.TotalBalance).ToList(),
TotalCurrent = customers.Sum(c => c.TotalCurrent), Total1to30 = customers.Sum(c => c.Total1to30),
Total31to60 = customers.Sum(c => c.Total31to60), Total61to90 = customers.Sum(c => c.Total61to90),
TotalOver90 = customers.Sum(c => c.TotalOver90),
};
var asOfDate = (asOf ?? DateTime.Today).Date;
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var dto = await _financialReports.GetArAgingAsync(companyId, asOfDate);
var pdfBytes = await _pdfService.GenerateArAgingPdfAsync(dto);
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"AR-Aging-{asOfDate:yyyyMMdd}.pdf");
}
@@ -1678,48 +1116,10 @@ public class ReportsController : Controller
// GET: /Reports/SalesAndIncomePdf
public async Task<IActionResult> SalesAndIncomePdf(DateTime? from, DateTime? to, bool inline = false)
{
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
var toDate = (to ?? DateTime.Today).Date;
var toEnd = toDate.AddDays(1).AddTicks(-1);
var companyName = await GetCompanyNameAsync();
var invoices = await _context.Invoices
.Include(i => i.Customer).Include(i => i.Payments)
.Where(i => i.Status != InvoiceStatus.Draft && i.Status != InvoiceStatus.Voided
&& i.InvoiceDate >= fromDate && i.InvoiceDate <= toEnd)
.OrderBy(i => i.InvoiceDate).ToListAsync();
var collectedInPeriod = await _context.Payments
.Where(p => p.PaymentDate >= fromDate && p.PaymentDate <= toEnd)
.SumAsync(p => (decimal?)p.Amount) ?? 0;
var byCustomer = invoices
.GroupBy(i => new { i.CustomerId, Name = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim() })
.Select(g => new SalesByCustomerDto { CustomerId = g.Key.CustomerId, CustomerName = g.Key.Name, InvoiceCount = g.Count(), TotalInvoiced = g.Sum(i => i.Total), TotalPaid = g.Sum(i => i.AmountPaid), BalanceDue = g.Sum(i => i.BalanceDue) })
.OrderByDescending(c => c.TotalInvoiced).ToList();
var byMonth = invoices
.GroupBy(i => new { i.InvoiceDate.Year, i.InvoiceDate.Month })
.Select(g => new SalesByMonthDto { Year = g.Key.Year, Month = g.Key.Month, Label = new DateTime(g.Key.Year, g.Key.Month, 1).ToString("MMM yyyy"), TotalInvoiced = g.Sum(i => i.Total), TotalCollected = g.Sum(i => i.AmountPaid), InvoiceCount = g.Count() })
.OrderBy(m => m.Year).ThenBy(m => m.Month).ToList();
var invoiceLines = invoices.Select(i => new SalesInvoiceLineDto
{
InvoiceId = i.Id, InvoiceNumber = i.InvoiceNumber,
CustomerName = i.Customer!.IsCommercial ? i.Customer.CompanyName : $"{i.Customer.ContactFirstName} {i.Customer.ContactLastName}".Trim(),
InvoiceDate = i.InvoiceDate, DueDate = i.DueDate, Status = i.Status.ToString(),
SubTotal = i.SubTotal, TaxAmount = i.TaxAmount, Total = i.Total, AmountPaid = i.AmountPaid, BalanceDue = i.BalanceDue,
}).ToList();
var dto = new SalesIncomeReportDto
{
From = fromDate, To = toDate, CompanyName = companyName,
TotalInvoiced = invoices.Sum(i => i.Total), TotalCollected = collectedInPeriod,
TotalTax = invoices.Sum(i => i.TaxAmount), TotalDiscount = invoices.Sum(i => i.DiscountAmount),
InvoiceCount = invoices.Count, CustomerCount = invoices.Select(i => i.CustomerId).Distinct().Count(),
ByCustomer = byCustomer, ByMonth = byMonth, Invoices = invoiceLines,
};
var fromDate = (from ?? new DateTime(DateTime.Today.Year, 1, 1)).Date;
var toDate = (to ?? DateTime.Today).Date;
var companyId = int.TryParse(User.FindFirst("CompanyId")?.Value, out var cid) ? cid : 0;
var dto = await _financialReports.GetSalesAndIncomeAsync(companyId, fromDate, toDate);
var pdfBytes = await _pdfService.GenerateSalesAndIncomePdfAsync(dto);
return inline ? File(pdfBytes, "application/pdf") : File(pdfBytes, "application/pdf", $"SalesAndIncome-{fromDate:yyyyMMdd}-{toDate:yyyyMMdd}.pdf");
}
@@ -1996,8 +1396,8 @@ public class ReportsController : Controller
if (!AllowAccounting()) return RedirectToAction(nameof(Landing));
var now = DateTime.UtcNow;
var today = DateTime.Today;
var allBills = await _context.Bills.Include(b => b.Vendor).Include(b => b.Payments.Where(p => !p.IsDeleted)).Where(b => !b.IsDeleted && b.Status != BillStatus.Voided).ToListAsync();
var allExpenses = await _context.Expenses.Include(e => e.ExpenseAccount).Where(e => !e.IsDeleted).ToListAsync();
var allBills = await _operationalReports.GetActiveBillsAsync();
var allExpenses = await _operationalReports.GetAllExpensesAsync();
var outstandingBills = allBills.Where(b => b.BalanceDue > 0).ToList();
var apAgingBuckets = new List<AgingBucketItem> { new() { Label = "Current (030 days)" }, new() { Label = "3160 days" }, new() { Label = "6190 days" }, new() { Label = "Over 90 days" } };
@@ -2116,7 +1516,7 @@ public class ReportsController : Controller
var now = DateTime.UtcNow;
var completedStatusCodes = new[] { "COMPLETED", "READY_FOR_PICKUP", "DELIVERED" };
var completedJobs = (await _unitOfWork.Jobs.GetAllAsync(false, j => j.JobStatus)).Where(j => completedStatusCodes.Contains(j.JobStatus.StatusCode) && j.CompletedDate.HasValue).ToList();
var allStatusHistory = await _context.JobStatusHistory.Include(h => h.FromStatus).Include(h => h.ToStatus).ToListAsync();
var allStatusHistory = await _operationalReports.GetAllJobStatusHistoryAsync();
var historyByJob = allStatusHistory.GroupBy(h => h.JobId).ToDictionary(g => g.Key, g => g.OrderBy(h => h.ChangedDate).ToList());
var statusDisplayOrder = new[] { "PENDING", "QUOTED", "APPROVED", "IN_PREPARATION", "SANDBLASTING", "MASKING_TAPING", "CLEANING", "IN_OVEN", "COATING", "CURING", "QUALITY_CHECK" };
var statusTimings = new Dictionary<string, (string DisplayName, List<double> Days)>();
@@ -2293,10 +1693,7 @@ public class ReportsController : Controller
.Count(i => i.DueDate.HasValue && (today - i.DueDate.Value).TotalDays > 30);
// Expenses by account
var allExpenses = await _context.Expenses
.Include(e => e.ExpenseAccount)
.Where(e => !e.IsDeleted)
.ToListAsync();
var allExpenses = await _operationalReports.GetAllExpensesAsync();
var expensesByCategory = allExpenses
.Where(e => e.ExpenseAccount != null)
.GroupBy(e => e.ExpenseAccount!.Name)
@@ -2306,7 +1703,7 @@ public class ReportsController : Controller
var totalExpenses = allExpenses.Sum(e => e.Amount);
// Also include bills paid as expenses
var allBills = await _context.Bills.Where(b => !b.IsDeleted).ToListAsync();
var allBills = await _operationalReports.GetActiveBillsAsync();
var billsPaid = allBills.Sum(b => b.AmountPaid);
totalExpenses += billsPaid;
@@ -2400,11 +1797,9 @@ public class ReportsController : Controller
}).ToList();
// Open AP bills
var openBills = await _context.Bills
.Include(b => b.Vendor)
.Where(b => !b.IsDeleted && b.AmountPaid < b.Total
&& b.Status != BillStatus.Voided)
.ToListAsync();
var openBills = (await _operationalReports.GetActiveBillsAsync())
.Where(b => b.AmountPaid < b.Total)
.ToList();
var apItems = openBills.Select(b => new CashFlowApItem
{
@@ -2415,13 +1810,11 @@ public class ReportsController : Controller
}).ToList();
// Active job pipeline (non-terminal jobs not yet invoiced)
var activeJobs = await _context.Jobs
.Include(j => j.Customer)
.Include(j => j.JobStatus)
.Where(j => !j.IsDeleted && j.JobStatus != null && !j.JobStatus.IsTerminalStatus)
var activeJobs = (await _unitOfWork.Jobs.GetBoardJobsAsync())
.Where(j => j.JobStatus != null && !j.JobStatus.IsTerminalStatus)
.OrderByDescending(j => j.FinalPrice > 0 ? j.FinalPrice : j.QuotedPrice)
.Take(30)
.ToListAsync();
.ToList();
var jobItems = activeJobs.Select(j => new CashFlowJobItem
{
@@ -2484,12 +1877,9 @@ public class ReportsController : Controller
var startOfThisMonth = new DateTime(today.Year, today.Month, 1);
var startOfLastMonth = startOfThisMonth.AddMonths(-1);
// Recent bills (last 90 days)
var recentBills = await _context.Bills
.Include(b => b.Vendor)
.Where(b => !b.IsDeleted && b.BillDate >= ninetyDaysAgo)
.OrderByDescending(b => b.BillDate)
.ToListAsync();
// All active bills — used for both recent-bill candidates and all-time vendor history
var allBills = await _operationalReports.GetActiveBillsAsync();
var recentBills = allBills.Where(b => b.BillDate >= ninetyDaysAgo).OrderByDescending(b => b.BillDate).ToList();
var billSummaries = recentBills.Select(b => new AnomalyBillSummary
{
@@ -2501,12 +1891,6 @@ public class ReportsController : Controller
VendorInvoiceNumber = b.VendorInvoiceNumber
}).ToList();
// Vendor history (all time, for averages)
var allBills = await _context.Bills
.Include(b => b.Vendor)
.Where(b => !b.IsDeleted && b.Status != BillStatus.Voided)
.ToListAsync();
var vendorHistory = allBills
.Where(b => b.Vendor != null)
.GroupBy(b => b.Vendor!.CompanyName)
@@ -2525,10 +1909,7 @@ public class ReportsController : Controller
}).ToList();
// Account spend trends (bills + expenses by account this month vs historical avg)
var allExpenses = await _context.Expenses
.Include(e => e.ExpenseAccount)
.Where(e => !e.IsDeleted)
.ToListAsync();
var allExpenses = await _operationalReports.GetAllExpensesAsync();
var accountTrends = allExpenses
.Where(e => e.ExpenseAccount != null)
@@ -2581,7 +1962,7 @@ public class ReportsController : Controller
var companyIdClaim = User.FindFirst("CompanyId")?.Value;
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
{
var company = await _context.Companies.FirstOrDefaultAsync(c => c.Id == companyId);
var company = await _unitOfWork.Companies.GetByIdAsync(companyId);
return company?.CompanyName ?? "Your Company";
}
return "Your Company";
@@ -13,6 +13,7 @@ namespace PowderCoating.Web.Controllers;
/// directly (bypassing the UoW) because it queries across company boundaries and
/// joins plan configs — a pattern that would require multiple unrelated repositories.
/// </summary>
// Intentional exception: cross-tenant MRR/ARR metrics joining Company + SubscriptionPlanConfig; same pattern as CompanyHealthController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class RevenueController : Controller
{
@@ -8,7 +8,6 @@ using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Shared.Constants;
using System.Text.Json;
@@ -20,7 +19,6 @@ public class SetupWizardController : Controller
private readonly IUnitOfWork _unitOfWork;
private readonly ITenantContext _tenantContext;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ApplicationDbContext _context;
private readonly ISeedDataService _seedDataService;
private readonly ILogger<SetupWizardController> _logger;
@@ -28,14 +26,12 @@ public class SetupWizardController : Controller
IUnitOfWork unitOfWork,
ITenantContext tenantContext,
UserManager<ApplicationUser> userManager,
ApplicationDbContext context,
ISeedDataService seedDataService,
ILogger<SetupWizardController> logger)
{
_unitOfWork = unitOfWork;
_tenantContext = tenantContext;
_userManager = userManager;
_context = context;
_seedDataService = seedDataService;
_logger = logger;
}
@@ -70,14 +66,14 @@ public class SetupWizardController : Controller
if (company.Preferences == null)
{
company.Preferences = new CompanyPreferences { CompanyId = companyId };
_context.Set<CompanyPreferences>().Add(company.Preferences);
await _unitOfWork.CompanyPreferences.AddAsync(company.Preferences);
await _unitOfWork.CompleteAsync();
}
if (company.OperatingCosts == null)
{
company.OperatingCosts = new CompanyOperatingCosts { CompanyId = companyId };
_context.Set<CompanyOperatingCosts>().Add(company.OperatingCosts);
await _unitOfWork.CompanyOperatingCosts.AddAsync(company.OperatingCosts);
await _unitOfWork.CompleteAsync();
}
@@ -1,7 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
using System.Text;
@@ -15,12 +14,12 @@ namespace PowderCoating.Web.Controllers;
[Authorize(Policy = AppConstants.Policies.CompanyAdminOnly)]
public class SmsConsentAuditController : Controller
{
private readonly ApplicationDbContext _context;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<SmsConsentAuditController> _logger;
public SmsConsentAuditController(ApplicationDbContext context, ILogger<SmsConsentAuditController> logger)
public SmsConsentAuditController(IUnitOfWork unitOfWork, ILogger<SmsConsentAuditController> logger)
{
_context = context;
_unitOfWork = unitOfWork;
_logger = logger;
}
@@ -31,14 +30,12 @@ public class SmsConsentAuditController : Controller
{
try
{
var query = _context.Customers
.AsNoTracking()
.Where(c => !c.IsDeleted);
var allCustomers = await _unitOfWork.Customers.GetAllAsync();
if (!string.IsNullOrWhiteSpace(search))
{
var s = search.Trim().ToLower();
query = query.Where(c =>
allCustomers = allCustomers.Where(c =>
(c.ContactFirstName != null && c.ContactFirstName.ToLower().Contains(s)) ||
(c.ContactLastName != null && c.ContactLastName.ToLower().Contains(s)) ||
(c.CompanyName != null && c.CompanyName.ToLower().Contains(s)) ||
@@ -46,43 +43,25 @@ public class SmsConsentAuditController : Controller
(c.Phone != null && c.Phone.Contains(s)));
}
var customers = await query
.Select(c => new
{
c.Id,
c.CompanyName,
c.ContactFirstName,
c.ContactLastName,
c.IsCommercial,
c.Phone,
c.MobilePhone,
c.NotifyBySms,
c.SmsConsentedAt,
c.SmsConsentMethod,
c.SmsOptedOutAt
})
var allRows = allCustomers
.OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
.ToListAsync();
.Select(c => new SmsConsentRow
{
CustomerId = c.Id,
CustomerName = GetDisplayName(c.IsCommercial, c.CompanyName, c.ContactFirstName, c.ContactLastName),
Phone = c.Phone,
MobilePhone = c.MobilePhone,
NotifyBySms = c.NotifyBySms,
ConsentedAt = c.SmsConsentedAt,
ConsentMethod = c.SmsConsentMethod,
OptedOutAt = c.SmsOptedOutAt,
SmsStatus = ResolveSmsStatus(c.NotifyBySms, c.SmsConsentedAt, c.SmsOptedOutAt)
}).ToList();
var allRows = customers.Select(c => new SmsConsentRow
{
CustomerId = c.Id,
CustomerName = GetDisplayName(c.IsCommercial, c.CompanyName, c.ContactFirstName, c.ContactLastName),
Phone = c.Phone,
MobilePhone = c.MobilePhone,
NotifyBySms = c.NotifyBySms,
ConsentedAt = c.SmsConsentedAt,
ConsentMethod = c.SmsConsentMethod,
OptedOutAt = c.SmsOptedOutAt,
SmsStatus = ResolveSmsStatus(c.NotifyBySms, c.SmsConsentedAt, c.SmsOptedOutAt)
}).ToList();
var optedIn = allRows.Count(r => r.SmsStatus == "active");
var optedOut = allRows.Count(r => r.SmsStatus == "opted-out");
var never = allRows.Count(r => r.SmsStatus == "never");
// Stat counts across unfiltered set
var optedIn = allRows.Count(r => r.SmsStatus == "active");
var optedOut = allRows.Count(r => r.SmsStatus == "opted-out");
var never = allRows.Count(r => r.SmsStatus == "never");
// Apply filter
var filtered = filter switch
{
"opted-in" => allRows.Where(r => r.SmsStatus == "active").ToList(),
@@ -119,25 +98,9 @@ public class SmsConsentAuditController : Controller
{
try
{
var customers = await _context.Customers
.AsNoTracking()
.Where(c => !c.IsDeleted)
.Select(c => new
{
c.Id,
c.CompanyName,
c.ContactFirstName,
c.ContactLastName,
c.IsCommercial,
c.Phone,
c.MobilePhone,
c.NotifyBySms,
c.SmsConsentedAt,
c.SmsConsentMethod,
c.SmsOptedOutAt
})
var customers = (await _unitOfWork.Customers.GetAllAsync())
.OrderBy(c => c.CompanyName ?? c.ContactLastName ?? c.ContactFirstName)
.ToListAsync();
.ToList();
var sb = new StringBuilder();
sb.AppendLine("Customer Name,Phone,Mobile Phone,SMS Status,Consented At (UTC),Consent Method,Opted Out At (UTC)");
@@ -14,6 +14,7 @@ namespace PowderCoating.Web.Controllers;
/// delivery, and view the raw JSON payload Stripe sent. Restricted to SuperAdmin because the raw
/// event payloads may contain sensitive subscription and billing information.
/// </summary>
// Intentional exception: StripeWebhookEvents is a platform infrastructure table (not a business entity); same reasoning as StripeWebhookController. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class StripeEventsController : Controller
{
@@ -19,6 +19,7 @@ namespace PowderCoating.Web.Controllers;
/// because subscription management is a platform-level concern, not a tenant-domain concern,
/// and requires direct access to the Company entity which lives outside the tenant data layer.
/// </summary>
// Intentional exception: cross-tenant Company management with raw SQL audit log writes that bypass the tenant pipeline; platform-level concern outside IUnitOfWork scope. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class SubscriptionManagementController : Controller
{
@@ -1,8 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Core.Interfaces;
using PowderCoating.Shared.Constants;
namespace PowderCoating.Web.Controllers;
@@ -15,12 +14,12 @@ namespace PowderCoating.Web.Controllers;
[EnableRateLimiting(AppConstants.RateLimitPolicies.Public)]
public class UnsubscribeController : Controller
{
private readonly ApplicationDbContext _context;
private readonly IUnitOfWork _unitOfWork;
private readonly ILogger<UnsubscribeController> _logger;
public UnsubscribeController(ApplicationDbContext context, ILogger<UnsubscribeController> logger)
public UnsubscribeController(IUnitOfWork unitOfWork, ILogger<UnsubscribeController> logger)
{
_context = context;
_unitOfWork = unitOfWork;
_logger = logger;
}
@@ -38,11 +37,10 @@ public class UnsubscribeController : Controller
try
{
// Bypass global query filters so we can find the customer by token
// ignoreQueryFilters=true so we can find the customer by token
// regardless of company context (the user clicking is not authenticated)
var customer = await _context.Customers
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.UnsubscribeToken == token && !c.IsDeleted);
var customer = await _unitOfWork.Customers.FirstOrDefaultAsync(
c => c.UnsubscribeToken == token && !c.IsDeleted, ignoreQueryFilters: true);
if (customer == null)
{
@@ -52,7 +50,6 @@ public class UnsubscribeController : Controller
if (!customer.NotifyByEmail)
{
// Already unsubscribed — show success page anyway (idempotent)
ViewBag.CustomerName = customer.CompanyName ?? $"{customer.ContactFirstName} {customer.ContactLastName}".Trim();
ViewBag.AlreadyUnsubscribed = true;
return View("EmailConfirm");
@@ -60,7 +57,7 @@ public class UnsubscribeController : Controller
customer.NotifyByEmail = false;
customer.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Customer {CustomerId} unsubscribed from email notifications via link", customer.Id);
@@ -88,9 +85,8 @@ public class UnsubscribeController : Controller
try
{
var company = await _context.Companies
.IgnoreQueryFilters()
.FirstOrDefaultAsync(c => c.MarketingUnsubscribeToken == token && !c.IsDeleted);
var company = await _unitOfWork.Companies.FirstOrDefaultAsync(
c => c.MarketingUnsubscribeToken == token && !c.IsDeleted, ignoreQueryFilters: true);
if (company == null)
{
@@ -105,7 +101,7 @@ public class UnsubscribeController : Controller
{
company.MarketingEmailOptOut = true;
company.UpdatedAt = DateTime.UtcNow;
await _context.SaveChangesAsync();
await _unitOfWork.CompleteAsync();
_logger.LogInformation("Company {CompanyId} opted out of broadcast emails via link", company.Id);
}
@@ -15,6 +15,7 @@ namespace PowderCoating.Web.Controllers;
/// efficient bulk-count GROUP BY queries in parallel rather than loading
/// each company's data through separate repository calls.
/// </summary>
// Intentional exception: cross-tenant bulk GROUP BY quota queries that would require O(n) repository round-trips if routed through IUnitOfWork. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class UsageQuotaController : Controller
{
@@ -8,6 +8,7 @@ using System.Text;
namespace PowderCoating.Web.Controllers;
// Intentional exception: queries ASP.NET Identity ApplicationUser across all tenants with Include(u => u.Company); Identity entities live outside IUnitOfWork. See docs/DATA_ACCESS_ARCHITECTURE.md — Permanent Exceptions.
[Authorize(Policy = AppConstants.Policies.SuperAdminOnly)]
public class UserActivityController : Controller
{
@@ -10,7 +10,6 @@ using PowderCoating.Application.DTOs.Vendor;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
namespace PowderCoating.Web.Controllers;
@@ -28,20 +27,17 @@ public class VendorsController : Controller
private readonly IMapper _mapper;
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<VendorsController> _logger;
private readonly ApplicationDbContext _context;
public VendorsController(
IUnitOfWork unitOfWork,
IMapper mapper,
UserManager<ApplicationUser> userManager,
ILogger<VendorsController> logger,
ApplicationDbContext context)
ILogger<VendorsController> logger)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
_userManager = userManager;
_logger = logger;
_context = context;
}
/// <summary>
@@ -163,7 +159,7 @@ public class VendorsController : Controller
var vendorDto = _mapper.Map<VendorDto>(vendor);
if (vendor.DefaultExpenseAccountId.HasValue)
{
var acct = await _context.Accounts.FindAsync(vendor.DefaultExpenseAccountId.Value);
var acct = await _unitOfWork.Accounts.GetByIdAsync(vendor.DefaultExpenseAccountId.Value);
vendorDto.DefaultExpenseAccountName = acct != null ? $"{acct.AccountNumber} {acct.Name}" : null;
}
return View(vendorDto);
@@ -395,14 +391,13 @@ public class VendorsController : Controller
/// </summary>
private async Task PopulateExpenseAccountsAsync()
{
var accounts = await _context.Accounts
.Where(a => !a.IsDeleted && a.IsActive &&
(a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods ||
a.AccountType == AccountType.Asset))
var accounts = (await _unitOfWork.Accounts.FindAsync(
a => a.IsActive && (a.AccountType == AccountType.Expense ||
a.AccountType == AccountType.CostOfGoods ||
a.AccountType == AccountType.Asset)))
.OrderBy(a => a.AccountNumber)
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToListAsync();
.ToList();
accounts.Insert(0, new SelectListItem("— None —", ""));
ViewBag.ExpenseAccounts = accounts;
+66
View File
@@ -6,6 +6,7 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
using PowderCoating.Core.Interfaces.Services;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Infrastructure.Repositories;
using PowderCoating.Infrastructure.Services;
@@ -209,6 +210,11 @@ builder.Services.AddScoped<ICompanyLogoService, CompanyLogoService>();
builder.Services.AddScoped<IEquipmentManualService, EquipmentManualService>();
builder.Services.AddScoped<IPricingCalculationService, PricingCalculationService>();
builder.Services.AddScoped<IPowderInsightsService, PowderInsightsService>();
builder.Services.AddScoped<IAuditLogService, AuditLogService>();
builder.Services.AddScoped<IAiUsageReportService, AiUsageReportService>();
builder.Services.AddScoped<IDashboardReadService, DashboardReadService>();
builder.Services.AddScoped<ICompanyListService, CompanyListService>();
builder.Services.AddScoped<ICompanyDataPurgeService, CompanyDataPurgeService>();
builder.Services.AddScoped<IPdfService, PdfService>();
builder.Services.AddScoped<ISeedDataService, SeedDataService>();
builder.Services.AddScoped<ICompanyConfigHealthService, CompanyConfigHealthService>();
@@ -835,6 +841,11 @@ using (var scope = app.Services.CreateScope())
// Fail fast with a clear message rather than a cryptic runtime error later.
ValidateRequiredConfiguration(app.Configuration, app.Environment);
// ── Data access architecture enforcement ─────────────────────────────────────
// Throws at startup if any non-exempt controller injects ApplicationDbContext directly.
// This is the Phase 4 gate: the app cannot start with a violation.
EnforceDataAccessArchitecture();
try
{
Log.Information("Starting web application");
@@ -894,6 +905,61 @@ static void ValidateRequiredConfiguration(IConfiguration config, IWebHostEnviron
}
}
// ── Data access architecture enforcement ─────────────────────────────────────────
/// <summary>
/// Scans every Controller subclass in the Web assembly at startup and throws if any
/// non-exempt controller declares ApplicationDbContext as a constructor parameter.
/// This enforces the rule defined in docs/DATA_ACCESS_ARCHITECTURE.md — if a developer
/// adds a new controller that injects ApplicationDbContext directly, the app will refuse
/// to start with a clear message naming the violator.
/// </summary>
static void EnforceDataAccessArchitecture()
{
// Controllers in this set are documented permanent exceptions — see DATA_ACCESS_ARCHITECTURE.md.
var permanentExceptions = new HashSet<string>
{
"StripeWebhookController",
"WebhooksController",
"PaymentController",
"RegistrationController",
"DataExportController",
"AccountDataExportController",
"DataPurgeController",
"SystemInfoController",
"SystemLogsController",
"CompanyHealthController",
"PasskeyController",
"AuditLogController",
"UserActivityController",
"EmailBroadcastController",
"RevenueController",
"StripeEventsController",
"SubscriptionManagementController",
"UsageQuotaController",
};
var violators = typeof(Program).Assembly.GetTypes()
.Where(t => t.IsClass && !t.IsAbstract
&& typeof(Microsoft.AspNetCore.Mvc.Controller).IsAssignableFrom(t)
&& !permanentExceptions.Contains(t.Name))
.Where(t => t.GetConstructors()
.Any(ctor => ctor.GetParameters()
.Any(p => p.ParameterType == typeof(ApplicationDbContext))))
.Select(t => t.Name)
.OrderBy(n => n)
.ToList();
if (violators.Count == 0) return;
var names = string.Join(", ", violators);
throw new InvalidOperationException(
$"DATA ACCESS VIOLATION — {violators.Count} controller(s) inject ApplicationDbContext directly " +
$"and are not in the permanent exceptions list.\n" +
$"Violators: {names}\n" +
$"Fix: route data access through IUnitOfWork. " +
$"To add a permanent exception, update both the controller comment and docs/DATA_ACCESS_ARCHITECTURE.md.");
}
// ── Serilog DB sink column configuration ─────────────────────────────────────────
static ColumnOptions BuildLogColumnOptions()
{