Multi-tenancy hardening: explicit companyId on all typed repository methods

All typed repository methods that previously relied solely on global query
filters now require an explicit companyId parameter, providing defense-in-
depth so IgnoreQueryFilters calls cannot leak cross-tenant data.

- IBillRepository/BillRepository: GetForIndexAsync, LoadForViewAsync,
  LoadForEditAsync, GetLastBillNumberAsync, GetLastPaymentNumberAsync,
  GetForDateRangeAsync all scoped to companyId
- IJobRepository/JobRepository: LoadForDetailsAsync, LoadForEditAsync,
  LoadForStatusChangeAsync, GetChangeHistoryAsync,
  LoadForTemplateSnapshotAsync, GetReworkJobCountAsync
- IQuoteRepository/QuoteRepository: LoadForDetailsAsync,
  GetChangeHistoryAsync, GetItemsWithCoatsAsync
- IInvoiceRepository/InvoiceRepository: LoadForViewAsync
- ICustomerRepository/CustomerRepository: LoadForDetailsAsync
- INotificationLogRepository/NotificationLogRepository: all 6 FK methods
- BillsController: ITenantContext injected, all call sites updated
- AccountingExportController, InvoicesController, JobsController,
  JobTemplatesController, QuotesController: call sites updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 19:12:23 -04:00
