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:
@@ -15,10 +15,10 @@ public class BillRepository : Repository<Bill>, IBillRepository
|
||||
public BillRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Bill?> LoadForViewAsync(int id)
|
||||
public async Task<Bill?> LoadForViewAsync(int id, int companyId)
|
||||
{
|
||||
return await _context.Bills
|
||||
.Where(b => b.Id == id && !b.IsDeleted)
|
||||
.Where(b => b.Id == id && b.CompanyId == companyId && !b.IsDeleted)
|
||||
.Include(b => b.Vendor)
|
||||
.Include(b => b.APAccount)
|
||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||
@@ -31,21 +31,21 @@ public class BillRepository : Repository<Bill>, IBillRepository
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Bill?> LoadForEditAsync(int id)
|
||||
public async Task<Bill?> LoadForEditAsync(int id, int companyId)
|
||||
{
|
||||
return await _context.Bills
|
||||
.Where(b => b.Id == id && !b.IsDeleted)
|
||||
.Where(b => b.Id == id && b.CompanyId == companyId && !b.IsDeleted)
|
||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Bill>> GetForIndexAsync(string? statusFilter, string? searchTerm, decimal? searchAmount)
|
||||
public async Task<List<Bill>> GetForIndexAsync(int companyId, string? statusFilter, string? searchTerm, decimal? searchAmount)
|
||||
{
|
||||
var query = _context.Bills
|
||||
.Include(b => b.Vendor)
|
||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||
.Where(b => !b.IsDeleted);
|
||||
.Where(b => b.CompanyId == companyId && !b.IsDeleted);
|
||||
|
||||
if (statusFilter == "Unpaid")
|
||||
query = query.Where(b => b.Status == BillStatus.Open || b.Status == BillStatus.PartiallyPaid);
|
||||
@@ -69,32 +69,32 @@ public class BillRepository : Repository<Bill>, IBillRepository
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<string?> GetLastBillNumberAsync(string prefix)
|
||||
public async Task<string?> GetLastBillNumberAsync(int companyId, string prefix)
|
||||
{
|
||||
return await _context.Bills
|
||||
.IgnoreQueryFilters()
|
||||
.Where(b => b.BillNumber.StartsWith(prefix))
|
||||
.Where(b => b.CompanyId == companyId && b.BillNumber.StartsWith(prefix))
|
||||
.OrderByDescending(b => b.BillNumber)
|
||||
.Select(b => b.BillNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<string?> GetLastPaymentNumberAsync(string prefix)
|
||||
public async Task<string?> GetLastPaymentNumberAsync(int companyId, string prefix)
|
||||
{
|
||||
return await _context.BillPayments
|
||||
.IgnoreQueryFilters()
|
||||
.Where(p => p.PaymentNumber.StartsWith(prefix))
|
||||
.Where(p => p.CompanyId == companyId && p.PaymentNumber.StartsWith(prefix))
|
||||
.OrderByDescending(p => p.PaymentNumber)
|
||||
.Select(p => p.PaymentNumber)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<Bill>> GetForDateRangeAsync(DateTime start, DateTime end)
|
||||
public async Task<List<Bill>> GetForDateRangeAsync(int companyId, DateTime start, DateTime end)
|
||||
{
|
||||
return await _context.Bills
|
||||
.Where(b => !b.IsDeleted && b.BillDate >= start && b.BillDate <= end)
|
||||
.Where(b => b.CompanyId == companyId && !b.IsDeleted && b.BillDate >= start && b.BillDate <= end)
|
||||
.Include(b => b.Vendor)
|
||||
.Include(b => b.LineItems.Where(li => !li.IsDeleted))
|
||||
.ThenInclude(li => li.Account)
|
||||
|
||||
@@ -14,10 +14,10 @@ public class CustomerRepository : Repository<Customer>, ICustomerRepository
|
||||
public CustomerRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Customer?> LoadForDetailsAsync(int id)
|
||||
public async Task<Customer?> LoadForDetailsAsync(int id, int companyId)
|
||||
{
|
||||
return await _context.Customers
|
||||
.Where(c => c.Id == id && !c.IsDeleted)
|
||||
.Where(c => c.Id == id && c.CompanyId == companyId && !c.IsDeleted)
|
||||
.Include(c => c.PricingTier)
|
||||
.Include(c => c.CustomerNotes.Where(n => !n.IsDeleted))
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
@@ -15,10 +15,10 @@ public class InvoiceRepository : Repository<Invoice>, IInvoiceRepository
|
||||
public InvoiceRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Invoice?> LoadForViewAsync(int id)
|
||||
public async Task<Invoice?> LoadForViewAsync(int id, int companyId)
|
||||
{
|
||||
return await _context.Set<Invoice>()
|
||||
.Where(i => i.Id == id && !i.IsDeleted)
|
||||
.Where(i => i.Id == id && i.CompanyId == companyId && !i.IsDeleted)
|
||||
.Include(i => i.Customer)
|
||||
.Include(i => i.Job)
|
||||
.Include(i => i.PreparedBy)
|
||||
|
||||
@@ -32,13 +32,13 @@ public class JobRepository : Repository<Job>, IJobRepository
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Job?> LoadForDetailsAsync(int id)
|
||||
public async Task<Job?> LoadForDetailsAsync(int id, int companyId)
|
||||
{
|
||||
// Single query replaces the per-item N+1 loop that was in JobsController.Details.
|
||||
// EF Core splits the multi-level ThenIncludes across two SQL queries automatically
|
||||
// (split query behavior), keeping result set size manageable.
|
||||
return await _context.Jobs
|
||||
.Where(j => j.Id == id && !j.IsDeleted)
|
||||
.Where(j => j.Id == id && j.CompanyId == companyId && !j.IsDeleted)
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
@@ -62,10 +62,10 @@ public class JobRepository : Repository<Job>, IJobRepository
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Job?> LoadForEditAsync(int id)
|
||||
public async Task<Job?> LoadForEditAsync(int id, int companyId)
|
||||
{
|
||||
return await _context.Jobs
|
||||
.Where(j => j.Id == id && !j.IsDeleted)
|
||||
.Where(j => j.Id == id && j.CompanyId == companyId && !j.IsDeleted)
|
||||
.Include(j => j.Customer)
|
||||
.Include(j => j.JobStatus)
|
||||
.Include(j => j.JobPriority)
|
||||
@@ -86,18 +86,18 @@ public class JobRepository : Repository<Job>, IJobRepository
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Job?> LoadForStatusChangeAsync(int id)
|
||||
public async Task<Job?> LoadForStatusChangeAsync(int id, int companyId)
|
||||
{
|
||||
return await _context.Jobs
|
||||
.Include(j => j.JobStatus)
|
||||
.FirstOrDefaultAsync(j => j.Id == id && !j.IsDeleted);
|
||||
.FirstOrDefaultAsync(j => j.Id == id && j.CompanyId == companyId && !j.IsDeleted);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId)
|
||||
public async Task<List<JobChangeHistory>> GetChangeHistoryAsync(int jobId, int companyId)
|
||||
{
|
||||
return await _context.JobChangeHistories
|
||||
.Where(h => h.JobId == jobId && !h.IsDeleted)
|
||||
.Where(h => h.JobId == jobId && h.CompanyId == companyId && !h.IsDeleted)
|
||||
.Include(h => h.ChangedBy)
|
||||
.OrderByDescending(h => h.ChangedAt)
|
||||
.AsNoTracking()
|
||||
@@ -176,10 +176,10 @@ public class JobRepository : Repository<Job>, IJobRepository
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Job?> LoadForTemplateSnapshotAsync(int jobId)
|
||||
public async Task<Job?> LoadForTemplateSnapshotAsync(int jobId, int companyId)
|
||||
{
|
||||
return await _context.Jobs
|
||||
.Where(j => j.Id == jobId && !j.IsDeleted)
|
||||
.Where(j => j.Id == jobId && j.CompanyId == companyId && !j.IsDeleted)
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
.ThenInclude(i => i.Coats.Where(c => !c.IsDeleted))
|
||||
.Include(j => j.JobItems.Where(i => !i.IsDeleted))
|
||||
@@ -188,11 +188,11 @@ public class JobRepository : Repository<Job>, IJobRepository
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<int> GetReworkJobCountAsync(int originalJobId)
|
||||
public async Task<int> GetReworkJobCountAsync(int originalJobId, int companyId)
|
||||
{
|
||||
return await _context.Jobs
|
||||
.IgnoreQueryFilters()
|
||||
.CountAsync(j => j.OriginalJobId == originalJobId);
|
||||
.CountAsync(j => j.OriginalJobId == originalJobId && j.CompanyId == companyId);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
@@ -15,50 +15,50 @@ public class NotificationLogRepository : Repository<NotificationLog>, INotificat
|
||||
public NotificationLogRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId) =>
|
||||
public async Task<NotificationLog?> GetLatestForInvoiceAsync(int invoiceId, int companyId) =>
|
||||
await _context.Set<NotificationLog>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.InvoiceId == invoiceId)
|
||||
.Where(n => n.InvoiceId == invoiceId && n.CompanyId == companyId)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId) =>
|
||||
public async Task<List<NotificationLog>> GetAllForInvoiceAsync(int invoiceId, int companyId) =>
|
||||
await _context.Set<NotificationLog>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.InvoiceId == invoiceId)
|
||||
.Where(n => n.InvoiceId == invoiceId && n.CompanyId == companyId)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.ToListAsync();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId) =>
|
||||
public async Task<NotificationLog?> GetLatestForQuoteAsync(int quoteId, int companyId) =>
|
||||
await _context.Set<NotificationLog>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.QuoteId == quoteId)
|
||||
.Where(n => n.QuoteId == quoteId && n.CompanyId == companyId)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId) =>
|
||||
public async Task<List<NotificationLog>> GetAllForQuoteAsync(int quoteId, int companyId) =>
|
||||
await _context.Set<NotificationLog>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.QuoteId == quoteId)
|
||||
.Where(n => n.QuoteId == quoteId && n.CompanyId == companyId)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.ToListAsync();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<NotificationLog?> GetLatestForJobAsync(int jobId) =>
|
||||
public async Task<NotificationLog?> GetLatestForJobAsync(int jobId, int companyId) =>
|
||||
await _context.Set<NotificationLog>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.JobId == jobId)
|
||||
.Where(n => n.JobId == jobId && n.CompanyId == companyId)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<NotificationLog>> GetAllForJobAsync(int jobId) =>
|
||||
public async Task<List<NotificationLog>> GetAllForJobAsync(int jobId, int companyId) =>
|
||||
await _context.Set<NotificationLog>()
|
||||
.IgnoreQueryFilters()
|
||||
.Where(n => n.JobId == jobId)
|
||||
.Where(n => n.JobId == jobId && n.CompanyId == companyId)
|
||||
.OrderByDescending(n => n.SentAt)
|
||||
.ToListAsync();
|
||||
|
||||
|
||||
@@ -15,10 +15,10 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
|
||||
public QuoteRepository(ApplicationDbContext context) : base(context) { }
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<Quote?> LoadForDetailsAsync(int id)
|
||||
public async Task<Quote?> LoadForDetailsAsync(int id, int companyId)
|
||||
{
|
||||
var quote = await _context.Quotes
|
||||
.Where(q => q.Id == id && !q.IsDeleted)
|
||||
.Where(q => q.Id == id && q.CompanyId == companyId && !q.IsDeleted)
|
||||
.Include(q => q.Customer)
|
||||
.Include(q => q.PreparedBy)
|
||||
.Include(q => q.QuoteStatus)
|
||||
@@ -32,7 +32,7 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
|
||||
// QuoteItems with nested coats and prep services loaded separately to avoid
|
||||
// cartesian explosion from multiple collection includes in a single query.
|
||||
quote.QuoteItems = await _context.QuoteItems
|
||||
.Where(qi => qi.QuoteId == id && !qi.IsDeleted)
|
||||
.Where(qi => qi.QuoteId == id && qi.CompanyId == companyId && !qi.IsDeleted)
|
||||
.Include(qi => qi.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.Include(qi => qi.Coats)
|
||||
@@ -58,10 +58,10 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId)
|
||||
public async Task<List<QuoteChangeHistory>> GetChangeHistoryAsync(int quoteId, int companyId)
|
||||
{
|
||||
return await _context.QuoteChangeHistories
|
||||
.Where(h => h.QuoteId == quoteId && !h.IsDeleted)
|
||||
.Where(h => h.QuoteId == quoteId && h.CompanyId == companyId && !h.IsDeleted)
|
||||
.Include(h => h.ChangedBy)
|
||||
.OrderByDescending(h => h.ChangedAt)
|
||||
.AsNoTracking()
|
||||
@@ -83,10 +83,10 @@ public class QuoteRepository : Repository<Quote>, IQuoteRepository
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId)
|
||||
public async Task<List<QuoteItem>> GetItemsWithCoatsAsync(int quoteId, int companyId)
|
||||
{
|
||||
return await _context.QuoteItems
|
||||
.Where(qi => qi.QuoteId == quoteId && !qi.IsDeleted)
|
||||
.Where(qi => qi.QuoteId == quoteId && qi.CompanyId == companyId && !qi.IsDeleted)
|
||||
.Include(qi => qi.Coats)
|
||||
.ThenInclude(c => c.InventoryItem)
|
||||
.Include(qi => qi.Coats)
|
||||
|
||||
Reference in New Issue
Block a user