Files
PowderCoatingLogix/src/PowderCoating.Infrastructure/Data/ApplicationDbContext.cs
T
spouliot 54f444d981 Add AI Catalog Price Check feature
Claude reviews every active catalog item against the shop's own operating costs
and returns a per-item verdict (below-cost / thin-margin / high / ok) with a
suggested price range, cost floor, and assumptions.

- New entity: CatalogPriceCheckReport (JSON blob, archived per company)
- New service: IAiCatalogPriceCheckService / AiCatalogPriceCheckService
  batches items 25 at a time to stay within model context limits
- Two new controller actions: GET AiPriceCheck (view report) + POST RunAiPriceCheck
- AiPriceCheck view: summary cards (counts by verdict), color-coded item cards
  with Edit Price link, assumptions detail, and loading spinner on submit
- AI Price Check button added to catalog Index header
- Migration AddCatalogPriceCheckReport applied

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 18:41:56 -04:00

1976 lines
90 KiB
C#

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>
{
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;
return null;
}
}
/// <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
{
if (!IsSuperAdmin) return false;
return CurrentCompanyId == null || 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>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>Shop worker profiles with role assignments; tenant-filtered with soft delete.</summary>
public DbSet<ShopWorker> ShopWorkers { get; set; }
/// <summary>Per-role labour cost rates used in pricing calculations; unique index on (CompanyId, Role).</summary>
public DbSet<ShopWorkerRoleCost> ShopWorkerRoleCosts { 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>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; }
// 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; }
/// <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>
/// 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<ShopWorker>().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity<ShopWorkerRoleCost>().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));
// 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));
// Account self-referencing hierarchy
modelBuilder.Entity<Account>()
.HasOne(a => a.ParentAccount)
.WithMany(a => a.SubAccounts)
.HasForeignKey(a => a.ParentAccountId)
.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);
// ShopWorker relationships
modelBuilder.Entity<ShopWorker>()
.HasOne<Company>()
.WithMany(c => c.ShopWorkers)
.HasForeignKey(e => e.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
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<ShopWorker>()
.HasIndex(w => w.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<ShopWorkerRoleCost>()
.HasIndex(r => new { r.CompanyId, r.Role })
.IsUnique()
.HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role");
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");
// 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;
}
}
}
}