parent 8f11e00a0a
commit 54defc158f
18 changed files with 141 additions and 127 deletions
@@ -81,7 +81,7 @@ public class AccountingExportController : Controller
.OrderBy(e => e.Date)
.ToList();
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(start, end);
var bills = await _unitOfWork.Bills.GetForDateRangeAsync(companyId, start, end);
var customers = (await _unitOfWork.Customers.FindAsync(c => c.CompanyId == companyId))
.OrderBy(c => c.CompanyName ?? c.ContactFirstName)
@@ -35,6 +35,7 @@ public class BillsController : Controller
private readonly IAzureBlobStorageService _blobStorage;
private readonly StorageSettings _storageSettings;
private readonly IAiUsageLogger _usageLogger;
private readonly ITenantContext _tenantContext;
public BillsController(
IUnitOfWork unitOfWork,
@@ -45,7 +46,8 @@ public class BillsController : Controller
IAccountingAiService accountingAi,
IAzureBlobStorageService blobStorage,
IOptions<StorageSettings> storageSettings,
IAiUsageLogger usageLogger)
IAiUsageLogger usageLogger,
ITenantContext tenantContext)
{
_unitOfWork = unitOfWork;
_mapper = mapper;
@@ -56,6 +58,7 @@ public class BillsController : Controller
_blobStorage = blobStorage;
_storageSettings = storageSettings.Value;
_usageLogger = usageLogger;
_tenantContext = tenantContext;
}
// -- Index ----------------------------------------------------------------
@@ -64,7 +67,7 @@ public class BillsController : Controller
/// Lists bills and direct expenses in a unified AP ledger view. The <paramref name="type"/>
/// parameter lets the caller pin the list to Bills only, Expenses only, or both (null).
/// Expenses are inherently fully paid so they are always excluded when the caller filters to
/// "Unpaid" or "Overdue" preventing them from inflating the "amount owed" summary.
/// "Unpaid" or "Overdue" preventing them from inflating the "amount owed" summary.
/// Amount-based search strips leading $ and commas before comparing so "$1,234" works naturally.
/// </summary>
public async Task<IActionResult> Index(string? type, string? search, string? status, int page = 1, int pageSize = 25)
@@ -81,10 +84,12 @@ public class BillsController : Controller
searchAmount = parsed;
}
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Bills
if (type == null || type == "Bill")
{
var bills = await _unitOfWork.Bills.GetForIndexAsync(status, search, searchAmount);
var bills = await _unitOfWork.Bills.GetForIndexAsync(companyId, status, search, searchAmount);
entries.AddRange(bills.Select(b => new BillExpenseListDto
{
@@ -112,7 +117,7 @@ public class BillsController : Controller
}));
}
// Expenses are always fully paid exclude when filtering to unpaid/overdue bills only
// Expenses are always fully paid exclude when filtering to unpaid/overdue bills only
if ((type == null || type == "Expense") && status != "Unpaid" && status != "Overdue")
{
var expSearch = search;
@@ -166,7 +171,7 @@ public class BillsController : Controller
/// <summary>
/// Scaffolds a new bill pre-filled from a received purchase order. Only POs in
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed earlier states mean
/// <c>Received</c> or <c>PartiallyReceived</c> status can be billed earlier states mean
/// goods have not yet arrived and no liability has been incurred. If a bill already exists for
/// the PO the user is redirected to the existing bill to prevent duplicate AP entries.
/// Line items are copied from PO items (using inventory item names where available), and
@@ -291,7 +296,7 @@ public class BillsController : Controller
/// review before committing to AP. Empty line items (zero account or zero price) are stripped
/// before validation to avoid spurious errors when the browser submits blank rows.
/// If <paramref name="payNow"/> is true a <see cref="BillPayment"/> record is inserted
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c>
/// immediately and the bill status is advanced to <c>Paid</c> or <c>PartiallyPaid</c>
/// useful for entering historical bills that were already settled. Account balance side
/// effects are deliberately deferred to <see cref="MarkOpen"/> so that Draft bills do not
/// affect the AP ledger until they are approved. If the bill was created from a PO the
@@ -322,7 +327,7 @@ public class BillsController : Controller
{
var currentUser = await _userManager.GetUserAsync(User);
// Period lock check block if the bill date is in a locked period
// Period lock check block if the bill date is in a locked period
if (currentUser != null)
{
var co = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
@@ -399,7 +404,7 @@ public class BillsController : Controller
await _unitOfWork.CompleteAsync();
});
// Receipt upload after the transaction commits bill.Id is set and core data
// Receipt upload after the transaction commits bill.Id is set and core data
// is secure. A blob failure here leaves the bill intact without an attachment.
if (receiptFile != null && receiptFile.Length > 0)
{
@@ -439,7 +444,8 @@ public class BillsController : Controller
{
if (id == null) return NotFound();
var bill = await _unitOfWork.Bills.LoadForViewAsync(id.Value);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var bill = await _unitOfWork.Bills.LoadForViewAsync(id.Value, companyId);
if (bill == null) return NotFound();
var dto = _mapper.Map<BillDto>(bill);
@@ -454,7 +460,7 @@ public class BillsController : Controller
.ToList();
ViewBag.BankAccounts = bankAccounts
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.Select(a => new SelectListItem($"{a.AccountNumber} {a.Name}", a.Id.ToString()))
.ToList();
ViewBag.PaymentMethods = Enum.GetValues<PaymentMethod>()
@@ -477,7 +483,8 @@ public class BillsController : Controller
{
if (id == null) return NotFound();
var bill = await _unitOfWork.Bills.LoadForEditAsync(id.Value);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var bill = await _unitOfWork.Bills.LoadForEditAsync(id.Value, companyId);
if (bill == null) return NotFound();
if (bill.Status == BillStatus.Paid || bill.Status == BillStatus.Voided)
@@ -540,7 +547,8 @@ public class BillsController : Controller
try
{
var bill = await _unitOfWork.Bills.LoadForEditAsync(id);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var bill = await _unitOfWork.Bills.LoadForEditAsync(id, companyId);
if (bill == null) return NotFound();
@@ -867,7 +875,7 @@ public class BillsController : Controller
/// <summary>
/// Voids an open or partially-paid bill, removing the remaining AP liability from the ledger.
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account any payments
/// Only the unpaid portion (<c>BalanceDue</c>) is reversed on the AP account any payments
/// already recorded remain as historical cash transactions. The vendor balance is likewise
/// reduced only by the outstanding balance, not the total. To signal "fully settled" without
/// leaving a positive <c>BalanceDue</c>, <c>AmountPaid</c> is set equal to <c>Total</c>
@@ -968,7 +976,8 @@ public class BillsController : Controller
private async Task<string> GenerateBillNumberAsync()
{
var prefix = $"BILL-{DateTime.Now:yyMM}-";
var last = await _unitOfWork.Bills.GetLastBillNumberAsync(prefix);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var last = await _unitOfWork.Bills.GetLastBillNumberAsync(companyId, prefix);
int next = 1;
if (last != null && int.TryParse(last[prefix.Length..], out int num))
@@ -979,13 +988,14 @@ public class BillsController : Controller
/// <summary>
/// Generates a sequential payment reference number in the format <c>BPMT-YYMM-####</c>.
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> soft-deleted
/// Same monotonic sequence logic as <see cref="GenerateBillNumberAsync"/> soft-deleted
/// records are included in the scan so payment numbers are never reused.
/// </summary>
private async Task<string> GeneratePaymentNumberAsync()
{
var prefix = $"BPMT-{DateTime.Now:yyMM}-";
var last = await _unitOfWork.Bills.GetLastPaymentNumberAsync(prefix);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var last = await _unitOfWork.Bills.GetLastPaymentNumberAsync(companyId, prefix);
int next = 1;
if (last != null && int.TryParse(last[prefix.Length..], out int num))
@@ -1139,13 +1149,13 @@ public class BillsController : Controller
// -- AI: Recurring Bill Detection ------------------------------------------
/// <summary>
/// GET page displays the recurring bill detection tool. No data is pre-fetched here;
/// GET page displays the recurring bill detection tool. No data is pre-fetched here;
/// the user triggers the scan by clicking a button which calls <see cref="RunRecurringDetection"/>.
/// </summary>
public IActionResult RecurringDetection() => View();
/// <summary>
/// AJAX POST loads up to 12 months of bill history for the company and passes it to
/// AJAX POST loads up to 12 months of bill history for the company and passes it to
/// Claude for recurring pattern analysis. Only posted bills (Draft/Open/Partial/Paid) are
/// included; Voided bills are excluded so cancelled payments do not distort the pattern.
/// Results are returned as JSON for client-side rendering in the view.
@@ -1103,7 +1103,7 @@ public class InvoicesController : Controller
paymentUrl = $"{Request.Scheme}://{Request.Host}/pay/{invoice.PaymentLinkToken}";
var viewUrl = $"{Request.Scheme}://{Request.Host}/invoice/{invoice.PublicViewToken}";
await _notificationService.NotifyInvoiceSentAsync(invoice, pdfBytes, $"Invoice-{invoice.InvoiceNumber}.pdf", paymentUrl, viewUrl: viewUrl);
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id, invoice.CompanyId);
this.SetNotificationResultToast(notifLog);
}
catch (Exception notifyEx)
@@ -1190,7 +1190,7 @@ public class InvoicesController : Controller
id, invoice.InvoiceNumber, notifyEx.InnerException?.Message ?? "none");
}
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
var notifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id, invoice.CompanyId);
this.SetNotificationResultToast(notifLog);
TempData["Success"] = $"Invoice {invoice.InvoiceNumber} marked as sent.";
@@ -1321,7 +1321,7 @@ public class InvoicesController : Controller
}
var paymentNotifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
var paymentNotifLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
this.SetNotificationResultToast(paymentNotifLog);
TempData["Success"] = overpayment > 0
@@ -1954,7 +1954,7 @@ public class InvoicesController : Controller
sendSms: sendSms,
viewUrl: viewUrl);
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id);
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForInvoiceAsync(id, invoice.CompanyId);
if (latestLog?.Status == NotificationStatus.Failed)
return Json(new { success = false, message = $"Delivery failed: {latestLog.ErrorMessage}" });
@@ -1987,7 +1987,7 @@ public class InvoicesController : Controller
public async Task<IActionResult> NotificationsSent(int id)
{
var tz = ViewBag.CompanyTimeZone as string;
var entries = await _unitOfWork.NotificationLogs.GetAllForInvoiceAsync(id);
var entries = await _unitOfWork.NotificationLogs.GetAllForInvoiceAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
var logs = entries.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.Message,
SentAt = n.SentAt.Tz(tz).ToString("MMM d, yyyy h:mm tt") });
@@ -2107,7 +2107,7 @@ public class InvoicesController : Controller
/// eight-table include chain. Returns null if not found or soft-deleted.
/// </summary>
private async Task<Invoice?> LoadInvoiceForViewAsync(int id) =>
await _unitOfWork.Invoices.LoadForViewAsync(id);
await _unitOfWork.Invoices.LoadForViewAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
/// <summary>
/// Converts an Invoice entity to a fully populated InvoiceDto for the view layer. AutoMapper
@@ -130,7 +130,7 @@ public class JobTemplatesController : Controller
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var job = await _unitOfWork.Jobs.LoadForTemplateSnapshotAsync(jobId);
var job = await _unitOfWork.Jobs.LoadForTemplateSnapshotAsync(jobId, companyId);
if (job == null) return NotFound();
@@ -333,7 +333,7 @@ public class JobsController : Controller
[HttpPost, ValidateAntiForgeryToken]
public async Task<IActionResult> MoveCard([FromBody] MoveCardRequest req)
{
var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(req.JobId);
var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(req.JobId, _tenantContext.GetCurrentCompanyId() ?? 0);
if (job == null)
return Json(new { success = false, message = "Job not found." });
@@ -396,7 +396,8 @@ public class JobsController : Controller
try
{
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value);
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value, companyId);
if (job == null)
{
return NotFound();
@@ -416,7 +417,7 @@ public class JobsController : Controller
var jobDto = _mapper.Map<JobDto>(job);
// Load change history
var changeHistories = await _unitOfWork.Jobs.GetChangeHistoryAsync(id.Value);
var changeHistories = await _unitOfWork.Jobs.GetChangeHistoryAsync(id.Value, companyId);
_logger.LogInformation("Loaded {Count} change history records for Job {JobId}", changeHistories.Count, id.Value);
var changeHistoryDtos = _mapper.Map<List<JobChangeHistoryDto>>(changeHistories);
@@ -741,7 +742,8 @@ public class JobsController : Controller
try
{
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value);
var companyId = _tenantContext.GetCurrentCompanyId();
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id.Value, companyId ?? 0);
if (job == null)
{
return NotFound();
@@ -751,7 +753,6 @@ public class JobsController : Controller
var jobDto = _mapper.Map<JobDto>(job);
// Get company info for the header
var companyId = _tenantContext.GetCurrentCompanyId();
if (companyId.HasValue)
{
var company = await _unitOfWork.Companies.GetByIdAsync(companyId.Value);
@@ -1268,7 +1269,7 @@ public class JobsController : Controller
try
{
var job = await _unitOfWork.Jobs.LoadForEditAsync(id.Value);
var job = await _unitOfWork.Jobs.LoadForEditAsync(id.Value, _tenantContext.GetCurrentCompanyId() ?? 0);
if (job == null)
{
return NotFound();
@@ -1782,7 +1783,7 @@ public class JobsController : Controller
_logger.LogWarning(ex, "Notification failed for job {Id}", job.Id);
}
var editNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(job.Id);
var editNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(job.Id, job.CompanyId);
this.SetNotificationResultToast(editNotifLog);
}
@@ -1996,10 +1997,9 @@ public class JobsController : Controller
{
try
{
var source = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
if (source == null) return NotFound();
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
var source = await _unitOfWork.Jobs.LoadForDetailsAsync(id, companyId);
if (source == null) return NotFound();
var pendingStatus = await _unitOfWork.JobStatusLookups.FirstOrDefaultAsync(
s => s.StatusCode == AppConstants.StatusCodes.Job.Pending && s.CompanyId == companyId);
if (pendingStatus == null)
@@ -2410,7 +2410,7 @@ public class JobsController : Controller
{
try
{
var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(request.JobId);
var job = await _unitOfWork.Jobs.LoadForStatusChangeAsync(request.JobId, _tenantContext.GetCurrentCompanyId() ?? 0);
if (job == null) return Json(new { success = false, message = "Job not found" });
var newStatus = await _unitOfWork.JobStatusLookups.GetByIdAsync(request.NewStatusId);
@@ -2661,7 +2661,7 @@ public class JobsController : Controller
_logger.LogWarning(ex, "Notification failed for job {Id}", request.JobId);
}
var statusNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(request.JobId);
var statusNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(request.JobId, job.CompanyId);
this.SetNotificationResultToast(statusNotifLog);
}
@@ -2888,7 +2888,7 @@ public class JobsController : Controller
[HttpGet]
public async Task<IActionResult> CompleteJobModal(int id)
{
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
if (job == null) return NotFound();
var currentUser = await _userManager.GetUserAsync(User);
if (currentUser != null)
@@ -3072,7 +3072,7 @@ public class JobsController : Controller
_logger.LogWarning(ex, "Notification failed for job {Id}", dto.JobId);
}
var completeNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(dto.JobId);
var completeNotifLog = await _unitOfWork.NotificationLogs.GetLatestForJobAsync(dto.JobId, _tenantContext.GetCurrentCompanyId() ?? 0);
this.SetNotificationResultToast(completeNotifLog);
}
@@ -3879,7 +3879,7 @@ public class JobsController : Controller
[HttpPost]
public async Task<IActionResult> AddReworkRecord([FromBody] CreateReworkRecordDto dto)
{
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(dto.JobId);
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(dto.JobId, _tenantContext.GetCurrentCompanyId() ?? 0);
if (job == null) return NotFound();
var companyId = job.CompanyId;
@@ -3957,7 +3957,7 @@ public class JobsController : Controller
var reworkRecord = await _unitOfWork.ReworkRecords.GetByIdAsync(req.ReworkRecordId, false, r => r.Job);
if (reworkRecord == null) return NotFound();
var originalJob = await _unitOfWork.Jobs.LoadForDetailsAsync(reworkRecord.JobId);
var originalJob = await _unitOfWork.Jobs.LoadForDetailsAsync(reworkRecord.JobId, _tenantContext.GetCurrentCompanyId() ?? 0);
if (originalJob == null) return NotFound();
var companyId = originalJob.CompanyId;
@@ -4013,7 +4013,7 @@ public class JobsController : Controller
var normalPriority = priorities.FirstOrDefault(p => p.PriorityCode == "NORMAL") ?? priorities.First();
// Sub-number: {parentJobNumber}-R{n+1}
var reworkCount = await _unitOfWork.Jobs.GetReworkJobCountAsync(originalJob.Id);
var reworkCount = await _unitOfWork.Jobs.GetReworkJobCountAsync(originalJob.Id, companyId);
var reworkNumber = $"{originalJob.JobNumber}-R{reworkCount + 1}";
var reworkJob = new Job
@@ -4165,7 +4165,7 @@ public class JobsController : Controller
[ValidateAntiForgeryToken]
public async Task<IActionResult> ResyncFromQuote(int id)
{
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id);
var job = await _unitOfWork.Jobs.LoadForDetailsAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
if (job == null) return NotFound();
// Guard: only allow re-sync while job is pre-production
@@ -4205,7 +4205,7 @@ public class JobsController : Controller
// Load quote items with full coat + prep-service data
var quote = job.Quote!;
var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(job.QuoteId.Value);
var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(job.QuoteId.Value, job.CompanyId);
foreach (var quoteItem in fullItems.Where(qi => !qi.IsDeleted))
{
@@ -288,8 +288,9 @@ public class QuotesController : Controller
try
{
var companyId = _tenantContext.GetCurrentCompanyId() ?? 0;
// Load quote with all navigations needed for the Details view
var quote = await _unitOfWork.Quotes.LoadForDetailsAsync(id.Value);
var quote = await _unitOfWork.Quotes.LoadForDetailsAsync(id.Value, companyId);
if (quote == null)
{
@@ -412,7 +413,7 @@ public class QuotesController : Controller
};
// Load change history
var changeHistories = await _unitOfWork.Quotes.GetChangeHistoryAsync(id.Value);
var changeHistories = await _unitOfWork.Quotes.GetChangeHistoryAsync(id.Value, companyId);
var changeHistoryDtos = _mapper.Map<List<QuoteChangeHistoryDto>>(changeHistories);
ViewBag.ChangeHistory = changeHistoryDtos;
@@ -560,7 +561,7 @@ public class QuotesController : Controller
}
}
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value);
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value, _tenantContext.GetCurrentCompanyId() ?? 0);
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
// Get company info and logo
@@ -1082,7 +1083,7 @@ public class QuotesController : Controller
_logger.LogWarning(ex, "Notification failed for quote {Id}", quote.Id);
}
var quoteCreateNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(quote.Id);
var quoteCreateNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(quote.Id, currentUser!.CompanyId);
this.SetNotificationResultToast(quoteCreateNotifLog);
}
@@ -1135,7 +1136,7 @@ public class QuotesController : Controller
}
// Get quote items with their coats, prep services and catalog item
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value);
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value, _tenantContext.GetCurrentCompanyId() ?? 0);
_logger.LogInformation("=== LOADING QUOTE {QuoteId} FOR EDIT ===", id.Value);
foreach (var item in quoteItems)
@@ -2197,7 +2198,7 @@ public class QuotesController : Controller
}
}
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value);
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(id.Value, _tenantContext.GetCurrentCompanyId() ?? 0);
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
// Warn on confirmation page if a job is linked
@@ -2356,7 +2357,7 @@ public class QuotesController : Controller
_logger.LogWarning(ex, "Notification failed for quote {Id}", id);
}
var approveNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id);
var approveNotifLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id, quote.CompanyId);
this.SetNotificationResultToast(approveNotifLog);
}
@@ -2718,7 +2719,7 @@ public class QuotesController : Controller
}
}
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quoteId);
var quoteItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quoteId, currentUser.CompanyId);
quoteDto.QuoteItems = _mapper.Map<List<QuoteItemDto>>(quoteItems);
var company = await _unitOfWork.Companies.GetByIdAsync(currentUser.CompanyId);
@@ -2892,7 +2893,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 _unitOfWork.Quotes.GetItemsWithCoatsAsync(quote.Id);
var fullItems = await _unitOfWork.Quotes.GetItemsWithCoatsAsync(quote.Id, quote.CompanyId);
// 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.
@@ -3210,7 +3211,7 @@ public class QuotesController : Controller
await _notificationService.NotifyQuoteSentAsync(quote, pdfBytes, pdfFilename, trimmedOverride);
// Check the most recent log entry to get actual send status
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id);
var latestLog = await _unitOfWork.NotificationLogs.GetLatestForQuoteAsync(id, quote.CompanyId);
if (latestLog?.Status == NotificationStatus.Failed)
return Json(new { success = false, message = $"Email delivery failed: {latestLog.ErrorMessage}" });
@@ -3312,7 +3313,7 @@ public class QuotesController : Controller
public async Task<IActionResult> NotificationsSent(int id)
{
var tz = ViewBag.CompanyTimeZone as string;
var rawLogs = await _unitOfWork.NotificationLogs.GetAllForQuoteAsync(id);
var rawLogs = await _unitOfWork.NotificationLogs.GetAllForQuoteAsync(id, _tenantContext.GetCurrentCompanyId() ?? 0);
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") });