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
@@ -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