8acbc8605d
Added explicit CompanyId == companyId predicates to every tenant-scoped query in 22 controllers so cross-tenant data leakage is impossible even if EF Core global query filters are bypassed or misconfigured. Also fixed ApplicationDbContext.IsPlatformAdmin to correctly return true for SuperAdmins with no CompanyId claim (break-glass accounts) and when no HTTP context is present (background services, unit tests), resolving 225 unit test failures that stemmed from the global filter blocking all in-memory test data. New MultiTenantIsolationTests class (8 tests) verifies the explicit predicate layer independently of the global query filters. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2158 lines
100 KiB
C#
2158 lines
100 KiB
C#
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
|
||
using Microsoft.AspNetCore.Http;
|
||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using PowderCoating.Core.Entities;
|
||
using PowderCoating.Core.Interfaces;
|
||
|
||
namespace PowderCoating.Infrastructure.Data;
|
||
|
||
/// <summary>
|
||
/// EF Core DbContext for the Powder Coating Logix application.
|
||
/// <para>
|
||
/// Extends <see cref="IdentityDbContext{TUser}"/> so that ASP.NET Core Identity tables
|
||
/// (Users, Roles, Claims, etc.) live in the same database as all business entities.
|
||
/// </para>
|
||
/// <para>
|
||
/// Two global query filters are applied automatically to every LINQ query against tenant entities:
|
||
/// <list type="number">
|
||
/// <item><description>Soft-delete filter — rows whose <c>IsDeleted == true</c> are hidden from all queries by default.</description></item>
|
||
/// <item><description>Multi-tenancy filter — non-platform-admin users only see rows whose <c>CompanyId</c> matches their own company.</description></item>
|
||
/// </list>
|
||
/// Both filters are evaluated at query execution time (not model build time) by reading
|
||
/// <see cref="CurrentCompanyId"/> and <see cref="IsSuperAdmin"/> from the live HTTP context,
|
||
/// so tenant isolation is enforced automatically without any controller-level boilerplate.
|
||
/// Bypass either filter by calling <c>.IgnoreQueryFilters()</c> (or passing <c>ignoreQueryFilters: true</c>
|
||
/// to repository methods) — reserved for SuperAdmin operations and document-number generation.
|
||
/// </para>
|
||
/// </summary>
|
||
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>, IDataProtectionKeyContext
|
||
{
|
||
private readonly IHttpContextAccessor? _httpContextAccessor;
|
||
private readonly IServiceProvider? _serviceProvider;
|
||
|
||
/// <summary>
|
||
/// Parameterless constructor used by EF Core design-time tooling (migrations, scaffolding).
|
||
/// The <see cref="IHttpContextAccessor"/> and <see cref="IServiceProvider"/> are not
|
||
/// available at design time, so optional overloads are used for the full runtime path.
|
||
/// </summary>
|
||
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
|
||
: base(options)
|
||
{
|
||
}
|
||
|
||
/// <summary>
|
||
/// Runtime constructor used by the ASP.NET Core DI container.
|
||
/// Receives the <paramref name="httpContextAccessor"/> so that global query filters can
|
||
/// read the current user's <c>CompanyId</c> claim on each query, and the
|
||
/// <paramref name="serviceProvider"/> so that <see cref="SaveChangesAsync"/> can resolve
|
||
/// <see cref="ITenantContext"/> to auto-stamp <c>CompanyId</c> on new entities.
|
||
/// </summary>
|
||
public ApplicationDbContext(
|
||
DbContextOptions<ApplicationDbContext> options,
|
||
IHttpContextAccessor httpContextAccessor,
|
||
IServiceProvider serviceProvider)
|
||
: base(options)
|
||
{
|
||
_httpContextAccessor = httpContextAccessor;
|
||
_serviceProvider = serviceProvider;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolves the company ID that should be used in global query filters for the current request.
|
||
/// <para>
|
||
/// Evaluated at query execution time (not at model build time), so every LINQ query issued
|
||
/// during a request automatically scopes results to the correct tenant without any
|
||
/// controller-level filtering code.
|
||
/// </para>
|
||
/// <para>
|
||
/// Resolution order:
|
||
/// <list type="number">
|
||
/// <item><description>If the user is not authenticated, returns <c>null</c> — filters will exclude all tenant rows from unauthenticated requests.</description></item>
|
||
/// <item><description>If the user is a SuperAdmin who is currently impersonating a company (session key <c>ImpersonatingCompanyId</c>), returns that company's ID so they see only impersonated tenant data.</description></item>
|
||
/// <item><description>Otherwise reads the <c>CompanyId</c> JWT/cookie claim written by <see cref="Identity.CustomUserClaimsPrincipalFactory"/> at login.</description></item>
|
||
/// </list>
|
||
/// </para>
|
||
/// </summary>
|
||
private int? CurrentCompanyId
|
||
{
|
||
get
|
||
{
|
||
if (_httpContextAccessor?.HttpContext?.User?.Identity?.IsAuthenticated != true)
|
||
return null;
|
||
|
||
// SuperAdmin impersonation override — checked before claims
|
||
if (_httpContextAccessor.HttpContext.User.IsInRole("SuperAdmin"))
|
||
{
|
||
var overrideId = _httpContextAccessor.HttpContext.Session?.GetInt32("ImpersonatingCompanyId");
|
||
if (overrideId.HasValue) return overrideId.Value;
|
||
}
|
||
|
||
var companyIdClaim = _httpContextAccessor.HttpContext.User.FindFirst("CompanyId")?.Value;
|
||
if (companyIdClaim != null && int.TryParse(companyIdClaim, out int companyId))
|
||
return companyId;
|
||
|
||
// Authenticated but CompanyId claim is missing or invalid.
|
||
// Return 0 (never a real company ID) so the global filter generates
|
||
// "CompanyId = 0" which matches nothing — prevents null-comparison
|
||
// ambiguity from leaking cross-tenant rows.
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns <c>true</c> when the currently authenticated user holds the <c>SuperAdmin</c> system role.
|
||
/// Evaluated at query execution time so global filter expressions always reflect the live request user.
|
||
/// SuperAdmins with this flag set to <c>true</c> and a non-demo company ID still see only their own
|
||
/// company's operational data; only <see cref="IsPlatformAdmin"/> unlocks cross-tenant visibility.
|
||
/// </summary>
|
||
private bool IsSuperAdmin
|
||
{
|
||
get
|
||
{
|
||
return _httpContextAccessor?.HttpContext?.User?.IsInRole("SuperAdmin") == true;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns <c>true</c> only for SuperAdmin users whose <c>CompanyId</c> is the platform demo
|
||
/// company (ID 1) or who have no company association at all (e.g., the break-glass account).
|
||
/// <para>
|
||
/// This distinction is critical: a SuperAdmin created for a specific tenant company
|
||
/// (<c>CompanyId != 1</c>) is a "company SuperAdmin" — they can access platform settings but
|
||
/// their queries are still scoped to their own company's operational data so they cannot
|
||
/// accidentally view or mutate another tenant's records.
|
||
/// </para>
|
||
/// <para>
|
||
/// Only platform admins (this property = <c>true</c>) bypass the CompanyId filter entirely and
|
||
/// can query across all tenants — necessary for platform management screens such as the
|
||
/// Companies list, Seed Data tool, and cross-tenant analytics.
|
||
/// </para>
|
||
/// </summary>
|
||
private bool IsPlatformAdmin
|
||
{
|
||
get
|
||
{
|
||
// No HTTP context means background service, hosted service, or unit test — bypass tenant filter
|
||
if (_httpContextAccessor?.HttpContext == null) return true;
|
||
if (!IsSuperAdmin) return false;
|
||
// CompanyId == 0 means no claim was present (break-glass / test SuperAdmins) — treat as platform admin
|
||
return CurrentCompanyId == null || CurrentCompanyId == 0 || CurrentCompanyId == 1;
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------------
|
||
// DbSet properties — one per entity type.
|
||
// EF Core uses these to generate migration SQL and to resolve Set<T>() calls
|
||
// made by the generic Repository<T>. Adding a new entity requires both a
|
||
// DbSet here AND a HasQueryFilter call in OnModelCreating.
|
||
// -------------------------------------------------------------------------
|
||
|
||
/// <summary>Tenant company records. Soft-delete only filter applies (no CompanyId filter — SuperAdmin manages all companies).</summary>
|
||
public DbSet<Company> Companies { get; set; }
|
||
|
||
/// <summary>Immutable audit trail of SMS terms-of-service acceptances per company. Tenant-filtered by CompanyId; never soft-deleted.</summary>
|
||
public DbSet<CompanySmsAgreement> CompanySmsAgreements { get; set; }
|
||
|
||
/// <summary>AI quote-item predictions; tenant-filtered. Both <c>QuoteItem</c> and <c>JobItem</c> share a single prediction record via nullable FK (no duplication on quote→job conversion).</summary>
|
||
public DbSet<AiItemPrediction> AiItemPredictions { get; set; }
|
||
|
||
/// <summary>Platform-wide log of every Anthropic API call. No tenant query filter — SuperAdmin reads across all companies for cost/abuse reporting.</summary>
|
||
public DbSet<AiUsageLog> AiUsageLogs { get; set; }
|
||
|
||
/// <summary>Per-coat powder consumption logs captured when a job coat is completed; tenant-filtered. Used by powder-usage analytics reports.</summary>
|
||
public DbSet<PowderUsageLog> PowderUsageLogs { get; set; }
|
||
|
||
// Core entities
|
||
/// <summary>Commercial and non-commercial customer records; tenant-filtered with soft delete.</summary>
|
||
public DbSet<Customer> Customers { get; set; }
|
||
/// <summary>Job records progressing through the 16-status lifecycle; tenant-filtered with soft delete.</summary>
|
||
public DbSet<Job> Jobs { get; set; }
|
||
/// <summary>Per-day priority overrides that let supervisors re-order the shop floor queue without editing the job itself.</summary>
|
||
public DbSet<JobDailyPriority> JobDailyPriorities { get; set; }
|
||
/// <summary>Individual line-items on a job (custom work, catalog items, labor); tenant-filtered with soft delete.</summary>
|
||
public DbSet<JobItem> JobItems { get; set; }
|
||
/// <summary>Powder coat passes for a job item (color, thickness, coverage); tenant-filtered with soft delete.</summary>
|
||
public DbSet<JobItemCoat> JobItemCoats { get; set; }
|
||
/// <summary>Prep-service assignments on a job item (sandblasting, masking, etc.); tenant-filtered with soft delete.</summary>
|
||
public DbSet<JobItemPrepService> JobItemPrepServices { get; set; }
|
||
/// <summary>Immutable audit trail of every field change on a job; tenant-filtered with soft delete.</summary>
|
||
public DbSet<JobChangeHistory> JobChangeHistories { get; set; }
|
||
/// <summary>Clock-in / clock-out time entries for workers on a job; used for labor-cost calculations.</summary>
|
||
public DbSet<JobTimeEntry> JobTimeEntries { get; set; }
|
||
/// <summary>Quote records with multi-item pricing; tenant-filtered with soft delete.</summary>
|
||
public DbSet<Quote> Quotes { get; set; }
|
||
/// <summary>Photos uploaded during the AI Photo Quote workflow; tenant-filtered with soft delete.</summary>
|
||
public DbSet<QuotePhoto> QuotePhotos { get; set; }
|
||
/// <summary>Line-items on a quote; tenant-filtered with soft delete.</summary>
|
||
public DbSet<QuoteItem> QuoteItems { get; set; }
|
||
/// <summary>Powder coat passes for a quote item; tenant-filtered with soft delete.</summary>
|
||
public DbSet<QuoteItemCoat> QuoteItemCoats { get; set; }
|
||
/// <summary>Prep-service assignments on a quote item; tenant-filtered with soft delete.</summary>
|
||
public DbSet<QuoteItemPrepService> QuoteItemPrepServices { get; set; }
|
||
/// <summary>Immutable audit trail of every field change on a quote; tenant-filtered with soft delete.</summary>
|
||
public DbSet<QuoteChangeHistory> QuoteChangeHistories { get; set; }
|
||
/// <summary>Powder and material inventory items; tenant-filtered with soft delete.</summary>
|
||
public DbSet<InventoryItem> InventoryItems { get; set; }
|
||
/// <summary>Stock movement transactions (Purchase, Sale, Adjustment, Waste, etc.); tenant-filtered with soft delete.</summary>
|
||
public DbSet<InventoryTransaction> InventoryTransactions { get; set; }
|
||
/// <summary>User-defined inventory categories; stored as a lookup table (not an enum) so tenants can customise them.</summary>
|
||
public DbSet<InventoryCategoryLookup> InventoryCategoryLookups { get; set; }
|
||
/// <summary>Shop equipment (ovens, sandblasters, coating booths); tenant-filtered with soft delete.</summary>
|
||
public DbSet<Equipment> Equipment { get; set; }
|
||
/// <summary>Named blast setups per company (cabinet, pressure pot, blast room, etc.); tenant-filtered with soft delete.</summary>
|
||
public DbSet<CompanyBlastSetup> CompanyBlastSetups { get; set; }
|
||
/// <summary>Named oven cost configurations used by the Oven Scheduler; tenant-filtered with soft delete.</summary>
|
||
public DbSet<OvenCost> OvenCosts { get; set; }
|
||
/// <summary>Scheduled oven batches that group jobs for a single cure run; tenant-filtered with soft delete.</summary>
|
||
public DbSet<OvenBatch> OvenBatches { get; set; }
|
||
/// <summary>Individual job/item assignments within an oven batch; tenant-filtered with soft delete.</summary>
|
||
public DbSet<OvenBatchItem> OvenBatchItems { get; set; }
|
||
/// <summary>Equipment maintenance records (scheduled, in-progress, completed); tenant-filtered with soft delete.</summary>
|
||
public DbSet<MaintenanceRecord> MaintenanceRecords { get; set; }
|
||
/// <summary>Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.</summary>
|
||
public DbSet<Vendor> Vendors { get; set; }
|
||
/// <summary>Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.</summary>
|
||
public DbSet<ReworkRecord> ReworkRecords { get; set; }
|
||
/// <summary>Customer refund records; tenant-filtered with soft delete.</summary>
|
||
public DbSet<Refund> Refunds { get; set; }
|
||
/// <summary>Credit memos issued to customers (e.g., after a refund or rework); unique memo number per company.</summary>
|
||
public DbSet<CreditMemo> CreditMemos { get; set; }
|
||
/// <summary>Records of credit memos applied against specific invoices; tenant-filtered with soft delete.</summary>
|
||
public DbSet<CreditMemoApplication> CreditMemoApplications { get; set; }
|
||
/// <summary>Gift certificates issued by the company; certificate code is unique per company.</summary>
|
||
public DbSet<GiftCertificate> GiftCertificates { get; set; }
|
||
/// <summary>Redemption events against a gift certificate; tenant-filtered with soft delete.</summary>
|
||
public DbSet<GiftCertificateRedemption> GiftCertificateRedemptions { get; set; }
|
||
/// <summary>Photos attached to a job (before/after, quality-check, AI analysis); tenant-filtered with soft delete.</summary>
|
||
public DbSet<JobPhoto> JobPhotos { get; set; }
|
||
/// <summary>Free-text notes added to a job by staff; tenant-filtered with soft delete.</summary>
|
||
public DbSet<JobNote> JobNotes { get; set; }
|
||
/// <summary>Free-text notes added to a customer record by staff; tenant-filtered with soft delete.</summary>
|
||
public DbSet<CustomerNote> CustomerNotes { get; set; }
|
||
/// <summary>Audit trail of every status transition on a job, referencing the lookup-table statuses.</summary>
|
||
public DbSet<JobStatusHistory> JobStatusHistory { get; set; }
|
||
/// <summary>Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.</summary>
|
||
public DbSet<PricingTier> PricingTiers { get; set; }
|
||
/// <summary>Company-level operating cost rates (labor, equipment, overhead) used by the pricing engine; soft-delete only (no tenant filter — linked 1:1 to Company).</summary>
|
||
public DbSet<CompanyOperatingCosts> CompanyOperatingCosts { get; set; }
|
||
/// <summary>Company-level UI and workflow preferences; soft-delete only (no tenant filter — linked 1:1 to Company).</summary>
|
||
public DbSet<CompanyPreferences> CompanyPreferences { get; set; }
|
||
|
||
// Lookup tables (replacing enums)
|
||
// Stored in the DB instead of C# enums so tenants can customise display names,
|
||
// colours, and ordering without requiring a code deployment.
|
||
/// <summary>Job status definitions (Pending → Delivered plus terminal states); unique on (CompanyId, StatusCode).</summary>
|
||
public DbSet<JobStatusLookup> JobStatusLookups { get; set; }
|
||
/// <summary>Job priority definitions (Low, Normal, High, Urgent, Rush); unique on (CompanyId, PriorityCode).</summary>
|
||
public DbSet<JobPriorityLookup> JobPriorityLookups { get; set; }
|
||
/// <summary>Quote status definitions (Draft, Sent, Approved, etc.); unique on (CompanyId, StatusCode).</summary>
|
||
public DbSet<QuoteStatusLookup> QuoteStatusLookups { get; set; }
|
||
/// <summary>Appointment status definitions; tenant-filtered with soft delete.</summary>
|
||
public DbSet<AppointmentStatusLookup> AppointmentStatusLookups { get; set; }
|
||
/// <summary>Appointment type definitions; tenant-filtered with soft delete.</summary>
|
||
public DbSet<AppointmentTypeLookup> AppointmentTypeLookups { get; set; }
|
||
/// <summary>Prep-service catalog entries (Sandblasting, Masking, etc.) shared across quotes and jobs.</summary>
|
||
public DbSet<PrepService> PrepServices { get; set; }
|
||
/// <summary>Many-to-many join table associating a <see cref="Quote"/> with selected prep services.</summary>
|
||
public DbSet<QuotePrepService> QuotePrepServices { get; set; }
|
||
/// <summary>Many-to-many join table associating a <see cref="Job"/> with selected prep services.</summary>
|
||
public DbSet<JobPrepService> JobPrepServices { get; set; }
|
||
|
||
/// <summary>Customer appointments (service, pickup, consultation); tenant-filtered with soft delete.</summary>
|
||
public DbSet<Appointment> Appointments { get; set; }
|
||
|
||
// Product Catalog
|
||
/// <summary>Hierarchical categories for the service catalog; tenant-filtered with soft delete.</summary>
|
||
public DbSet<CatalogCategory> CatalogCategories { get; set; }
|
||
/// <summary>Pre-priced service catalog items that can be added to quotes/jobs; tenant-filtered with soft delete.</summary>
|
||
public DbSet<CatalogItem> CatalogItems { get; set; }
|
||
/// <summary>Most-recent AI price-check report per company; tenant-filtered with soft delete.</summary>
|
||
public DbSet<CatalogPriceCheckReport> CatalogPriceCheckReports { get; set; }
|
||
|
||
// Notifications
|
||
/// <summary>Log of all outbound notifications (email, SMS, in-app) for audit and retry; tenant-filtered with soft delete.</summary>
|
||
public DbSet<NotificationLog> NotificationLogs { get; set; }
|
||
/// <summary>Per-company, per-channel notification template overrides; unique on (CompanyId, Type, Channel).</summary>
|
||
public DbSet<NotificationTemplate> NotificationTemplates { get; set; }
|
||
|
||
/// <summary>
|
||
/// Stripe subscription plan configurations (limits, pricing, feature flags).
|
||
/// Global — not tenant-scoped. Soft-delete only filter applied.
|
||
/// Managed by platform admins via the Subscription Plans UI.
|
||
/// </summary>
|
||
public DbSet<SubscriptionPlanConfig> SubscriptionPlanConfigs { get; set; }
|
||
|
||
/// <summary>
|
||
/// Platform-level master list of powder coating products across all vendors.
|
||
/// Not tenant-scoped — no global query filters applied.
|
||
/// </summary>
|
||
public DbSet<PowderCatalogItem> PowderCatalogItems { get; set; }
|
||
|
||
/// <summary>User-submitted bug reports; tenant-filtered with soft delete.</summary>
|
||
public DbSet<BugReport> BugReports { get; set; }
|
||
/// <summary>File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).</summary>
|
||
public DbSet<BugReportAttachment> BugReportAttachments { get; set; }
|
||
/// <summary>Contact Us form submissions; platform admins see all, company users see their own.</summary>
|
||
public DbSet<ContactSubmission> ContactSubmissions { get; set; }
|
||
|
||
// Invoices, Payments & Deposits
|
||
/// <summary>Customer invoices (1:1 with Job enforced by unique index); tenant-filtered with soft delete.</summary>
|
||
public DbSet<Invoice> Invoices { get; set; }
|
||
/// <summary>Line-items on an invoice, with optional back-reference to the originating <see cref="JobItem"/>.</summary>
|
||
public DbSet<InvoiceItem> InvoiceItems { get; set; }
|
||
/// <summary>Customer payment records against an invoice; multiple partial payments supported.</summary>
|
||
public DbSet<Payment> Payments { get; set; }
|
||
/// <summary>
|
||
/// Deposit records collected before a job is invoiced.
|
||
/// Auto-applied to the invoice when it is created (unapplied deposits are swept into Payment records).
|
||
/// </summary>
|
||
public DbSet<Deposit> Deposits { get; set; }
|
||
|
||
// Purchase Orders
|
||
/// <summary>Purchase orders issued to vendors; tenant-filtered with soft delete.</summary>
|
||
public DbSet<PurchaseOrder> PurchaseOrders { get; set; }
|
||
/// <summary>Individual line-items on a purchase order; cascade-deleted when the PO is deleted.</summary>
|
||
public DbSet<PurchaseOrderItem> PurchaseOrderItems { get; set; }
|
||
|
||
// Expense Tracking / Accounts Payable
|
||
/// <summary>Chart-of-accounts entries (Income, Expense, Asset, Liability); supports parent/child hierarchy via self-referencing FK.</summary>
|
||
public DbSet<Account> Accounts { get; set; }
|
||
/// <summary>Vendor bills (accounts payable); tenant-filtered with soft delete.</summary>
|
||
public DbSet<Bill> Bills { get; set; }
|
||
/// <summary>Line-items on a vendor bill, each assigned to a chart-of-accounts entry.</summary>
|
||
public DbSet<BillLineItem> BillLineItems { get; set; }
|
||
/// <summary>Payment records against a vendor bill; tracks cash disbursements to vendors.</summary>
|
||
public DbSet<BillPayment> BillPayments { get; set; }
|
||
/// <summary>Ad-hoc expense records (non-bill spending); tenant-filtered with soft delete.</summary>
|
||
public DbSet<Expense> Expenses { get; set; }
|
||
|
||
/// <summary>Manual double-entry journal entries (Draft/Posted/Reversed lifecycle); tenant-filtered with soft delete.</summary>
|
||
public DbSet<JournalEntry> JournalEntries { get; set; }
|
||
/// <summary>Individual debit/credit lines within a journal entry; soft-delete only (access controlled through parent JournalEntry).</summary>
|
||
public DbSet<JournalEntryLine> JournalEntryLines { get; set; }
|
||
|
||
/// <summary>Bank reconciliation sessions matching GL transactions to bank statements; tenant-filtered with soft delete.</summary>
|
||
public DbSet<BankReconciliation> BankReconciliations { get; set; }
|
||
|
||
/// <summary>Named tax rates used to pre-fill invoice tax percent by jurisdiction; tenant-filtered with soft delete.</summary>
|
||
public DbSet<TaxRate> TaxRates { get; set; }
|
||
|
||
/// <summary>Recurring transaction templates that auto-generate bills or expenses on a schedule; tenant-filtered with soft delete.</summary>
|
||
public DbSet<RecurringTemplate> RecurringTemplates { get; set; }
|
||
|
||
/// <summary>Fixed assets subject to straight-line depreciation; tenant-filtered with soft delete.</summary>
|
||
public DbSet<FixedAsset> FixedAssets { get; set; }
|
||
/// <summary>One record per asset per period for each depreciation posting; soft-delete only.</summary>
|
||
public DbSet<FixedAssetDepreciationEntry> FixedAssetDepreciationEntries { get; set; }
|
||
|
||
/// <summary>Named annual budgets with monthly amounts per GL account; tenant-filtered with soft delete.</summary>
|
||
public DbSet<Budget> Budgets { get; set; }
|
||
/// <summary>One row per account per Budget; contains Jan–Dec decimal columns.</summary>
|
||
public DbSet<BudgetLine> BudgetLines { get; set; }
|
||
/// <summary>Audit trail of completed year-end closes; tenant-filtered with soft delete.</summary>
|
||
public DbSet<YearEndClose> YearEndCloses { get; set; }
|
||
|
||
/// <summary>Credit notes received from vendors (returned goods, pricing disputes); tenant-filtered with soft delete.</summary>
|
||
public DbSet<VendorCredit> VendorCredits { get; set; }
|
||
/// <summary>Expense-reversal line items on a vendor credit; soft-delete only.</summary>
|
||
public DbSet<VendorCreditLineItem> VendorCreditLineItems { get; set; }
|
||
/// <summary>Application records linking a vendor credit to a specific bill; soft-delete only.</summary>
|
||
public DbSet<VendorCreditApplication> VendorCreditApplications { get; set; }
|
||
|
||
// Job Templates
|
||
/// <summary>Reusable job templates that pre-populate job items, coats, and prep services on job creation.</summary>
|
||
public DbSet<JobTemplate> JobTemplates { get; set; }
|
||
/// <summary>Item definitions within a job template.</summary>
|
||
public DbSet<JobTemplateItem> JobTemplateItems { get; set; }
|
||
/// <summary>Coat definitions within a job template item.</summary>
|
||
public DbSet<JobTemplateItemCoat> JobTemplateItemCoats { get; set; }
|
||
/// <summary>Prep-service definitions within a job template item.</summary>
|
||
public DbSet<JobTemplateItemPrepService> JobTemplateItemPrepServices { get; set; }
|
||
|
||
// Customer Intake Kiosk
|
||
/// <summary>Customer self-service intake sessions (walk-in tablet or remote email link); tenant-filtered with soft delete.</summary>
|
||
public DbSet<KioskSession> KioskSessions { get; set; }
|
||
|
||
/// <summary>
|
||
/// Platform-wide audit log capturing who changed what and when, across all tenants.
|
||
/// No global query filter — SuperAdmin controllers query this directly.
|
||
/// Indexed on (CompanyId, Timestamp) and (EntityType, EntityId) for efficient lookup.
|
||
/// </summary>
|
||
public DbSet<AuditLog> AuditLogs { get; set; }
|
||
|
||
/// <summary>
|
||
/// Platform-wide announcement banners shown to tenant users on login.
|
||
/// No tenant filter — visibility controlled by the Target field (All, SpecificPlan, etc.) in the application layer.
|
||
/// </summary>
|
||
public DbSet<Announcement> Announcements { get; set; }
|
||
/// <summary>Records of which users have dismissed which announcements; unique on (AnnouncementId, UserId).</summary>
|
||
public DbSet<AnnouncementDismissal> AnnouncementDismissals { get; set; }
|
||
|
||
/// <summary>Platform-wide release notes / changelog entries; no tenant filter.</summary>
|
||
public DbSet<ReleaseNote> ReleaseNotes { get; set; }
|
||
|
||
/// <summary>
|
||
/// Global AI lookup patterns for resolving manufacturer product URLs.
|
||
/// <c>CompanyId = 0</c> by convention (these are platform-level, not per-tenant).
|
||
/// Soft-delete only filter applied; no tenant filter.
|
||
/// </summary>
|
||
public DbSet<ManufacturerLookupPattern> ManufacturerLookupPatterns { get; set; }
|
||
|
||
/// <summary>
|
||
/// Rotating dashboard tips shown in the welcome section.
|
||
/// Platform-wide (seeded by <c>SeedDataService</c> with 40 tips); no tenant filter.
|
||
/// </summary>
|
||
public DbSet<DashboardTip> DashboardTips { get; set; }
|
||
|
||
/// <summary>
|
||
/// Platform-wide key/value configuration store (e.g., maintenance mode, feature flags).
|
||
/// No tenant filter and no soft-delete — values are always current.
|
||
/// Managed via the Platform Settings SuperAdmin UI.
|
||
/// </summary>
|
||
public DbSet<PlatformSetting> PlatformSettings { get; set; }
|
||
|
||
/// <summary>
|
||
/// ASP.NET Core Data Protection key ring — required by <see cref="IDataProtectionKeyContext"/>.
|
||
/// Keys stored here survive deploys and IIS app pool recycles.
|
||
/// </summary>
|
||
public DbSet<DataProtectionKey> DataProtectionKeys { get; set; }
|
||
|
||
/// <summary>
|
||
/// IP address ban list. Login attempts from a matching active entry are rejected
|
||
/// before Identity even checks credentials. No tenant filter; SuperAdmin-managed only.
|
||
/// </summary>
|
||
public DbSet<BannedIp> BannedIps { get; set; }
|
||
|
||
/// <summary>
|
||
/// Stripe webhook event log for idempotency checking and replay debugging.
|
||
/// Platform-wide; no tenant filter.
|
||
/// </summary>
|
||
public DbSet<StripeWebhookEvent> StripeWebhookEvents { get; set; }
|
||
|
||
/// <summary>In-app notification bell entries; tenant-filtered with soft delete. The bell loads the last 20 records (read + unread).</summary>
|
||
public DbSet<InAppNotification> InAppNotifications { get; set; }
|
||
|
||
/// <summary>Records of users accepting the platform Terms of Service; used for compliance auditing.</summary>
|
||
public DbSet<TermsAcceptance> TermsAcceptances { get; set; }
|
||
|
||
/// <summary>
|
||
/// Temporary Stripe checkout registration sessions created during the sign-up flow.
|
||
/// No tenant filter — sessions exist before a Company record is created.
|
||
/// Expired sessions are cleaned up by a background job.
|
||
/// </summary>
|
||
public DbSet<PendingRegistrationSession> PendingRegistrationSessions { get; set; }
|
||
|
||
/// <summary>
|
||
/// WebAuthn passkey credentials registered by users for biometric login (Face ID, fingerprint).
|
||
/// No global query filter — the login flow queries by credentialId before authentication,
|
||
/// requiring cross-tenant lookup. Per-user isolation is enforced in the controller.
|
||
/// </summary>
|
||
public DbSet<UserPasskey> UserPasskeys { get; set; }
|
||
|
||
/// <summary>
|
||
/// Configures the EF Core model: applies entity type configurations from the assembly,
|
||
/// registers global query filters, defines relationships, adds performance indexes, and seeds
|
||
/// static reference data.
|
||
/// <para>
|
||
/// Global query filters are set up here using lambda expressions that close over the
|
||
/// <see cref="CurrentCompanyId"/> and <see cref="IsPlatformAdmin"/> properties.
|
||
/// Because EF Core evaluates those property getters at query execution time (not at startup),
|
||
/// each SQL query automatically includes the correct WHERE clause for the current HTTP request
|
||
/// without any per-controller filtering code.
|
||
/// </para>
|
||
/// <para>
|
||
/// Entities fall into three filter categories:
|
||
/// <list type="bullet">
|
||
/// <item><description>Tenant-operational (most entities): <c>!IsDeleted AND (IsPlatformAdmin OR CompanyId == CurrentCompanyId)</c></description></item>
|
||
/// <item><description>Company-configuration (CompanyOperatingCosts, CompanyPreferences, SubscriptionPlanConfig): soft-delete only.</description></item>
|
||
/// <item><description>Platform-global (AuditLog, DashboardTip, PlatformSetting, etc.): no filter at all.</description></item>
|
||
/// </list>
|
||
/// </para>
|
||
/// </summary>
|
||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||
{
|
||
base.OnModelCreating(modelBuilder);
|
||
|
||
// Apply configurations
|
||
modelBuilder.ApplyConfigurationsFromAssembly(typeof(ApplicationDbContext).Assembly);
|
||
|
||
// Global query filters for soft deletes and multi-tenancy
|
||
// These filters use properties that are evaluated at QUERY TIME, not model build time
|
||
// This ensures proper per-request multi-tenancy isolation
|
||
|
||
// Apply combined filters (soft delete + tenant isolation)
|
||
// The CurrentCompanyId and IsSuperAdmin properties are evaluated for each query
|
||
// Operational entities: only platform admins (demo company) bypass the company filter.
|
||
// Company SuperAdmins (non-demo) see only their own company's records.
|
||
modelBuilder.Entity<Customer>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<Job>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<JobItem>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<JobItemPrepService>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<JobChangeHistory>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<Quote>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<AiItemPrediction>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<PowderUsageLog>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<QuoteItem>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<QuoteItemCoat>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<QuoteItemPrepService>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<QuoteChangeHistory>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<QuotePhoto>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<InventoryItem>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<InventoryTransaction>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<InventoryCategoryLookup>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<Equipment>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<MaintenanceRecord>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<Vendor>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<PricingTier>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<JobPhoto>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<JobNote>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<CustomerNote>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<JobStatusHistory>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<ReworkRecord>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<Refund>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<CreditMemo>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<CreditMemoApplication>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<GiftCertificate>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<GiftCertificateRedemption>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
|
||
// Job Templates
|
||
modelBuilder.Entity<JobTemplate>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<JobTemplateItem>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<JobTemplateItemCoat>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<JobTemplateItemPrepService>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
|
||
// Catalog/product entities: ALL SuperAdmins (both platform and company) bypass the
|
||
// company filter so they can see the full manufacturer product catalog ("manu items").
|
||
modelBuilder.Entity<CatalogCategory>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<CatalogItem>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<CatalogPriceCheckReport>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
|
||
modelBuilder.Entity<Appointment>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<AppointmentStatusLookup>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<NotificationLog>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<NotificationTemplate>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<AppointmentTypeLookup>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
|
||
// Lookup tables (company-specific)
|
||
modelBuilder.Entity<JobStatusLookup>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<JobPriorityLookup>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<QuoteStatusLookup>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<PrepService>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<QuotePrepService>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<JobPrepService>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
|
||
// Company entity doesn't need tenant filter (SuperAdmin manages companies)
|
||
modelBuilder.Entity<Company>().HasQueryFilter(e => !e.IsDeleted);
|
||
|
||
// CompanyBlastSetups — tenant + soft-delete filter
|
||
modelBuilder.Entity<CompanyBlastSetup>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
|
||
// OvenCosts use tenant filter
|
||
modelBuilder.Entity<OvenCost>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
|
||
// Oven Scheduling
|
||
modelBuilder.Entity<OvenBatch>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<OvenBatchItem>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
|
||
// CompanyOperatingCosts doesn't need tenant filter (linked to Company)
|
||
modelBuilder.Entity<CompanyOperatingCosts>().HasQueryFilter(e => !e.IsDeleted);
|
||
modelBuilder.Entity<CompanyPreferences>().HasQueryFilter(e => !e.IsDeleted);
|
||
|
||
// SubscriptionPlanConfig is global (no tenant filter)
|
||
modelBuilder.Entity<SubscriptionPlanConfig>().HasQueryFilter(e => !e.IsDeleted);
|
||
|
||
// BugReports: platform admins see all, others see their own company's
|
||
modelBuilder.Entity<BugReport>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<BugReportAttachment>().HasQueryFilter(e => !e.IsDeleted);
|
||
modelBuilder.Entity<ContactSubmission>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
|
||
// Invoices, InvoiceItems, Payments, Deposits: tenant-filtered
|
||
modelBuilder.Entity<Invoice>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<InvoiceItem>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<Payment>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<Deposit>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
|
||
// Deposit → Invoice (nullable, no cascade)
|
||
modelBuilder.Entity<Deposit>()
|
||
.HasOne(d => d.AppliedToInvoice)
|
||
.WithMany()
|
||
.HasForeignKey(d => d.AppliedToInvoiceId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
// Expense Tracking / Accounts Payable: tenant-filtered
|
||
modelBuilder.Entity<Account>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<Bill>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<BillLineItem>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<BillPayment>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<Expense>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
|
||
// Journal Entries: tenant-filtered; lines use soft-delete only (child rows)
|
||
modelBuilder.Entity<JournalEntry>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<JournalEntryLine>().HasQueryFilter(e => !e.IsDeleted);
|
||
|
||
// Bank Reconciliation: tenant-filtered
|
||
modelBuilder.Entity<BankReconciliation>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
|
||
// Tax Rates: tenant-filtered
|
||
modelBuilder.Entity<TaxRate>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
|
||
// Recurring Templates: tenant-filtered
|
||
modelBuilder.Entity<RecurringTemplate>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
|
||
// Fixed Assets: tenant-filtered with soft delete; depreciation entries soft-delete only
|
||
modelBuilder.Entity<FixedAsset>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<FixedAssetDepreciationEntry>().HasQueryFilter(e => !e.IsDeleted);
|
||
|
||
// FixedAsset → Account (three FKs): NoAction to avoid cascade conflicts; Account has no
|
||
// reverse collection for FixedAssets so WithMany() is anonymous for each.
|
||
modelBuilder.Entity<FixedAsset>()
|
||
.HasOne(fa => fa.AssetAccount)
|
||
.WithMany()
|
||
.HasForeignKey(fa => fa.AssetAccountId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
modelBuilder.Entity<FixedAsset>()
|
||
.HasOne(fa => fa.DepreciationExpenseAccount)
|
||
.WithMany()
|
||
.HasForeignKey(fa => fa.DepreciationExpenseAccountId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
modelBuilder.Entity<FixedAsset>()
|
||
.HasOne(fa => fa.AccumDepreciationAccount)
|
||
.WithMany()
|
||
.HasForeignKey(fa => fa.AccumDepreciationAccountId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// FixedAssetDepreciationEntry → JournalEntry: NoAction (entries outlive their JE)
|
||
modelBuilder.Entity<FixedAssetDepreciationEntry>()
|
||
.HasOne(e => e.JournalEntry)
|
||
.WithMany()
|
||
.HasForeignKey(e => e.JournalEntryId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// Budgets: tenant-filtered; BudgetLines soft-delete only
|
||
modelBuilder.Entity<Budget>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<BudgetLine>().HasQueryFilter(e => !e.IsDeleted);
|
||
|
||
// BudgetLine → Account: Restrict delete so removing an account doesn't cascade into budget data
|
||
modelBuilder.Entity<BudgetLine>()
|
||
.HasOne(bl => bl.Account)
|
||
.WithMany()
|
||
.HasForeignKey(bl => bl.AccountId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// YearEndClose: tenant-filtered; links to a specific JE
|
||
modelBuilder.Entity<YearEndClose>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<YearEndClose>()
|
||
.HasOne(y => y.JournalEntry)
|
||
.WithMany()
|
||
.HasForeignKey(y => y.JournalEntryId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// Vendor Credits: tenant-filtered; child rows soft-delete only
|
||
modelBuilder.Entity<VendorCredit>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<VendorCreditLineItem>().HasQueryFilter(e => !e.IsDeleted);
|
||
modelBuilder.Entity<VendorCreditApplication>().HasQueryFilter(e => !e.IsDeleted);
|
||
|
||
// VendorCreditApplication: NoAction on both FKs to avoid SQL Server multiple-cascade-path error 1785.
|
||
// Bills and VendorCredits both cascade-delete through Vendor, creating two paths to VendorCreditApplications.
|
||
modelBuilder.Entity<VendorCreditApplication>()
|
||
.HasOne(vca => vca.Bill)
|
||
.WithMany()
|
||
.HasForeignKey(vca => vca.BillId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
modelBuilder.Entity<VendorCreditApplication>()
|
||
.HasOne(vca => vca.VendorCredit)
|
||
.WithMany(vc => vc.Applications)
|
||
.HasForeignKey(vca => vca.VendorCreditId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// Purchase Orders
|
||
modelBuilder.Entity<PurchaseOrder>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<PurchaseOrderItem>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
|
||
// ManufacturerLookupPatterns are global (CompanyId = 0); only soft-delete filter applies
|
||
modelBuilder.Entity<ManufacturerLookupPattern>().HasQueryFilter(e => !e.IsDeleted);
|
||
|
||
modelBuilder.Entity<InAppNotification>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
|
||
// Customer intake kiosk sessions — tenant-filtered + soft delete.
|
||
// Anonymous intake routes must use ignoreQueryFilters:true when loading by SessionToken.
|
||
modelBuilder.Entity<KioskSession>().HasQueryFilter(e =>
|
||
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
|
||
modelBuilder.Entity<KioskSession>()
|
||
.HasIndex(e => e.SessionToken)
|
||
.IsUnique();
|
||
modelBuilder.Entity<KioskSession>()
|
||
.HasOne(k => k.LinkedCustomer)
|
||
.WithMany()
|
||
.HasForeignKey(k => k.LinkedCustomerId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
modelBuilder.Entity<KioskSession>()
|
||
.HasOne(k => k.LinkedJob)
|
||
.WithMany()
|
||
.HasForeignKey(k => k.LinkedJobId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
// Account self-referencing hierarchy
|
||
modelBuilder.Entity<Account>()
|
||
.HasOne(a => a.ParentAccount)
|
||
.WithMany(a => a.SubAccounts)
|
||
.HasForeignKey(a => a.ParentAccountId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// JournalEntry self-referencing reversal link
|
||
modelBuilder.Entity<JournalEntry>()
|
||
.HasOne(je => je.ReversalOf)
|
||
.WithMany()
|
||
.HasForeignKey(je => je.ReversalOfId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// BankReconciliation → Account (no cascade)
|
||
modelBuilder.Entity<BankReconciliation>()
|
||
.HasOne(br => br.Account)
|
||
.WithMany()
|
||
.HasForeignKey(br => br.AccountId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// VendorCredit → APAccount (no cascade)
|
||
modelBuilder.Entity<VendorCredit>()
|
||
.HasOne(vc => vc.APAccount)
|
||
.WithMany()
|
||
.HasForeignKey(vc => vc.APAccountId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// VendorCreditLineItem → Account (nullable, no cascade)
|
||
modelBuilder.Entity<VendorCreditLineItem>()
|
||
.HasOne(li => li.Account)
|
||
.WithMany()
|
||
.HasForeignKey(li => li.AccountId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// Vendor → DefaultExpenseAccount (no cascade)
|
||
modelBuilder.Entity<Vendor>()
|
||
.HasOne(s => s.DefaultExpenseAccount)
|
||
.WithMany()
|
||
.HasForeignKey(s => s.DefaultExpenseAccountId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
// Bill → APAccount (no cascade to avoid cycles)
|
||
modelBuilder.Entity<Bill>()
|
||
.HasOne(b => b.APAccount)
|
||
.WithMany(a => a.Bills)
|
||
.HasForeignKey(b => b.APAccountId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// BillLineItem → Account
|
||
modelBuilder.Entity<BillLineItem>()
|
||
.HasOne(li => li.Account)
|
||
.WithMany(a => a.BillLineItems)
|
||
.HasForeignKey(li => li.AccountId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// BillPayment → BankAccount
|
||
modelBuilder.Entity<BillPayment>()
|
||
.HasOne(bp => bp.BankAccount)
|
||
.WithMany(a => a.BillPayments)
|
||
.HasForeignKey(bp => bp.BankAccountId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// BillPayment → Vendor (no cascade; Bill already has FK to Vendor)
|
||
modelBuilder.Entity<BillPayment>()
|
||
.HasOne(bp => bp.Vendor)
|
||
.WithMany(s => s.BillPayments)
|
||
.HasForeignKey(bp => bp.VendorId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// Expense → ExpenseAccount
|
||
modelBuilder.Entity<Expense>()
|
||
.HasOne(e => e.ExpenseAccount)
|
||
.WithMany(a => a.Expenses)
|
||
.HasForeignKey(e => e.ExpenseAccountId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// Expense → PaymentAccount
|
||
modelBuilder.Entity<Expense>()
|
||
.HasOne(e => e.PaymentAccount)
|
||
.WithMany(a => a.ExpensePaymentAccounts)
|
||
.HasForeignKey(e => e.PaymentAccountId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// Payment → DepositAccount (nullable, no cascade)
|
||
modelBuilder.Entity<Payment>()
|
||
.HasOne(p => p.DepositAccount)
|
||
.WithMany()
|
||
.HasForeignKey(p => p.DepositAccountId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// Invoice → SalesTaxAccount (nullable, no cascade)
|
||
modelBuilder.Entity<Invoice>()
|
||
.HasOne(i => i.SalesTaxAccount)
|
||
.WithMany()
|
||
.HasForeignKey(i => i.SalesTaxAccountId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// InvoiceItem → RevenueAccount (nullable, no cascade)
|
||
modelBuilder.Entity<InvoiceItem>()
|
||
.HasOne(ii => ii.RevenueAccount)
|
||
.WithMany()
|
||
.HasForeignKey(ii => ii.RevenueAccountId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// InventoryItem → InventoryAccount / CogsAccount (nullable, no cascade — accounts use soft delete)
|
||
modelBuilder.Entity<InventoryItem>()
|
||
.HasOne(i => i.InventoryAccount)
|
||
.WithMany()
|
||
.HasForeignKey(i => i.InventoryAccountId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
modelBuilder.Entity<InventoryItem>()
|
||
.HasOne(i => i.CogsAccount)
|
||
.WithMany()
|
||
.HasForeignKey(i => i.CogsAccountId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// CatalogItem → RevenueAccount / CogsAccount (nullable, no cascade — accounts use soft delete)
|
||
modelBuilder.Entity<CatalogItem>()
|
||
.HasOne(ci => ci.RevenueAccount)
|
||
.WithMany()
|
||
.HasForeignKey(ci => ci.RevenueAccountId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
modelBuilder.Entity<CatalogItem>()
|
||
.HasOne(ci => ci.CogsAccount)
|
||
.WithMany()
|
||
.HasForeignKey(ci => ci.CogsAccountId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// Performance indexes for frequently filtered non-FK columns
|
||
// Jobs — dashboard and list views filter heavily on dates and status
|
||
modelBuilder.Entity<Job>().HasIndex(j => j.DueDate);
|
||
modelBuilder.Entity<Job>().HasIndex(j => j.ScheduledDate);
|
||
modelBuilder.Entity<Job>().HasIndex(j => new { j.CompanyId, j.IsDeleted });
|
||
|
||
// Quotes — dashboard filters on expiry; list views filter by status
|
||
modelBuilder.Entity<Quote>().HasIndex(q => q.ExpirationDate);
|
||
modelBuilder.Entity<Quote>().HasIndex(q => new { q.CompanyId, q.IsDeleted });
|
||
|
||
// Invoices — dashboard and financial reports filter on status and dates
|
||
modelBuilder.Entity<Invoice>().HasIndex(i => i.Status);
|
||
modelBuilder.Entity<Invoice>().HasIndex(i => i.DueDate);
|
||
modelBuilder.Entity<Invoice>().HasIndex(i => i.InvoiceDate);
|
||
modelBuilder.Entity<Invoice>().HasIndex(i => new { i.CompanyId, i.IsDeleted });
|
||
// Unique invoice number per company (includes soft-deleted to prevent reuse)
|
||
modelBuilder.Entity<Invoice>()
|
||
.HasIndex(i => new { i.CompanyId, i.InvoiceNumber })
|
||
.IsUnique()
|
||
.HasFilter(null); // enforce across all rows including soft-deleted
|
||
|
||
// Unique memo number per company (prevents duplicate CM numbers across companies)
|
||
modelBuilder.Entity<CreditMemo>()
|
||
.HasIndex(m => new { m.CompanyId, m.MemoNumber })
|
||
.IsUnique()
|
||
.HasFilter(null);
|
||
|
||
// Gift certificate codes must be unique per company (redemption is tenant-scoped via global query filter)
|
||
modelBuilder.Entity<GiftCertificate>()
|
||
.HasIndex(gc => new { gc.CompanyId, gc.CertificateCode })
|
||
.IsUnique()
|
||
.HasFilter(null);
|
||
|
||
// Payments — dashboard aggregates by payment date
|
||
modelBuilder.Entity<Payment>().HasIndex(p => p.PaymentDate);
|
||
|
||
// Maintenance — dashboard filters on status and scheduled date
|
||
modelBuilder.Entity<MaintenanceRecord>().HasIndex(m => m.Status);
|
||
modelBuilder.Entity<MaintenanceRecord>().HasIndex(m => m.ScheduledDate);
|
||
|
||
// Oven scheduler — filters batches by date and status every page load
|
||
modelBuilder.Entity<OvenBatch>().HasIndex(o => new { o.ScheduledDate, o.Status });
|
||
|
||
// Jobs — FK to status/priority lookups hit on every list/analytics query
|
||
modelBuilder.Entity<Job>().HasIndex(j => j.JobStatusId);
|
||
modelBuilder.Entity<Job>().HasIndex(j => j.JobPriorityId);
|
||
|
||
// Quotes — FK to status lookup
|
||
modelBuilder.Entity<Quote>().HasIndex(q => q.QuoteStatusId);
|
||
|
||
// Bills — status and due-date filtering in AP reports and overdue detection
|
||
modelBuilder.Entity<Bill>().HasIndex(b => b.Status);
|
||
modelBuilder.Entity<Bill>().HasIndex(b => b.DueDate);
|
||
modelBuilder.Entity<Bill>().HasIndex(b => new { b.CompanyId, b.Status });
|
||
|
||
// Appointments — date + status filtering on list view and analytics
|
||
modelBuilder.Entity<Appointment>().HasIndex(a => a.ScheduledStartTime);
|
||
modelBuilder.Entity<Appointment>().HasIndex(a => new { a.CompanyId, a.AppointmentStatusId });
|
||
|
||
// Inventory — active-flag filtering on list view and stats queries
|
||
modelBuilder.Entity<InventoryItem>().HasIndex(i => i.IsActive);
|
||
modelBuilder.Entity<InventoryItem>().HasIndex(i => new { i.CompanyId, i.IsActive });
|
||
|
||
// Inventory transactions — powder usage analytics filters on type + date
|
||
modelBuilder.Entity<InventoryTransaction>().HasIndex(t => new { t.TransactionType, t.TransactionDate });
|
||
|
||
// AuditLog — no query filter; SuperAdmin controller queries directly
|
||
modelBuilder.Entity<AuditLog>()
|
||
.HasIndex(a => new { a.CompanyId, a.Timestamp });
|
||
modelBuilder.Entity<AuditLog>()
|
||
.HasIndex(a => new { a.EntityType, a.EntityId });
|
||
|
||
// Announcements — no tenant filter; visible based on Target logic in app layer
|
||
modelBuilder.Entity<AnnouncementDismissal>()
|
||
.HasOne(d => d.Announcement)
|
||
.WithMany(a => a.Dismissals)
|
||
.HasForeignKey(d => d.AnnouncementId)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
modelBuilder.Entity<AnnouncementDismissal>()
|
||
.HasIndex(d => new { d.AnnouncementId, d.UserId })
|
||
.IsUnique();
|
||
|
||
// Configure decimal precision
|
||
foreach (var property in modelBuilder.Model.GetEntityTypes()
|
||
.SelectMany(t => t.GetProperties())
|
||
.Where(p => p.ClrType == typeof(decimal) || p.ClrType == typeof(decimal?)))
|
||
{
|
||
property.SetColumnType("decimal(18,2)");
|
||
}
|
||
|
||
// UserPasskey: unique index on CredentialId (WebAuthn requires global uniqueness)
|
||
modelBuilder.Entity<UserPasskey>()
|
||
.HasIndex(p => p.CredentialId)
|
||
.IsUnique();
|
||
|
||
// Configure relationships
|
||
ConfigureRelationships(modelBuilder);
|
||
|
||
// Seed initial data
|
||
SeedInitialData(modelBuilder);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Configures all explicit EF Core relationships (FKs, navigation properties, delete behaviors)
|
||
/// that cannot be inferred by convention or that require a non-default delete behavior.
|
||
/// <para>
|
||
/// Key design decisions encoded here:
|
||
/// <list type="bullet">
|
||
/// <item><description><c>NoAction</c> delete is used extensively on audit / history tables so that deleting a parent entity does not erase the historical record.</description></item>
|
||
/// <item><description><c>Restrict</c> is used on operational FKs to prevent orphaned data (e.g., deleting a Vendor while POs exist).</description></item>
|
||
/// <item><description><c>SetNull</c> is used on optional navigations where the child can meaningfully exist without the parent (e.g., a Payment whose recorder has been deleted).</description></item>
|
||
/// <item><description><c>Cascade</c> is used only where child rows have no independent meaning (e.g., <c>InvoiceItem → Invoice</c>).</description></item>
|
||
/// <item><description>Self-referencing FKs (Account hierarchy, Job.OriginalJob) use <c>Restrict</c> or <c>NoAction</c> to avoid cycles.</description></item>
|
||
/// </list>
|
||
/// </para>
|
||
/// </summary>
|
||
private void ConfigureRelationships(ModelBuilder modelBuilder)
|
||
{
|
||
// AiUsageLog: FK to Company with no cascade so logs survive company deletion.
|
||
// Composite index on (CompanyId, CalledAt) supports the SuperAdmin usage report efficiently.
|
||
modelBuilder.Entity<AiUsageLog>()
|
||
.HasOne(l => l.Company)
|
||
.WithMany()
|
||
.HasForeignKey(l => l.CompanyId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
modelBuilder.Entity<AiUsageLog>()
|
||
.HasIndex(l => new { l.CompanyId, l.CalledAt })
|
||
.HasDatabaseName("IX_AiUsageLogs_CompanyId_CalledAt");
|
||
|
||
// AiItemPrediction: QuoteItem and JobItem each carry a nullable FK pointing to the same
|
||
// prediction record (shared when a quote converts to a job — no duplication).
|
||
// No cascade delete: predictions are audit data and outlive their item records.
|
||
modelBuilder.Entity<QuoteItem>()
|
||
.HasOne(qi => qi.AiPrediction)
|
||
.WithMany()
|
||
.HasForeignKey(qi => qi.AiPredictionId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
modelBuilder.Entity<JobItem>()
|
||
.HasOne(ji => ji.AiPrediction)
|
||
.WithMany()
|
||
.HasForeignKey(ji => ji.AiPredictionId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// PowderUsageLog relationships — no cascade delete (logs are permanent audit data)
|
||
modelBuilder.Entity<PowderUsageLog>()
|
||
.HasOne(l => l.Job)
|
||
.WithMany()
|
||
.HasForeignKey(l => l.JobId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
modelBuilder.Entity<PowderUsageLog>()
|
||
.HasOne(l => l.JobItem)
|
||
.WithMany()
|
||
.HasForeignKey(l => l.JobItemId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
modelBuilder.Entity<PowderUsageLog>()
|
||
.HasOne(l => l.JobItemCoat)
|
||
.WithMany()
|
||
.HasForeignKey(l => l.JobItemCoatId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
modelBuilder.Entity<PowderUsageLog>()
|
||
.HasOne(l => l.InventoryItem)
|
||
.WithMany()
|
||
.HasForeignKey(l => l.InventoryItemId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
modelBuilder.Entity<PowderUsageLog>()
|
||
.HasOne(l => l.InventoryTransaction)
|
||
.WithMany()
|
||
.HasForeignKey(l => l.InventoryTransactionId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// Company relationships
|
||
modelBuilder.Entity<ApplicationUser>()
|
||
.HasOne(u => u.Company)
|
||
.WithMany(c => c.Users)
|
||
.HasForeignKey(u => u.CompanyId)
|
||
.IsRequired(true) // CompanyId is required, but navigation is nullable
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<Customer>()
|
||
.HasOne<Company>()
|
||
.WithMany(c => c.Customers)
|
||
.HasForeignKey(e => e.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<Job>()
|
||
.HasOne<Company>()
|
||
.WithMany(c => c.Jobs)
|
||
.HasForeignKey(e => e.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<Equipment>()
|
||
.HasOne<Company>()
|
||
.WithMany(c => c.Equipment)
|
||
.HasForeignKey(e => e.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<Quote>()
|
||
.HasOne<Company>()
|
||
.WithMany(c => c.Quotes)
|
||
.HasForeignKey(e => e.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<InventoryItem>()
|
||
.HasOne<Company>()
|
||
.WithMany(c => c.InventoryItems)
|
||
.HasForeignKey(e => e.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<Vendor>()
|
||
.HasOne<Company>()
|
||
.WithMany(c => c.Vendors)
|
||
.HasForeignKey(e => e.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<PricingTier>()
|
||
.HasOne<Company>()
|
||
.WithMany(c => c.PricingTiers)
|
||
.HasForeignKey(e => e.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// CompanyOperatingCosts relationship (one-to-one)
|
||
modelBuilder.Entity<CompanyOperatingCosts>()
|
||
.HasOne(c => c.Company)
|
||
.WithOne(c => c.OperatingCosts)
|
||
.HasForeignKey<CompanyOperatingCosts>(c => c.CompanyId)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
|
||
// CompanyPreferences relationship (one-to-one)
|
||
modelBuilder.Entity<CompanyPreferences>()
|
||
.HasOne(c => c.Company)
|
||
.WithOne(c => c.Preferences)
|
||
.HasForeignKey<CompanyPreferences>(c => c.CompanyId)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
|
||
// Customer relationships
|
||
modelBuilder.Entity<Customer>()
|
||
.HasOne(c => c.PricingTier)
|
||
.WithMany(p => p.Customers)
|
||
.HasForeignKey(c => c.PricingTierId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
// Job relationships
|
||
modelBuilder.Entity<Job>()
|
||
.HasOne(j => j.Customer)
|
||
.WithMany(c => c.Jobs)
|
||
.HasForeignKey(j => j.CustomerId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<Job>()
|
||
.HasOne(j => j.Quote)
|
||
.WithOne(q => q.ConvertedToJob)
|
||
.HasForeignKey<Job>(j => j.QuoteId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
// JobChangeHistory relationships
|
||
modelBuilder.Entity<JobChangeHistory>()
|
||
.HasOne(h => h.Job)
|
||
.WithMany()
|
||
.HasForeignKey(h => h.JobId)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
|
||
modelBuilder.Entity<JobChangeHistory>()
|
||
.HasOne(h => h.ChangedBy)
|
||
.WithMany()
|
||
.HasForeignKey(h => h.ChangedByUserId)
|
||
.IsRequired(false)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<JobChangeHistory>()
|
||
.HasOne<Company>()
|
||
.WithMany()
|
||
.HasForeignKey(h => h.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// Quote relationships
|
||
modelBuilder.Entity<Quote>()
|
||
.HasOne(q => q.Customer)
|
||
.WithMany(c => c.Quotes)
|
||
.HasForeignKey(q => q.CustomerId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<Quote>()
|
||
.HasOne(q => q.PreparedBy)
|
||
.WithMany(u => u.PreparedQuotes)
|
||
.HasForeignKey(q => q.PreparedById)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
// CompanyBlastSetup relationships
|
||
modelBuilder.Entity<CompanyBlastSetup>()
|
||
.HasOne(b => b.Company)
|
||
.WithMany()
|
||
.HasForeignKey(b => b.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<QuoteItemPrepService>()
|
||
.HasOne(q => q.BlastSetup)
|
||
.WithMany()
|
||
.HasForeignKey(q => q.BlastSetupId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
modelBuilder.Entity<JobItemPrepService>()
|
||
.HasOne(j => j.BlastSetup)
|
||
.WithMany()
|
||
.HasForeignKey(j => j.BlastSetupId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
// OvenCost relationships
|
||
modelBuilder.Entity<OvenCost>()
|
||
.HasOne(o => o.Company)
|
||
.WithMany()
|
||
.HasForeignKey(o => o.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<Quote>()
|
||
.HasOne(q => q.OvenCost)
|
||
.WithMany(o => o.Quotes)
|
||
.HasForeignKey(q => q.OvenCostId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<Job>()
|
||
.HasOne(j => j.OvenCost)
|
||
.WithMany(o => o.Jobs)
|
||
.HasForeignKey(j => j.OvenCostId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// QuoteChangeHistory relationships
|
||
modelBuilder.Entity<QuoteChangeHistory>()
|
||
.HasOne(h => h.Quote)
|
||
.WithMany()
|
||
.HasForeignKey(h => h.QuoteId)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
|
||
modelBuilder.Entity<QuoteChangeHistory>()
|
||
.HasOne(h => h.ChangedBy)
|
||
.WithMany()
|
||
.HasForeignKey(h => h.ChangedByUserId)
|
||
.IsRequired(false)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<QuoteChangeHistory>()
|
||
.HasOne<Company>()
|
||
.WithMany()
|
||
.HasForeignKey(h => h.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// QuoteItemCoat relationships
|
||
modelBuilder.Entity<QuoteItemCoat>()
|
||
.HasOne(c => c.QuoteItem)
|
||
.WithMany(qi => qi.Coats)
|
||
.HasForeignKey(c => c.QuoteItemId)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
|
||
// QuoteItemPrepService relationships
|
||
modelBuilder.Entity<QuoteItemPrepService>()
|
||
.HasOne(ps => ps.QuoteItem)
|
||
.WithMany(qi => qi.PrepServices)
|
||
.HasForeignKey(ps => ps.QuoteItemId)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
|
||
modelBuilder.Entity<QuoteItemPrepService>()
|
||
.HasOne(ps => ps.PrepService)
|
||
.WithMany()
|
||
.HasForeignKey(ps => ps.PrepServiceId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<QuoteItemPrepService>()
|
||
.HasOne<Company>()
|
||
.WithMany()
|
||
.HasForeignKey(ps => ps.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<QuoteItemCoat>()
|
||
.HasOne(c => c.InventoryItem)
|
||
.WithMany()
|
||
.HasForeignKey(c => c.InventoryItemId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<QuoteItemCoat>()
|
||
.HasOne<Company>()
|
||
.WithMany()
|
||
.HasForeignKey(c => c.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// Inventory relationships
|
||
modelBuilder.Entity<InventoryItem>()
|
||
.HasOne(i => i.PrimaryVendor)
|
||
.WithMany(s => s.InventoryItems)
|
||
.HasForeignKey(i => i.PrimaryVendorId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
modelBuilder.Entity<InventoryItem>()
|
||
.HasOne(i => i.InventoryCategory)
|
||
.WithMany(c => c.InventoryItems)
|
||
.HasForeignKey(i => i.InventoryCategoryId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// Equipment relationships
|
||
modelBuilder.Entity<MaintenanceRecord>()
|
||
.HasOne(m => m.Equipment)
|
||
.WithMany(e => e.MaintenanceRecords)
|
||
.HasForeignKey(m => m.EquipmentId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<MaintenanceRecord>()
|
||
.HasOne(m => m.PerformedBy)
|
||
.WithMany(u => u.PerformedMaintenances)
|
||
.HasForeignKey(m => m.PerformedById)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
|
||
|
||
modelBuilder.Entity<Job>()
|
||
.HasOne(j => j.AssignedUser)
|
||
.WithMany()
|
||
.HasForeignKey(j => j.AssignedUserId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
modelBuilder.Entity<MaintenanceRecord>()
|
||
.HasOne(m => m.AssignedUser)
|
||
.WithMany()
|
||
.HasForeignKey(m => m.AssignedUserId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
modelBuilder.Entity<MaintenanceRecord>()
|
||
.HasOne(m => m.RecurrenceParent)
|
||
.WithMany()
|
||
.HasForeignKey(m => m.RecurrenceParentId)
|
||
.OnDelete(DeleteBehavior.ClientSetNull);
|
||
|
||
modelBuilder.Entity<Appointment>()
|
||
.HasOne(a => a.AssignedUser)
|
||
.WithMany()
|
||
.HasForeignKey(a => a.AssignedUserId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// Catalog relationships
|
||
modelBuilder.Entity<CatalogCategory>()
|
||
.HasOne<Company>()
|
||
.WithMany()
|
||
.HasForeignKey(e => e.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<CatalogCategory>()
|
||
.HasOne(c => c.ParentCategory)
|
||
.WithMany(c => c.SubCategories)
|
||
.HasForeignKey(c => c.ParentCategoryId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<CatalogItem>()
|
||
.HasOne<Company>()
|
||
.WithMany()
|
||
.HasForeignKey(e => e.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<CatalogItem>()
|
||
.HasOne(i => i.Category)
|
||
.WithMany(c => c.Items)
|
||
.HasForeignKey(i => i.CategoryId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// Configure indexes
|
||
// Multi-tenancy indexes
|
||
modelBuilder.Entity<Customer>()
|
||
.HasIndex(c => c.CompanyId);
|
||
|
||
modelBuilder.Entity<Job>()
|
||
.HasIndex(j => j.CompanyId);
|
||
|
||
modelBuilder.Entity<Equipment>()
|
||
.HasIndex(e => e.CompanyId);
|
||
|
||
modelBuilder.Entity<Quote>()
|
||
.HasIndex(q => q.CompanyId);
|
||
|
||
modelBuilder.Entity<InventoryItem>()
|
||
.HasIndex(i => i.CompanyId);
|
||
|
||
modelBuilder.Entity<Vendor>()
|
||
.HasIndex(s => s.CompanyId);
|
||
|
||
modelBuilder.Entity<PricingTier>()
|
||
.HasIndex(p => p.CompanyId);
|
||
|
||
modelBuilder.Entity<CatalogCategory>()
|
||
.HasIndex(c => c.CompanyId);
|
||
|
||
modelBuilder.Entity<CatalogCategory>()
|
||
.HasIndex(c => c.ParentCategoryId);
|
||
|
||
modelBuilder.Entity<CatalogItem>()
|
||
.HasIndex(i => i.CompanyId);
|
||
|
||
modelBuilder.Entity<CatalogItem>()
|
||
.HasIndex(i => i.CategoryId);
|
||
|
||
// Unique indexes (scoped to company where applicable)
|
||
modelBuilder.Entity<Customer>()
|
||
.HasIndex(c => new { c.CompanyId, c.Email })
|
||
.IsUnique()
|
||
.HasFilter("[Email] IS NOT NULL");
|
||
|
||
modelBuilder.Entity<Customer>()
|
||
.HasIndex(c => c.CompanyName);
|
||
|
||
modelBuilder.Entity<Customer>()
|
||
.Property(c => c.UnsubscribeToken)
|
||
.HasDefaultValueSql("REPLACE(NEWID(),'-','')");
|
||
|
||
modelBuilder.Entity<Customer>()
|
||
.HasIndex(c => c.UnsubscribeToken)
|
||
.IsUnique()
|
||
.HasDatabaseName("IX_Customers_UnsubscribeToken");
|
||
|
||
modelBuilder.Entity<Job>()
|
||
.HasIndex(j => new { j.CompanyId, j.JobNumber })
|
||
.IsUnique()
|
||
.HasDatabaseName("IX_Jobs_CompanyId_JobNumber");
|
||
|
||
modelBuilder.Entity<Job>()
|
||
.Property(j => j.ShopAccessCode)
|
||
.HasDefaultValueSql("NEWID()");
|
||
|
||
modelBuilder.Entity<Job>()
|
||
.HasIndex(j => new { j.CompanyId, j.ShopAccessCode })
|
||
.IsUnique()
|
||
.HasDatabaseName("IX_Jobs_CompanyId_ShopAccessCode");
|
||
|
||
modelBuilder.Entity<Quote>()
|
||
.HasIndex(q => new { q.CompanyId, q.QuoteNumber })
|
||
.IsUnique()
|
||
.HasDatabaseName("IX_Quotes_CompanyId_QuoteNumber");
|
||
|
||
modelBuilder.Entity<InventoryItem>()
|
||
.HasIndex(i => new { i.CompanyId, i.SKU })
|
||
.IsUnique()
|
||
.HasDatabaseName("IX_InventoryItems_CompanyId_SKU");
|
||
|
||
modelBuilder.Entity<Company>()
|
||
.HasIndex(c => c.CompanyCode)
|
||
.IsUnique();
|
||
|
||
// Lookup table relationships and indexes
|
||
|
||
// JobStatusLookup relationships
|
||
modelBuilder.Entity<JobStatusLookup>()
|
||
.HasOne<Company>()
|
||
.WithMany()
|
||
.HasForeignKey(e => e.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<Job>()
|
||
.HasOne(j => j.JobStatus)
|
||
.WithMany(s => s.Jobs)
|
||
.HasForeignKey(j => j.JobStatusId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<JobStatusLookup>()
|
||
.HasIndex(s => new { s.CompanyId, s.StatusCode })
|
||
.IsUnique();
|
||
|
||
modelBuilder.Entity<JobStatusLookup>()
|
||
.HasIndex(s => s.CompanyId);
|
||
|
||
// JobPriorityLookup relationships
|
||
modelBuilder.Entity<JobPriorityLookup>()
|
||
.HasOne<Company>()
|
||
.WithMany()
|
||
.HasForeignKey(e => e.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<Job>()
|
||
.HasOne(j => j.JobPriority)
|
||
.WithMany(p => p.Jobs)
|
||
.HasForeignKey(j => j.JobPriorityId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<JobPriorityLookup>()
|
||
.HasIndex(p => new { p.CompanyId, p.PriorityCode })
|
||
.IsUnique();
|
||
|
||
modelBuilder.Entity<JobPriorityLookup>()
|
||
.HasIndex(p => p.CompanyId);
|
||
|
||
// QuoteStatusLookup relationships
|
||
modelBuilder.Entity<QuoteStatusLookup>()
|
||
.HasOne<Company>()
|
||
.WithMany()
|
||
.HasForeignKey(e => e.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<Quote>()
|
||
.HasOne(q => q.QuoteStatus)
|
||
.WithMany(s => s.Quotes)
|
||
.HasForeignKey(q => q.QuoteStatusId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<QuoteStatusLookup>()
|
||
.HasIndex(s => new { s.CompanyId, s.StatusCode })
|
||
.IsUnique();
|
||
|
||
modelBuilder.Entity<QuoteStatusLookup>()
|
||
.HasIndex(s => s.CompanyId);
|
||
|
||
// ===================================================================
|
||
// PERFORMANCE OPTIMIZATION: Composite Indexes for Frequent Queries
|
||
// ===================================================================
|
||
|
||
// Job composite indexes for filtering operations
|
||
modelBuilder.Entity<Job>()
|
||
.HasIndex(j => new { j.CompanyId, j.JobStatusId })
|
||
.HasDatabaseName("IX_Jobs_CompanyId_JobStatusId");
|
||
|
||
modelBuilder.Entity<Job>()
|
||
.HasIndex(j => new { j.CompanyId, j.JobPriorityId })
|
||
.HasDatabaseName("IX_Jobs_CompanyId_JobPriorityId");
|
||
|
||
modelBuilder.Entity<Job>()
|
||
.HasIndex(j => new { j.CompanyId, j.CustomerId })
|
||
.HasDatabaseName("IX_Jobs_CompanyId_CustomerId");
|
||
|
||
modelBuilder.Entity<Job>()
|
||
.HasIndex(j => new { j.CompanyId, j.DueDate })
|
||
.HasDatabaseName("IX_Jobs_CompanyId_DueDate");
|
||
|
||
modelBuilder.Entity<Job>()
|
||
.HasIndex(j => new { j.CompanyId, j.ScheduledDate })
|
||
.HasDatabaseName("IX_Jobs_CompanyId_ScheduledDate");
|
||
|
||
// Quote composite indexes
|
||
modelBuilder.Entity<Quote>()
|
||
.HasIndex(q => new { q.CompanyId, q.QuoteStatusId })
|
||
.HasDatabaseName("IX_Quotes_CompanyId_QuoteStatusId");
|
||
|
||
modelBuilder.Entity<Quote>()
|
||
.HasIndex(q => new { q.CompanyId, q.ExpirationDate })
|
||
.HasDatabaseName("IX_Quotes_CompanyId_ExpirationDate");
|
||
|
||
modelBuilder.Entity<Quote>()
|
||
.HasIndex(q => q.ApprovalToken)
|
||
.IsUnique()
|
||
.HasFilter("[ApprovalToken] IS NOT NULL")
|
||
.HasDatabaseName("IX_Quotes_ApprovalToken");
|
||
|
||
// Inventory composite indexes for low-stock queries
|
||
modelBuilder.Entity<InventoryItem>()
|
||
.HasIndex(i => new { i.CompanyId, i.QuantityOnHand, i.ReorderPoint })
|
||
.HasDatabaseName("IX_InventoryItems_CompanyId_Quantity_Reorder");
|
||
|
||
// Maintenance composite index
|
||
modelBuilder.Entity<MaintenanceRecord>()
|
||
.HasIndex(m => new { m.CompanyId, m.Status })
|
||
.HasDatabaseName("IX_MaintenanceRecords_CompanyId_Status");
|
||
|
||
modelBuilder.Entity<MaintenanceRecord>()
|
||
.HasIndex(m => new { m.CompanyId, m.ScheduledDate })
|
||
.HasDatabaseName("IX_MaintenanceRecords_CompanyId_ScheduledDate");
|
||
|
||
// Appointment composite indexes for calendar queries
|
||
modelBuilder.Entity<Appointment>()
|
||
.HasIndex(a => new { a.CompanyId, a.ScheduledStartTime })
|
||
.HasDatabaseName("IX_Appointments_CompanyId_ScheduledStartTime");
|
||
|
||
modelBuilder.Entity<Appointment>()
|
||
.HasIndex(a => new { a.CompanyId, a.AppointmentStatusId })
|
||
.HasDatabaseName("IX_Appointments_CompanyId_AppointmentStatusId");
|
||
|
||
// Equipment composite index for status filtering
|
||
modelBuilder.Entity<Equipment>()
|
||
.HasIndex(e => new { e.CompanyId, e.Status })
|
||
.HasDatabaseName("IX_Equipment_CompanyId_Status");
|
||
|
||
// JobItemPrepService relationships
|
||
modelBuilder.Entity<JobItemPrepService>()
|
||
.HasOne(ps => ps.JobItem)
|
||
.WithMany(ji => ji.PrepServices)
|
||
.HasForeignKey(ps => ps.JobItemId)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
|
||
modelBuilder.Entity<JobItemPrepService>()
|
||
.HasOne(ps => ps.PrepService)
|
||
.WithMany()
|
||
.HasForeignKey(ps => ps.PrepServiceId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<JobItemPrepService>()
|
||
.HasOne<Company>()
|
||
.WithMany()
|
||
.HasForeignKey(ps => ps.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// JobItem → CatalogItem relationship
|
||
modelBuilder.Entity<JobItem>()
|
||
.HasOne(ji => ji.CatalogItem)
|
||
.WithMany()
|
||
.HasForeignKey(ji => ji.CatalogItemId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
// JobItem composite index for job detail queries
|
||
modelBuilder.Entity<JobItem>()
|
||
.HasIndex(ji => new { ji.JobId, ji.IsDeleted })
|
||
.HasDatabaseName("IX_JobItems_JobId_IsDeleted");
|
||
|
||
// JobPhoto composite index for photo gallery queries
|
||
modelBuilder.Entity<JobPhoto>()
|
||
.HasIndex(jp => new { jp.JobId, jp.IsDeleted, jp.DisplayOrder })
|
||
.HasDatabaseName("IX_JobPhotos_JobId_IsDeleted_DisplayOrder");
|
||
|
||
// Additional frequently-queried foreign keys
|
||
modelBuilder.Entity<JobItem>()
|
||
.HasIndex(ji => ji.JobId)
|
||
.HasDatabaseName("IX_JobItems_JobId");
|
||
|
||
modelBuilder.Entity<QuoteItem>()
|
||
.HasIndex(qi => qi.QuoteId)
|
||
.HasDatabaseName("IX_QuoteItems_QuoteId");
|
||
|
||
modelBuilder.Entity<QuoteItemCoat>()
|
||
.HasIndex(qic => qic.QuoteItemId)
|
||
.HasDatabaseName("IX_QuoteItemCoats_QuoteItemId");
|
||
|
||
modelBuilder.Entity<QuoteItemCoat>()
|
||
.HasIndex(qic => qic.InventoryItemId)
|
||
.HasDatabaseName("IX_QuoteItemCoats_InventoryItemId");
|
||
|
||
modelBuilder.Entity<QuoteItemCoat>()
|
||
.HasIndex(qic => qic.CompanyId)
|
||
.HasDatabaseName("IX_QuoteItemCoats_CompanyId");
|
||
|
||
modelBuilder.Entity<JobItemPrepService>()
|
||
.HasIndex(ps => ps.JobItemId)
|
||
.HasDatabaseName("IX_JobItemPrepServices_JobItemId");
|
||
|
||
modelBuilder.Entity<JobItemPrepService>()
|
||
.HasIndex(ps => ps.PrepServiceId)
|
||
.HasDatabaseName("IX_JobItemPrepServices_PrepServiceId");
|
||
|
||
modelBuilder.Entity<JobItemPrepService>()
|
||
.HasIndex(ps => ps.CompanyId)
|
||
.HasDatabaseName("IX_JobItemPrepServices_CompanyId");
|
||
|
||
modelBuilder.Entity<QuoteItemPrepService>()
|
||
.HasIndex(qips => qips.QuoteItemId)
|
||
.HasDatabaseName("IX_QuoteItemPrepServices_QuoteItemId");
|
||
|
||
modelBuilder.Entity<QuoteItemPrepService>()
|
||
.HasIndex(qips => qips.PrepServiceId)
|
||
.HasDatabaseName("IX_QuoteItemPrepServices_PrepServiceId");
|
||
|
||
modelBuilder.Entity<QuoteItemPrepService>()
|
||
.HasIndex(qips => qips.CompanyId)
|
||
.HasDatabaseName("IX_QuoteItemPrepServices_CompanyId");
|
||
|
||
modelBuilder.Entity<JobNote>()
|
||
.HasIndex(jn => new { jn.JobId, jn.CreatedAt })
|
||
.HasDatabaseName("IX_JobNotes_JobId_CreatedAt");
|
||
|
||
modelBuilder.Entity<CustomerNote>()
|
||
.HasIndex(cn => new { cn.CustomerId, cn.CreatedAt })
|
||
.HasDatabaseName("IX_CustomerNotes_CustomerId_CreatedAt");
|
||
|
||
// ===================================================================
|
||
// END PERFORMANCE OPTIMIZATION INDEXES
|
||
// ===================================================================
|
||
|
||
// InventoryCategoryLookup relationships
|
||
modelBuilder.Entity<InventoryCategoryLookup>()
|
||
.HasOne<Company>()
|
||
.WithMany()
|
||
.HasForeignKey(e => e.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<InventoryCategoryLookup>()
|
||
.HasIndex(c => new { c.CompanyId, c.CategoryCode })
|
||
.IsUnique();
|
||
|
||
modelBuilder.Entity<InventoryCategoryLookup>()
|
||
.HasIndex(c => c.CompanyId);
|
||
|
||
// JobStatusHistory relationships (with lookup tables)
|
||
modelBuilder.Entity<JobStatusHistory>()
|
||
.HasOne(h => h.FromStatus)
|
||
.WithMany(s => s.FromStatusHistory)
|
||
.HasForeignKey(h => h.FromStatusId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<JobStatusHistory>()
|
||
.HasOne(h => h.ToStatus)
|
||
.WithMany(s => s.ToStatusHistory)
|
||
.HasForeignKey(h => h.ToStatusId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// NotificationLog relationships
|
||
modelBuilder.Entity<NotificationLog>()
|
||
.HasOne(n => n.Customer)
|
||
.WithMany(c => c.NotificationLogs)
|
||
.HasForeignKey(n => n.CustomerId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
modelBuilder.Entity<NotificationLog>()
|
||
.HasOne(n => n.Job)
|
||
.WithMany()
|
||
.HasForeignKey(n => n.JobId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
modelBuilder.Entity<NotificationLog>()
|
||
.HasOne(n => n.Quote)
|
||
.WithMany()
|
||
.HasForeignKey(n => n.QuoteId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
modelBuilder.Entity<NotificationLog>()
|
||
.HasOne(n => n.Invoice)
|
||
.WithMany()
|
||
.HasForeignKey(n => n.InvoiceId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
// Invoice relationships
|
||
modelBuilder.Entity<Invoice>()
|
||
.HasOne(i => i.Job)
|
||
.WithOne(j => j.Invoice)
|
||
.HasForeignKey<Invoice>(i => i.JobId)
|
||
.IsRequired(false)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<Invoice>()
|
||
.HasOne(i => i.Customer)
|
||
.WithMany(c => c.Invoices)
|
||
.HasForeignKey(i => i.CustomerId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<Invoice>()
|
||
.HasOne(i => i.PreparedBy)
|
||
.WithMany()
|
||
.HasForeignKey(i => i.PreparedById)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
modelBuilder.Entity<Invoice>()
|
||
.HasOne<Company>()
|
||
.WithMany()
|
||
.HasForeignKey(i => i.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// InvoiceItem relationships
|
||
modelBuilder.Entity<InvoiceItem>()
|
||
.HasOne(ii => ii.Invoice)
|
||
.WithMany(i => i.InvoiceItems)
|
||
.HasForeignKey(ii => ii.InvoiceId)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
|
||
modelBuilder.Entity<InvoiceItem>()
|
||
.HasOne(ii => ii.SourceJobItem)
|
||
.WithMany()
|
||
.HasForeignKey(ii => ii.SourceJobItemId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
modelBuilder.Entity<InvoiceItem>()
|
||
.HasOne<Company>()
|
||
.WithMany()
|
||
.HasForeignKey(ii => ii.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// Payment relationships
|
||
modelBuilder.Entity<Payment>()
|
||
.HasOne(p => p.Invoice)
|
||
.WithMany(i => i.Payments)
|
||
.HasForeignKey(p => p.InvoiceId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<Payment>()
|
||
.HasOne(p => p.RecordedBy)
|
||
.WithMany()
|
||
.HasForeignKey(p => p.RecordedById)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
modelBuilder.Entity<Payment>()
|
||
.HasOne<Company>()
|
||
.WithMany()
|
||
.HasForeignKey(p => p.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// Invoice indexes
|
||
modelBuilder.Entity<Invoice>()
|
||
.HasIndex(i => new { i.CompanyId, i.JobId })
|
||
.IsUnique()
|
||
.HasDatabaseName("IX_Invoices_CompanyId_JobId");
|
||
|
||
modelBuilder.Entity<Invoice>()
|
||
.HasIndex(i => new { i.CompanyId, i.InvoiceNumber })
|
||
.IsUnique()
|
||
.HasDatabaseName("IX_Invoices_CompanyId_InvoiceNumber");
|
||
|
||
modelBuilder.Entity<Invoice>()
|
||
.HasIndex(i => new { i.CompanyId, i.Status })
|
||
.HasDatabaseName("IX_Invoices_CompanyId_Status");
|
||
|
||
modelBuilder.Entity<Invoice>()
|
||
.HasIndex(i => new { i.CompanyId, i.ExternalReference })
|
||
.HasDatabaseName("IX_Invoices_CompanyId_ExternalReference");
|
||
|
||
modelBuilder.Entity<Invoice>()
|
||
.HasIndex(i => new { i.CompanyId, i.DueDate })
|
||
.HasDatabaseName("IX_Invoices_CompanyId_DueDate");
|
||
|
||
modelBuilder.Entity<Invoice>()
|
||
.HasIndex(i => new { i.CompanyId, i.CustomerId })
|
||
.HasDatabaseName("IX_Invoices_CompanyId_CustomerId");
|
||
|
||
modelBuilder.Entity<InvoiceItem>()
|
||
.HasIndex(ii => ii.InvoiceId)
|
||
.HasDatabaseName("IX_InvoiceItems_InvoiceId");
|
||
|
||
modelBuilder.Entity<Payment>()
|
||
.HasIndex(p => p.InvoiceId)
|
||
.HasDatabaseName("IX_Payments_InvoiceId");
|
||
|
||
modelBuilder.Entity<Payment>()
|
||
.HasIndex(p => new { p.CompanyId, p.PaymentDate })
|
||
.HasDatabaseName("IX_Payments_CompanyId_PaymentDate");
|
||
|
||
modelBuilder.Entity<NotificationLog>()
|
||
.HasOne<Company>()
|
||
.WithMany()
|
||
.HasForeignKey(n => n.CompanyId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
modelBuilder.Entity<NotificationLog>()
|
||
.HasIndex(n => new { n.CompanyId, n.SentAt })
|
||
.HasDatabaseName("IX_NotificationLogs_CompanyId_SentAt");
|
||
|
||
modelBuilder.Entity<NotificationLog>()
|
||
.HasIndex(n => new { n.CompanyId, n.Status })
|
||
.HasDatabaseName("IX_NotificationLogs_CompanyId_Status");
|
||
|
||
// NotificationTemplate relationships
|
||
modelBuilder.Entity<NotificationTemplate>()
|
||
.HasOne(t => t.Company)
|
||
.WithMany()
|
||
.HasForeignKey(t => t.CompanyId)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
|
||
modelBuilder.Entity<NotificationTemplate>()
|
||
.HasIndex(t => new { t.CompanyId, t.NotificationType, t.Channel })
|
||
.IsUnique()
|
||
.HasDatabaseName("IX_NotificationTemplates_Company_Type_Channel");
|
||
|
||
// PowderCatalogItem — platform-level, no tenant filter, unique on (VendorName, Sku)
|
||
modelBuilder.Entity<PowderCatalogItem>()
|
||
.HasIndex(p => new { p.VendorName, p.Sku })
|
||
.IsUnique()
|
||
.HasDatabaseName("IX_PowderCatalogItems_Vendor_Sku");
|
||
|
||
modelBuilder.Entity<PowderCatalogItem>()
|
||
.HasIndex(p => p.ColorName)
|
||
.HasDatabaseName("IX_PowderCatalogItems_ColorName");
|
||
|
||
// OvenBatch → Equipment (nullable, legacy — batches are historical records)
|
||
modelBuilder.Entity<OvenBatch>()
|
||
.HasOne(b => b.Equipment)
|
||
.WithMany(e => e.OvenBatches)
|
||
.HasForeignKey(b => b.EquipmentId)
|
||
.IsRequired(false)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// OvenBatch → OvenCost (new — scheduler uses Named Ovens)
|
||
modelBuilder.Entity<OvenBatch>()
|
||
.HasOne(b => b.OvenCost)
|
||
.WithMany()
|
||
.HasForeignKey(b => b.OvenCostId)
|
||
.IsRequired(false)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// OvenBatchItem → OvenBatch (cascade delete items when batch deleted)
|
||
modelBuilder.Entity<OvenBatchItem>()
|
||
.HasOne(i => i.Batch)
|
||
.WithMany(b => b.Items)
|
||
.HasForeignKey(i => i.OvenBatchId)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
|
||
// OvenBatchItem → Job (no cascade)
|
||
modelBuilder.Entity<OvenBatchItem>()
|
||
.HasOne(i => i.Job)
|
||
.WithMany()
|
||
.HasForeignKey(i => i.JobId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// OvenBatchItem → JobItem (no cascade)
|
||
modelBuilder.Entity<OvenBatchItem>()
|
||
.HasOne(i => i.JobItem)
|
||
.WithMany()
|
||
.HasForeignKey(i => i.JobItemId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// OvenBatchItem → JobItemCoat (no cascade)
|
||
modelBuilder.Entity<OvenBatchItem>()
|
||
.HasOne(i => i.JobItemCoat)
|
||
.WithMany()
|
||
.HasForeignKey(i => i.JobItemCoatId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// PurchaseOrder → Vendor (Restrict; vendor deletion blocked while POs exist)
|
||
modelBuilder.Entity<PurchaseOrder>()
|
||
.HasOne(po => po.Vendor)
|
||
.WithMany()
|
||
.HasForeignKey(po => po.VendorId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// PurchaseOrder → Bill (SetNull; deleting a bill doesn't remove the PO)
|
||
modelBuilder.Entity<PurchaseOrder>()
|
||
.HasOne(po => po.Bill)
|
||
.WithMany()
|
||
.HasForeignKey(po => po.BillId)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
// PurchaseOrderItem → PurchaseOrder (Cascade)
|
||
modelBuilder.Entity<PurchaseOrderItem>()
|
||
.HasOne(poi => poi.PurchaseOrder)
|
||
.WithMany(po => po.Items)
|
||
.HasForeignKey(poi => poi.PurchaseOrderId)
|
||
.OnDelete(DeleteBehavior.Cascade);
|
||
|
||
// PurchaseOrderItem → InventoryItem (optional; SetNull so deleting an item doesn't block)
|
||
modelBuilder.Entity<PurchaseOrderItem>()
|
||
.HasOne(poi => poi.InventoryItem)
|
||
.WithMany()
|
||
.HasForeignKey(poi => poi.InventoryItemId)
|
||
.IsRequired(false)
|
||
.OnDelete(DeleteBehavior.SetNull);
|
||
|
||
// InventoryTransaction → PurchaseOrder (NoAction; transactions are audit data)
|
||
modelBuilder.Entity<InventoryTransaction>()
|
||
.HasOne(t => t.PurchaseOrder)
|
||
.WithMany()
|
||
.HasForeignKey(t => t.PurchaseOrderId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// InventoryTransaction → Job (NoAction; transactions are audit data, job deletion independent)
|
||
modelBuilder.Entity<InventoryTransaction>()
|
||
.HasOne(t => t.Job)
|
||
.WithMany()
|
||
.HasForeignKey(t => t.JobId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// ReworkRecord → original Job (Restrict; job deletion blocked while rework exists)
|
||
modelBuilder.Entity<ReworkRecord>()
|
||
.HasOne(r => r.Job)
|
||
.WithMany(j => j.ReworkRecords)
|
||
.HasForeignKey(r => r.JobId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// ReworkRecord → optional JobItem (NoAction; item deletion doesn't cascade)
|
||
modelBuilder.Entity<ReworkRecord>()
|
||
.HasOne(r => r.JobItem)
|
||
.WithMany()
|
||
.HasForeignKey(r => r.JobItemId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// ReworkRecord → optional rework Job (NoAction; rework job can exist independently)
|
||
modelBuilder.Entity<ReworkRecord>()
|
||
.HasOne(r => r.ReworkJob)
|
||
.WithMany()
|
||
.HasForeignKey(r => r.ReworkJobId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// Job.OriginalJobId → Job (self-referential; NoAction)
|
||
modelBuilder.Entity<Job>()
|
||
.HasOne(j => j.OriginalJob)
|
||
.WithMany()
|
||
.HasForeignKey(j => j.OriginalJobId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// Refund → Invoice
|
||
modelBuilder.Entity<Refund>()
|
||
.HasOne(r => r.Invoice)
|
||
.WithMany(i => i.Refunds)
|
||
.HasForeignKey(r => r.InvoiceId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// Refund → Payment (optional)
|
||
modelBuilder.Entity<Refund>()
|
||
.HasOne(r => r.Payment)
|
||
.WithMany()
|
||
.HasForeignKey(r => r.PaymentId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// CreditMemo → Customer
|
||
modelBuilder.Entity<CreditMemo>()
|
||
.HasOne(c => c.Customer)
|
||
.WithMany()
|
||
.HasForeignKey(c => c.CustomerId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// CreditMemo → original Invoice (optional)
|
||
modelBuilder.Entity<CreditMemo>()
|
||
.HasOne(c => c.OriginalInvoice)
|
||
.WithMany()
|
||
.HasForeignKey(c => c.OriginalInvoiceId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// CreditMemo → ReworkRecord (optional)
|
||
modelBuilder.Entity<CreditMemo>()
|
||
.HasOne(c => c.ReworkRecord)
|
||
.WithMany()
|
||
.HasForeignKey(c => c.ReworkRecordId)
|
||
.OnDelete(DeleteBehavior.NoAction);
|
||
|
||
// CreditMemoApplication → CreditMemo
|
||
modelBuilder.Entity<CreditMemoApplication>()
|
||
.HasOne(a => a.CreditMemo)
|
||
.WithMany(c => c.Applications)
|
||
.HasForeignKey(a => a.CreditMemoId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// CreditMemoApplication → Invoice
|
||
modelBuilder.Entity<CreditMemoApplication>()
|
||
.HasOne(a => a.Invoice)
|
||
.WithMany(i => i.CreditApplications)
|
||
.HasForeignKey(a => a.InvoiceId)
|
||
.OnDelete(DeleteBehavior.Restrict);
|
||
|
||
// CreditMemo number unique per company
|
||
modelBuilder.Entity<CreditMemo>()
|
||
.HasIndex(c => new { c.CompanyId, c.MemoNumber })
|
||
.IsUnique()
|
||
.HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Seeds static reference data baked directly into the migration history via
|
||
/// <see cref="ModelBuilder.Entity{TEntity}().HasData"/>.
|
||
/// <para>
|
||
/// Only truly global, immutable reference rows belong here (e.g., the three default
|
||
/// <see cref="PricingTier"/> records that every new tenant starts with).
|
||
/// Tenant-specific seed data (demo jobs, customers, etc.) is NOT seeded here —
|
||
/// it is triggered manually through Platform Management → Seed Data so that it
|
||
/// only runs for companies that opt in.
|
||
/// </para>
|
||
/// <para>
|
||
/// Note: <c>HasData</c> rows use hard-coded IDs and are owned by the migration system.
|
||
/// Never delete or renumber these rows without creating a corresponding data migration.
|
||
/// </para>
|
||
/// </summary>
|
||
private void SeedInitialData(ModelBuilder modelBuilder)
|
||
{
|
||
// Seed default pricing tiers
|
||
modelBuilder.Entity<PricingTier>().HasData(
|
||
new PricingTier
|
||
{
|
||
Id = 1,
|
||
TierName = "Standard",
|
||
Description = "Standard pricing for regular customers",
|
||
DiscountPercent = 0,
|
||
IsActive = true,
|
||
CreatedAt = DateTime.UtcNow
|
||
},
|
||
new PricingTier
|
||
{
|
||
Id = 2,
|
||
TierName = "Preferred",
|
||
Description = "5% discount for preferred customers",
|
||
DiscountPercent = 5,
|
||
IsActive = true,
|
||
CreatedAt = DateTime.UtcNow
|
||
},
|
||
new PricingTier
|
||
{
|
||
Id = 3,
|
||
TierName = "Premium",
|
||
Description = "10% discount for premium customers",
|
||
DiscountPercent = 10,
|
||
IsActive = true,
|
||
CreatedAt = DateTime.UtcNow
|
||
}
|
||
);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Intercepts all saves to stamp audit fields and auto-assign <c>CompanyId</c> before
|
||
/// delegating to the EF Core base implementation.
|
||
/// Calling <see cref="UpdateTimestampsAndTenancy"/> first ensures that no caller needs to
|
||
/// manually set <c>CreatedAt</c>, <c>UpdatedAt</c>, <c>CreatedBy</c>, or <c>CompanyId</c>.
|
||
/// </summary>
|
||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||
{
|
||
UpdateTimestampsAndTenancy();
|
||
return base.SaveChangesAsync(cancellationToken);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Iterates all Added/Modified <see cref="BaseEntity"/> entries in the change tracker and
|
||
/// stamps audit fields and multi-tenancy data automatically.
|
||
/// <para>
|
||
/// For <b>Added</b> entities:
|
||
/// <list type="bullet">
|
||
/// <item><description>Sets <c>CreatedAt</c> to UTC now and <c>CreatedBy</c> to the current user's Identity name.</description></item>
|
||
/// <item><description>If <c>CompanyId</c> is still 0 (default), auto-assigns the current tenant's company ID from <see cref="ITenantContext"/>. Controllers can override this by setting <c>CompanyId</c> explicitly before saving — useful for SuperAdmin cross-tenant operations.</description></item>
|
||
/// <item><description>Generates a unique <c>UnsubscribeToken</c> for new <see cref="Customer"/> records if one was not already provided.</description></item>
|
||
/// </list>
|
||
/// </para>
|
||
/// <para>
|
||
/// For <b>Modified</b> entities:
|
||
/// <list type="bullet">
|
||
/// <item><description>Sets <c>UpdatedAt</c> to UTC now and <c>UpdatedBy</c> to the current user's Identity name.</description></item>
|
||
/// </list>
|
||
/// </para>
|
||
/// <para>
|
||
/// Using <see cref="ITenantContext"/> (resolved via the service provider) rather than reading
|
||
/// the HTTP claim directly avoids a circular dependency between the DbContext and Identity,
|
||
/// and allows the same logic to work in background jobs where <c>IHttpContextAccessor</c>
|
||
/// returns null.
|
||
/// </para>
|
||
/// </summary>
|
||
private void UpdateTimestampsAndTenancy()
|
||
{
|
||
var tenantContext = _serviceProvider?.GetService(typeof(ITenantContext)) as ITenantContext;
|
||
var currentCompanyId = tenantContext?.GetCurrentCompanyId();
|
||
var currentUser = _httpContextAccessor?.HttpContext?.User?.Identity?.Name;
|
||
|
||
var entries = ChangeTracker.Entries()
|
||
.Where(e => e.Entity is BaseEntity && (
|
||
e.State == EntityState.Added ||
|
||
e.State == EntityState.Modified));
|
||
|
||
foreach (var entry in entries)
|
||
{
|
||
var entity = (BaseEntity)entry.Entity;
|
||
|
||
if (entry.State == EntityState.Added)
|
||
{
|
||
entity.CreatedAt = DateTime.UtcNow;
|
||
entity.CreatedBy = currentUser;
|
||
|
||
// Auto-set CompanyId for new entities (if not already set)
|
||
if (currentCompanyId.HasValue && entity.CompanyId == 0)
|
||
{
|
||
entity.CompanyId = currentCompanyId.Value;
|
||
}
|
||
|
||
// Ensure Customer always has an unsubscribe token
|
||
if (entity is Customer customer && string.IsNullOrEmpty(customer.UnsubscribeToken))
|
||
{
|
||
customer.UnsubscribeToken = Guid.NewGuid().ToString("N");
|
||
}
|
||
}
|
||
else if (entry.State == EntityState.Modified)
|
||
{
|
||
entity.UpdatedAt = DateTime.UtcNow;
|
||
entity.UpdatedBy = currentUser;
|
||
}
|
||
}
|
||
}
|
||
}
|