Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs
T
spouliot 8acbc8605d Harden multi-tenant isolation across all user-facing controllers
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>
2026-05-17 18:04:22 -04:00

2158 lines
100 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&nbsp;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 JanDec 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;
}
}
}
}