Phase 2: Eliminate ApplicationDbContext from domain controllers

Migrated InvoicesController, QuotesController, JobsController, BillsController,
PurchaseOrdersController, and CustomersController to route all data access
through IUnitOfWork typed/generic repositories instead of injecting
ApplicationDbContext directly.

New typed repositories added: IJobRepository (GetScheduledJobsForDateAsync,
GetActiveJobsForMobileAsync, LoadForCostingAsync), INotificationLogRepository
(GetLatestForJobAsync, GetAllForJobAsync), IQuoteRepository (GetItemsWithCoatsAsync
with CatalogItem eager load + AsNoTracking), and IJobRepository.GetOrphanedConversionJobAsync.

All EF complex include chains relocated into repository methods; controllers now
call named query methods rather than composing raw IQueryable chains.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 21:20:39 -04:00
parent 80b0e547cc
commit 90bc0d965f
20 changed files with 730 additions and 878 deletions
@@ -6,7 +6,6 @@ using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Application.DTOs.AI;
using PowderCoating.Application.DTOs.Common;
using PowderCoating.Application.DTOs.Quote;
@@ -15,7 +14,6 @@ using PowderCoating.Application.Services;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Enums;
using PowderCoating.Core.Interfaces;
using PowderCoating.Infrastructure.Data;
using PowderCoating.Web.Extensions;
using PowderCoating.Web.Helpers;
@@ -30,7 +28,6 @@ public class QuotesController : Controller
private readonly UserManager<ApplicationUser> _userManager;
private readonly ILogger<QuotesController> _logger;
private readonly IPdfService _pdfService;
private readonly ApplicationDbContext _context;
private readonly ITenantContext _tenantContext;
private readonly IMeasurementConversionService _measurementService;
private readonly ILookupCacheService _lookupCache;
@@ -51,7 +48,6 @@ public class QuotesController : Controller
UserManager<ApplicationUser> userManager,
ILogger<QuotesController> logger,
IPdfService pdfService,
ApplicationDbContext context,
ITenantContext tenantContext,
IMeasurementConversionService measurementService,
ILookupCacheService lookupCache,
@@ -71,7 +67,6 @@ public class QuotesController : Controller
_userManager = userManager;
_logger = logger;
_pdfService = pdfService;
_context = context;
_tenantContext = tenantContext;
_measurementService = measurementService;
_lookupCache = lookupCache;
@@ -234,11 +229,10 @@ public class QuotesController : Controller
var approvedConvertedIds = quoteStatuses
.Where(s => s.StatusCode == "APPROVED" || s.StatusCode == "CONVERTED")
.Select(s => s.Id).ToList();
var allCompanyQuotes = _context.Quotes
.Where(q => q.CompanyId == companyId && !q.IsDeleted);
ViewBag.StatOpenCount = await allCompanyQuotes.CountAsync(q => draftSentIds.Contains(q.QuoteStatusId));
ViewBag.StatApprovedCount = await allCompanyQuotes.CountAsync(q => approvedConvertedIds.Contains(q.QuoteStatusId));
ViewBag.StatTotalValue = await allCompanyQuotes.SumAsync(q => (decimal?)q.Total) ?? 0m;
var indexStats = await _unitOfWork.Quotes.GetIndexStatsAsync(draftSentIds, approvedConvertedIds);
ViewBag.StatOpenCount = indexStats.OpenCount;
ViewBag.StatApprovedCount = indexStats.ApprovedConvertedCount;
ViewBag.StatTotalValue = indexStats.TotalValue;
// Calibration nudge — suppress when named blast setups exist OR legacy CFM is set
var costs = (await _unitOfWork.CompanyOperatingCosts.FindAsync(c => c.CompanyId == companyId)).FirstOrDefault();
@@ -275,32 +269,14 @@ public class QuotesController : Controller
try
{
// Load quote with items and their related entities
var quote = await _unitOfWork.Quotes.GetByIdAsync(
id.Value,
false,
q => q.QuoteItems,
q => q.Customer,
q => q.PreparedBy,
q => q.QuoteStatus,
q => q.QuotePrepServices,
q => q.OvenCost
);
// Load quote with all navigations needed for the Details view
var quote = await _unitOfWork.Quotes.LoadForDetailsAsync(id.Value);
if (quote == null)
{
return NotFound();
}
// Load prep services with their details
var quotePrepServices = await _context.QuotePrepServices
.Where(qps => qps.QuoteId == id.Value && !qps.IsDeleted)
.Include(qps => qps.PrepService)
.ToListAsync();
// Assign to quote so AutoMapper can map them
quote.QuotePrepServices = quotePrepServices;
var quoteDto = _mapper.Map<QuoteDto>(quote);
// Get customer info if exists
@@ -353,19 +329,7 @@ public class QuotesController : Controller
}
}
// Get quote items with their related entities (Coats, CatalogItem, and PrepServices)
var quoteItems = await _context.QuoteItems
.Where(qi => qi.QuoteId == id.Value && !qi.IsDeleted)
.Include(qi => qi.Coats)
.ThenInclude(c => c.InventoryItem)
.Include(qi => qi.Coats)
.ThenInclude(c => c.Vendor)
.Include(qi => qi.CatalogItem)
.Include(qi => qi.PrepServices)
.ThenInclude(ps => ps.PrepService)
.ToListAsync();
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quote.QuoteItems);
// DEBUG: Log coat data
_logger.LogInformation($"=== DETAILS VIEW: Quote {id} has {quoteDto.QuoteItems.Count} items ===");
@@ -427,13 +391,7 @@ public class QuotesController : Controller
}
// Load change history
var changeHistories = await _context.QuoteChangeHistories
.Where(h => h.QuoteId == id.Value && !h.IsDeleted)
.Include(h => h.ChangedBy)
.OrderByDescending(h => h.ChangedAt)
.AsNoTracking()
.ToListAsync();
var changeHistories = await _unitOfWork.Quotes.GetChangeHistoryAsync(id.Value);
var changeHistoryDtos = _mapper.Map<List<QuoteChangeHistoryDto>>(changeHistories);
ViewBag.ChangeHistory = changeHistoryDtos;
@@ -458,12 +416,9 @@ public class QuotesController : Controller
await PopulateEmailNotificationDefaultsAsync(currentUserDetails.CompanyId);
// Load deposits recorded against this quote
var quoteDeposits = await _context.Set<Deposit>()
.Where(d => !d.IsDeleted && d.CompanyId == quote.CompanyId && d.QuoteId == id.Value)
.Include(d => d.RecordedBy)
var quoteDeposits = (await _unitOfWork.Deposits.FindAsync(d => d.QuoteId == id.Value, false, d => d.RecordedBy))
.OrderByDescending(d => d.ReceivedDate)
.AsNoTracking()
.ToListAsync();
.ToList();
ViewBag.Deposits = quoteDeposits;
// Customer list for inline customer-change dropdown
@@ -564,16 +519,7 @@ public class QuotesController : Controller
}
}
// Get quote items with their related entities (Coats and CatalogItem)
var quoteItems = await _context.QuoteItems
.Where(qi => qi.QuoteId == id.Value && !qi.IsDeleted)
.Include(qi => qi.Coats)
.ThenInclude(c => c.InventoryItem)
.Include(qi => qi.Coats)
.ThenInclude(c => c.Vendor)
.Include(qi => qi.CatalogItem)
.ToListAsync();
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value);
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
// Get company info and logo
@@ -632,10 +578,8 @@ public class QuotesController : Controller
};
// Load company preferences for PDF template settings
var prefs = await _context.CompanyPreferences
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted);
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted, ignoreQueryFilters: true);
var template = new Application.DTOs.Company.QuoteTemplateSettingsDto
{
@@ -1186,11 +1130,7 @@ public class QuotesController : Controller
_logger.LogWarning(ex, "Notification failed for quote {Id}", quote.Id);
}
var quoteCreateNotifLog = await _context.NotificationLogs
.IgnoreQueryFilters()
.Where(n => n.QuoteId == quote.Id)
.OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync();
var quoteCreateNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(quote.Id);
this.SetNotificationResultToast(quoteCreateNotifLog);
}
@@ -1231,13 +1171,7 @@ public class QuotesController : Controller
}
// Get quote items with their coats, prep services and catalog item
var quoteItems = await _context.QuoteItems
.Where(qi => qi.QuoteId == id.Value && !qi.IsDeleted)
.Include(qi => qi.Coats)
.ThenInclude(c => c.InventoryItem)
.Include(qi => qi.PrepServices)
.Include(qi => qi.CatalogItem)
.ToListAsync();
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value);
_logger.LogInformation("=== LOADING QUOTE {QuoteId} FOR EDIT ===", id.Value);
foreach (var item in quoteItems)
@@ -2230,15 +2164,13 @@ public class QuotesController : Controller
// Check for an orphaned partial job (previous conversion attempt that failed mid-way).
// This can happen if SaveChangesAsync succeeded for the Job row but failed for JobItems.
// The unique index on Jobs.QuoteId would block a retry — clean it up first.
var orphanedJob = await _context.Jobs
.IgnoreQueryFilters()
.FirstOrDefaultAsync(j => j.QuoteId == id && j.CompanyId == quote.CompanyId);
var orphanedJob = await _unitOfWork.Jobs.GetOrphanedConversionJobAsync(id, quote.CompanyId);
if (orphanedJob != null)
{
_logger.LogWarning("Found orphaned job {JobNumber} (Id={JobId}) from a previous failed conversion of quote {QuoteId}. Cleaning up.",
orphanedJob.JobNumber, orphanedJob.Id, id);
_context.Jobs.Remove(orphanedJob);
await _context.SaveChangesAsync();
await _unitOfWork.Jobs.DeleteAsync(orphanedJob);
await _unitOfWork.CompleteAsync();
}
var currentUser = await _userManager.GetUserAsync(User);
@@ -2351,25 +2283,11 @@ public class QuotesController : Controller
}
}
// Get quote items with their related entities (Coats and CatalogItem)
var quoteItems = await _context.QuoteItems
.Where(qi => qi.QuoteId == id.Value && !qi.IsDeleted)
.Include(qi => qi.Coats)
.ThenInclude(c => c.InventoryItem)
.Include(qi => qi.Coats)
.ThenInclude(c => c.Vendor)
.Include(qi => qi.CatalogItem)
.ToListAsync();
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value);
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
// Warn on confirmation page if a job is linked
var linkedJob = await _context.Jobs
.AsNoTracking()
.Where(j => j.QuoteId == id.Value && !j.IsDeleted)
.Select(j => new { j.Id, j.JobNumber })
.FirstOrDefaultAsync();
var linkedJob = await _unitOfWork.Jobs.FirstOrDefaultAsync(j => j.QuoteId == id.Value);
if (linkedJob != null)
{
ViewBag.LinkedJobId = linkedJob.Id;
@@ -2403,12 +2321,7 @@ public class QuotesController : Controller
}
// Block deletion if a job was created from this quote
var linkedJob = await _context.Jobs
.AsNoTracking()
.Where(j => j.QuoteId == id && !j.IsDeleted)
.Select(j => new { j.Id, j.JobNumber })
.FirstOrDefaultAsync();
var linkedJob = await _unitOfWork.Jobs.FirstOrDefaultAsync(j => j.QuoteId == id);
if (linkedJob != null)
{
this.ToastError($"Quote {quote.QuoteNumber} cannot be deleted because Job {linkedJob.JobNumber} was created from it. Delete the job first, or keep the quote as a record.");
@@ -2465,8 +2378,8 @@ public class QuotesController : Controller
var currentUser = await _userManager.GetUserAsync(User);
// Find the Approved status for this company
var approvedStatus = await _context.QuoteStatusLookups
.FirstOrDefaultAsync(s => s.StatusCode == "APPROVED" && s.CompanyId == currentUser!.CompanyId);
var approvedStatus = await _unitOfWork.QuoteStatusLookups.FirstOrDefaultAsync(
s => s.StatusCode == "APPROVED" && s.CompanyId == currentUser!.CompanyId);
if (approvedStatus == null)
{
@@ -2518,11 +2431,7 @@ public class QuotesController : Controller
_logger.LogWarning(ex, "Notification failed for quote {Id}", id);
}
var approveNotifLog = await _context.NotificationLogs
.IgnoreQueryFilters()
.Where(n => n.QuoteId == id)
.OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync();
var approveNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id);
this.SetNotificationResultToast(approveNotifLog);
}
@@ -2766,9 +2675,8 @@ public class QuotesController : Controller
/// </summary>
private async Task SetDefaultTermsAsync(int companyId)
{
var prefs = await _context.CompanyPreferences
.IgnoreQueryFilters().AsNoTracking()
.FirstOrDefaultAsync(p => p.CompanyId == companyId && !p.IsDeleted);
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
ViewBag.DefaultTerms = prefs?.QtDefaultTerms;
var costs = await _pricingService.GetOperatingCostsAsync(companyId);
@@ -2841,13 +2749,7 @@ public class QuotesController : Controller
}
}
var quoteItems = await _context.QuoteItems
.Where(qi => qi.QuoteId == quoteId && !qi.IsDeleted)
.Include(qi => qi.Coats).ThenInclude(c => c.InventoryItem)
.Include(qi => qi.Coats).ThenInclude(c => c.Vendor)
.Include(qi => qi.CatalogItem)
.ToListAsync();
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quoteId);
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
@@ -2864,10 +2766,8 @@ public class QuotesController : Controller
PrimaryContactEmail = company.PrimaryContactEmail
};
var prefs = await _context.CompanyPreferences
.IgnoreQueryFilters()
.AsNoTracking()
.FirstOrDefaultAsync(p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted);
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == currentUser.CompanyId && !p.IsDeleted, ignoreQueryFilters: true);
var template = new Application.DTOs.Company.QuoteTemplateSettingsDto
{
@@ -2895,23 +2795,14 @@ public class QuotesController : Controller
var now = DateTime.UtcNow;
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
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}";
// IgnoreQueryFilters so soft-deleted quotes are counted (prevents number reuse)
// Explicit CompanyId filter scopes to current company only
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)
@@ -3017,15 +2908,7 @@ public class QuotesController : Controller
// Always reload quote items with full coat/prep-service data so this works
// regardless of which caller loaded the quote (some callers don't include coats).
var fullItems = await _context.QuoteItems
.Where(qi => qi.QuoteId == quote.Id && !qi.IsDeleted)
.Include(qi => qi.Coats)
.ThenInclude(c => c.InventoryItem)
.Include(qi => qi.Coats)
.ThenInclude(c => c.Vendor)
.Include(qi => qi.PrepServices)
.AsNoTracking()
.ToListAsync();
var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quote.Id);
// Do NOT assign fullItems to quote.QuoteItems — quote is a tracked entity and assigning
// no-tracking children (which may share InventoryItem instances) causes EF identity conflicts.
@@ -3166,9 +3049,8 @@ public class QuotesController : Controller
// Aggregate unique prep services from all quote items and copy to job
// Load from DB directly to ensure prep services are available regardless of caller's includes
var quoteItemIds = fullItems.Select(qi => qi.Id).ToList();
var itemPrepServices = await _context.QuoteItemPrepServices
.Where(ps => quoteItemIds.Contains(ps.QuoteItemId) && !ps.IsDeleted)
.ToListAsync();
var itemPrepServices = (await _unitOfWork.QuoteItemPrepServices.FindAsync(
ps => quoteItemIds.Contains(ps.QuoteItemId))).ToList();
var uniquePrepServiceIds = itemPrepServices
.Select(ps => ps.PrepServiceId)
.Distinct()
@@ -3178,7 +3060,7 @@ public class QuotesController : Controller
{
foreach (var prepServiceId in uniquePrepServiceIds)
{
await _context.JobPrepServices.AddAsync(new JobPrepService
await _unitOfWork.JobPrepServices.AddAsync(new JobPrepService
{
JobId = job.Id,
PrepServiceId = prepServiceId,
@@ -3186,7 +3068,7 @@ public class QuotesController : Controller
CreatedAt = DateTime.UtcNow
});
}
await _context.SaveChangesAsync();
await _unitOfWork.SaveChangesAsync();
_logger.LogInformation("Copied {Count} unique prep services to job {JobNumber}",
uniquePrepServiceIds.Count, job.JobNumber);
}
@@ -3200,10 +3082,8 @@ public class QuotesController : Controller
// AI analysis photos are copied with IsAiAnalysisPhoto=true so they don't count against subscription limits
try
{
var quotePhotos = await _context.QuotePhotos
.IgnoreQueryFilters()
.Where(p => p.QuoteId == quote.Id && !p.IsDeleted)
.ToListAsync();
var quotePhotos = (await _unitOfWork.QuotePhotos.FindAsync(
p => p.QuoteId == quote.Id && !p.IsDeleted, ignoreQueryFilters: true)).ToList();
foreach (var qp in quotePhotos)
{
@@ -3223,7 +3103,7 @@ public class QuotesController : Controller
if (saved)
{
await _context.JobPhotos.AddAsync(new JobPhoto
await _unitOfWork.JobPhotos.AddAsync(new JobPhoto
{
JobId = job.Id,
CompanyId = quote.CompanyId,
@@ -3239,7 +3119,7 @@ public class QuotesController : Controller
});
}
}
await _context.SaveChangesAsync();
await _unitOfWork.SaveChangesAsync();
}
catch (Exception photoEx)
{
@@ -3263,23 +3143,14 @@ public class QuotesController : Controller
var month = DateTime.Now.Month.ToString("D2");
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var prefs = await _context.CompanyPreferences
.IgnoreQueryFilters()
.Where(p => p.CompanyId == companyId && !p.IsDeleted)
.Select(p => new { p.JobNumberPrefix })
.FirstOrDefaultAsync();
var prefs = await _unitOfWork.CompanyPreferences.FirstOrDefaultAsync(
p => p.CompanyId == companyId && !p.IsDeleted, ignoreQueryFilters: true);
var jobPrefix = !string.IsNullOrWhiteSpace(prefs?.JobNumberPrefix) ? prefs.JobNumberPrefix : "JOB";
var prefix = $"{jobPrefix}-{year}{month}";
// IgnoreQueryFilters so soft-deleted jobs are counted (prevents number reuse)
// Explicit CompanyId filter scopes to current company only
var lastJobNumber = await _context.Jobs
.IgnoreQueryFilters()
.Where(j => j.CompanyId == companyId && j.JobNumber.StartsWith(prefix))
.OrderByDescending(j => j.JobNumber)
.Select(j => j.JobNumber)
.FirstOrDefaultAsync();
var lastJobNumber = await _unitOfWork.Jobs.GetLastJobNumberByPrefixAsync(companyId, prefix);
int nextNumber = 1;
if (lastJobNumber != null)
@@ -3352,11 +3223,7 @@ public class QuotesController : Controller
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename);
// Check the most recent log entry to get actual send status
var latestLog = await _context.NotificationLogs
.IgnoreQueryFilters()
.Where(n => n.QuoteId == id)
.OrderByDescending(n => n.SentAt)
.FirstOrDefaultAsync();
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id);
if (latestLog?.Status == NotificationStatus.Failed)
return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" });
@@ -3383,16 +3250,10 @@ public class QuotesController : Controller
public async Task<IActionResult> NotificationsSent(int id)
{
var tz = ViewBag.CompanyTimeZone as string;
var raw = await _context.NotificationLogs
.IgnoreQueryFilters()
.Where(n => n.QuoteId == id)
.OrderByDescending(n => n.SentAt)
.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(),
Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage, n.SentAt })
.ToListAsync();
var logs = raw.Select(n => new { n.Id, n.Channel, n.Type, n.Status, n.RecipientName,
n.Recipient, n.Subject, n.ErrorMessage, SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") });
var rawLogs = await _unitOfWork.NotificationLogs.GetAllForQuoteAsync(id);
var logs = rawLogs.Select(n => new { n.Id, Channel = n.Channel.ToString(), Type = n.NotificationType.ToString(),
Status = n.Status.ToString(), n.RecipientName, n.Recipient, n.Subject, n.ErrorMessage,
SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") });
return Json(logs);
}