using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using PowderCoating.Core.Entities;
using PowderCoating.Core.Interfaces;
namespace PowderCoating.Infrastructure.Data;
///
/// EF Core DbContext for the Powder Coating Logix application.
///
/// Extends so that ASP.NET Core Identity tables
/// (Users, Roles, Claims, etc.) live in the same database as all business entities.
///
///
/// Two global query filters are applied automatically to every LINQ query against tenant entities:
///
/// - Soft-delete filter — rows whose IsDeleted == true are hidden from all queries by default.
/// - Multi-tenancy filter — non-platform-admin users only see rows whose CompanyId matches their own company.
///
/// Both filters are evaluated at query execution time (not model build time) by reading
/// and from the live HTTP context,
/// so tenant isolation is enforced automatically without any controller-level boilerplate.
/// Bypass either filter by calling .IgnoreQueryFilters() (or passing ignoreQueryFilters: true
/// to repository methods) — reserved for SuperAdmin operations and document-number generation.
///
///
public class ApplicationDbContext : IdentityDbContext
{
private readonly IHttpContextAccessor? _httpContextAccessor;
private readonly IServiceProvider? _serviceProvider;
///
/// Parameterless constructor used by EF Core design-time tooling (migrations, scaffolding).
/// The and are not
/// available at design time, so optional overloads are used for the full runtime path.
///
public ApplicationDbContext(DbContextOptions options)
: base(options)
{
}
///
/// Runtime constructor used by the ASP.NET Core DI container.
/// Receives the so that global query filters can
/// read the current user's CompanyId claim on each query, and the
/// so that can resolve
/// to auto-stamp CompanyId on new entities.
///
public ApplicationDbContext(
DbContextOptions options,
IHttpContextAccessor httpContextAccessor,
IServiceProvider serviceProvider)
: base(options)
{
_httpContextAccessor = httpContextAccessor;
_serviceProvider = serviceProvider;
}
///
/// Resolves the company ID that should be used in global query filters for the current request.
///
/// 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.
///
///
/// Resolution order:
///
/// - If the user is not authenticated, returns null — filters will exclude all tenant rows from unauthenticated requests.
/// - If the user is a SuperAdmin who is currently impersonating a company (session key ImpersonatingCompanyId), returns that company's ID so they see only impersonated tenant data.
/// - Otherwise reads the CompanyId JWT/cookie claim written by at login.
///
///
///
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;
}
}
///
/// Returns true when the currently authenticated user holds the SuperAdmin system role.
/// Evaluated at query execution time so global filter expressions always reflect the live request user.
/// SuperAdmins with this flag set to true and a non-demo company ID still see only their own
/// company's operational data; only unlocks cross-tenant visibility.
///
private bool IsSuperAdmin
{
get
{
return _httpContextAccessor?.HttpContext?.User?.IsInRole("SuperAdmin") == true;
}
}
///
/// Returns true only for SuperAdmin users whose CompanyId is the platform demo
/// company (ID 1) or who have no company association at all (e.g., the break-glass account).
///
/// This distinction is critical: a SuperAdmin created for a specific tenant company
/// (CompanyId != 1) 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.
///
///
/// Only platform admins (this property = true) 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.
///
///
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() calls
// made by the generic Repository. Adding a new entity requires both a
// DbSet here AND a HasQueryFilter call in OnModelCreating.
// -------------------------------------------------------------------------
/// Tenant company records. Soft-delete only filter applies (no CompanyId filter — SuperAdmin manages all companies).
public DbSet Companies { get; set; }
/// Immutable audit trail of SMS terms-of-service acceptances per company. Tenant-filtered by CompanyId; never soft-deleted.
public DbSet CompanySmsAgreements { get; set; }
/// AI quote-item predictions; tenant-filtered. Both QuoteItem and JobItem share a single prediction record via nullable FK (no duplication on quote→job conversion).
public DbSet AiItemPredictions { get; set; }
/// Platform-wide log of every Anthropic API call. No tenant query filter — SuperAdmin reads across all companies for cost/abuse reporting.
public DbSet AiUsageLogs { get; set; }
/// Per-coat powder consumption logs captured when a job coat is completed; tenant-filtered. Used by powder-usage analytics reports.
public DbSet PowderUsageLogs { get; set; }
// Core entities
/// Commercial and non-commercial customer records; tenant-filtered with soft delete.
public DbSet Customers { get; set; }
/// Job records progressing through the 16-status lifecycle; tenant-filtered with soft delete.
public DbSet Jobs { get; set; }
/// Per-day priority overrides that let supervisors re-order the shop floor queue without editing the job itself.
public DbSet JobDailyPriorities { get; set; }
/// Individual line-items on a job (custom work, catalog items, labor); tenant-filtered with soft delete.
public DbSet JobItems { get; set; }
/// Powder coat passes for a job item (color, thickness, coverage); tenant-filtered with soft delete.
public DbSet JobItemCoats { get; set; }
/// Prep-service assignments on a job item (sandblasting, masking, etc.); tenant-filtered with soft delete.
public DbSet JobItemPrepServices { get; set; }
/// Immutable audit trail of every field change on a job; tenant-filtered with soft delete.
public DbSet JobChangeHistories { get; set; }
/// Clock-in / clock-out time entries for workers on a job; used for labor-cost calculations.
public DbSet JobTimeEntries { get; set; }
/// Quote records with multi-item pricing; tenant-filtered with soft delete.
public DbSet Quotes { get; set; }
/// Photos uploaded during the AI Photo Quote workflow; tenant-filtered with soft delete.
public DbSet QuotePhotos { get; set; }
/// Line-items on a quote; tenant-filtered with soft delete.
public DbSet QuoteItems { get; set; }
/// Powder coat passes for a quote item; tenant-filtered with soft delete.
public DbSet QuoteItemCoats { get; set; }
/// Prep-service assignments on a quote item; tenant-filtered with soft delete.
public DbSet QuoteItemPrepServices { get; set; }
/// Immutable audit trail of every field change on a quote; tenant-filtered with soft delete.
public DbSet QuoteChangeHistories { get; set; }
/// Powder and material inventory items; tenant-filtered with soft delete.
public DbSet InventoryItems { get; set; }
/// Stock movement transactions (Purchase, Sale, Adjustment, Waste, etc.); tenant-filtered with soft delete.
public DbSet InventoryTransactions { get; set; }
/// User-defined inventory categories; stored as a lookup table (not an enum) so tenants can customise them.
public DbSet InventoryCategoryLookups { get; set; }
/// Shop equipment (ovens, sandblasters, coating booths); tenant-filtered with soft delete.
public DbSet Equipment { get; set; }
/// Named blast setups per company (cabinet, pressure pot, blast room, etc.); tenant-filtered with soft delete.
public DbSet CompanyBlastSetups { get; set; }
/// Named oven cost configurations used by the Oven Scheduler; tenant-filtered with soft delete.
public DbSet OvenCosts { get; set; }
/// Scheduled oven batches that group jobs for a single cure run; tenant-filtered with soft delete.
public DbSet OvenBatches { get; set; }
/// Individual job/item assignments within an oven batch; tenant-filtered with soft delete.
public DbSet OvenBatchItems { get; set; }
/// Equipment maintenance records (scheduled, in-progress, completed); tenant-filtered with soft delete.
public DbSet MaintenanceRecords { get; set; }
/// Supplier/vendor records used by Purchasing and Accounts Payable; tenant-filtered with soft delete.
public DbSet Vendors { get; set; }
/// Shop worker profiles with role assignments; tenant-filtered with soft delete.
public DbSet ShopWorkers { get; set; }
/// Per-role labour cost rates used in pricing calculations; unique index on (CompanyId, Role).
public DbSet ShopWorkerRoleCosts { get; set; }
/// Rework records tracking quality failures and remediation work against a job; tenant-filtered with soft delete.
public DbSet ReworkRecords { get; set; }
/// Customer refund records; tenant-filtered with soft delete.
public DbSet Refunds { get; set; }
/// Credit memos issued to customers (e.g., after a refund or rework); unique memo number per company.
public DbSet CreditMemos { get; set; }
/// Records of credit memos applied against specific invoices; tenant-filtered with soft delete.
public DbSet CreditMemoApplications { get; set; }
/// Gift certificates issued by the company; certificate code is unique per company.
public DbSet GiftCertificates { get; set; }
/// Redemption events against a gift certificate; tenant-filtered with soft delete.
public DbSet GiftCertificateRedemptions { get; set; }
/// Photos attached to a job (before/after, quality-check, AI analysis); tenant-filtered with soft delete.
public DbSet JobPhotos { get; set; }
/// Free-text notes added to a job by staff; tenant-filtered with soft delete.
public DbSet JobNotes { get; set; }
/// Free-text notes added to a customer record by staff; tenant-filtered with soft delete.
public DbSet CustomerNotes { get; set; }
/// Audit trail of every status transition on a job, referencing the lookup-table statuses.
public DbSet JobStatusHistory { get; set; }
/// Customer pricing tiers (Standard, Preferred, Premium); tenant-filtered with soft delete.
public DbSet PricingTiers { get; set; }
/// Company-level operating cost rates (labor, equipment, overhead) used by the pricing engine; soft-delete only (no tenant filter — linked 1:1 to Company).
public DbSet CompanyOperatingCosts { get; set; }
/// Company-level UI and workflow preferences; soft-delete only (no tenant filter — linked 1:1 to Company).
public DbSet 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.
/// Job status definitions (Pending → Delivered plus terminal states); unique on (CompanyId, StatusCode).
public DbSet JobStatusLookups { get; set; }
/// Job priority definitions (Low, Normal, High, Urgent, Rush); unique on (CompanyId, PriorityCode).
public DbSet JobPriorityLookups { get; set; }
/// Quote status definitions (Draft, Sent, Approved, etc.); unique on (CompanyId, StatusCode).
public DbSet QuoteStatusLookups { get; set; }
/// Appointment status definitions; tenant-filtered with soft delete.
public DbSet AppointmentStatusLookups { get; set; }
/// Appointment type definitions; tenant-filtered with soft delete.
public DbSet AppointmentTypeLookups { get; set; }
/// Prep-service catalog entries (Sandblasting, Masking, etc.) shared across quotes and jobs.
public DbSet PrepServices { get; set; }
/// Many-to-many join table associating a with selected prep services.
public DbSet QuotePrepServices { get; set; }
/// Many-to-many join table associating a with selected prep services.
public DbSet JobPrepServices { get; set; }
/// Customer appointments (service, pickup, consultation); tenant-filtered with soft delete.
public DbSet Appointments { get; set; }
// Product Catalog
/// Hierarchical categories for the service catalog; tenant-filtered with soft delete.
public DbSet CatalogCategories { get; set; }
/// Pre-priced service catalog items that can be added to quotes/jobs; tenant-filtered with soft delete.
public DbSet CatalogItems { get; set; }
/// Most-recent AI price-check report per company; tenant-filtered with soft delete.
public DbSet CatalogPriceCheckReports { get; set; }
// Notifications
/// Log of all outbound notifications (email, SMS, in-app) for audit and retry; tenant-filtered with soft delete.
public DbSet NotificationLogs { get; set; }
/// Per-company, per-channel notification template overrides; unique on (CompanyId, Type, Channel).
public DbSet NotificationTemplates { get; set; }
///
/// 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.
///
public DbSet SubscriptionPlanConfigs { get; set; }
///
/// Platform-level master list of powder coating products across all vendors.
/// Not tenant-scoped — no global query filters applied.
///
public DbSet PowderCatalogItems { get; set; }
/// User-submitted bug reports; tenant-filtered with soft delete.
public DbSet BugReports { get; set; }
/// File attachments for bug reports; soft-delete only (no tenant filter — access controlled via parent BugReport).
public DbSet BugReportAttachments { get; set; }
/// Contact Us form submissions; platform admins see all, company users see their own.
public DbSet ContactSubmissions { get; set; }
// Invoices, Payments & Deposits
/// Customer invoices (1:1 with Job enforced by unique index); tenant-filtered with soft delete.
public DbSet Invoices { get; set; }
/// Line-items on an invoice, with optional back-reference to the originating .
public DbSet InvoiceItems { get; set; }
/// Customer payment records against an invoice; multiple partial payments supported.
public DbSet Payments { get; set; }
///
/// Deposit records collected before a job is invoiced.
/// Auto-applied to the invoice when it is created (unapplied deposits are swept into Payment records).
///
public DbSet Deposits { get; set; }
// Purchase Orders
/// Purchase orders issued to vendors; tenant-filtered with soft delete.
public DbSet PurchaseOrders { get; set; }
/// Individual line-items on a purchase order; cascade-deleted when the PO is deleted.
public DbSet PurchaseOrderItems { get; set; }
// Expense Tracking / Accounts Payable
/// Chart-of-accounts entries (Income, Expense, Asset, Liability); supports parent/child hierarchy via self-referencing FK.
public DbSet Accounts { get; set; }
/// Vendor bills (accounts payable); tenant-filtered with soft delete.
public DbSet Bills { get; set; }
/// Line-items on a vendor bill, each assigned to a chart-of-accounts entry.
public DbSet BillLineItems { get; set; }
/// Payment records against a vendor bill; tracks cash disbursements to vendors.
public DbSet BillPayments { get; set; }
/// Ad-hoc expense records (non-bill spending); tenant-filtered with soft delete.
public DbSet Expenses { get; set; }
// Job Templates
/// Reusable job templates that pre-populate job items, coats, and prep services on job creation.
public DbSet JobTemplates { get; set; }
/// Item definitions within a job template.
public DbSet JobTemplateItems { get; set; }
/// Coat definitions within a job template item.
public DbSet JobTemplateItemCoats { get; set; }
/// Prep-service definitions within a job template item.
public DbSet JobTemplateItemPrepServices { get; set; }
///
/// 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.
///
public DbSet AuditLogs { get; set; }
///
/// 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.
///
public DbSet Announcements { get; set; }
/// Records of which users have dismissed which announcements; unique on (AnnouncementId, UserId).
public DbSet AnnouncementDismissals { get; set; }
/// Platform-wide release notes / changelog entries; no tenant filter.
public DbSet ReleaseNotes { get; set; }
///
/// Global AI lookup patterns for resolving manufacturer product URLs.
/// CompanyId = 0 by convention (these are platform-level, not per-tenant).
/// Soft-delete only filter applied; no tenant filter.
///
public DbSet ManufacturerLookupPatterns { get; set; }
///
/// Rotating dashboard tips shown in the welcome section.
/// Platform-wide (seeded by SeedDataService with 40 tips); no tenant filter.
///
public DbSet DashboardTips { get; set; }
///
/// 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.
///
public DbSet PlatformSettings { get; set; }
///
/// IP address ban list. Login attempts from a matching active entry are rejected
/// before Identity even checks credentials. No tenant filter; SuperAdmin-managed only.
///
public DbSet BannedIps { get; set; }
///
/// Stripe webhook event log for idempotency checking and replay debugging.
/// Platform-wide; no tenant filter.
///
public DbSet StripeWebhookEvents { get; set; }
/// In-app notification bell entries; tenant-filtered with soft delete. The bell loads the last 20 records (read + unread).
public DbSet InAppNotifications { get; set; }
/// Records of users accepting the platform Terms of Service; used for compliance auditing.
public DbSet TermsAcceptances { get; set; }
///
/// 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.
///
public DbSet PendingRegistrationSessions { get; set; }
///
/// 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.
///
public DbSet UserPasskeys { get; set; }
///
/// 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.
///
/// Global query filters are set up here using lambda expressions that close over the
/// and 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.
///
///
/// Entities fall into three filter categories:
///
/// - Tenant-operational (most entities): !IsDeleted AND (IsPlatformAdmin OR CompanyId == CurrentCompanyId)
/// - Company-configuration (CompanyOperatingCosts, CompanyPreferences, SubscriptionPlanConfig): soft-delete only.
/// - Platform-global (AuditLog, DashboardTip, PlatformSetting, etc.): no filter at all.
///
///
///
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().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Job Templates
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().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().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Lookup tables (company-specific)
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Company entity doesn't need tenant filter (SuperAdmin manages companies)
modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted);
// CompanyBlastSetups — tenant + soft-delete filter
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// OvenCosts use tenant filter
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Oven Scheduling
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// CompanyOperatingCosts doesn't need tenant filter (linked to Company)
modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted);
// SubscriptionPlanConfig is global (no tenant filter)
modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted);
// BugReports: platform admins see all, others see their own company's
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Invoices, InvoiceItems, Payments, Deposits: tenant-filtered
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Deposit → Invoice (nullable, no cascade)
modelBuilder.Entity()
.HasOne(d => d.AppliedToInvoice)
.WithMany()
.HasForeignKey(d => d.AppliedToInvoiceId)
.OnDelete(DeleteBehavior.SetNull);
// Expense Tracking / Accounts Payable: tenant-filtered
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Purchase Orders
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// ManufacturerLookupPatterns are global (CompanyId = 0); only soft-delete filter applies
modelBuilder.Entity().HasQueryFilter(e => !e.IsDeleted);
modelBuilder.Entity().HasQueryFilter(e =>
!e.IsDeleted && (IsPlatformAdmin || e.CompanyId == CurrentCompanyId));
// Account self-referencing hierarchy
modelBuilder.Entity()
.HasOne(a => a.ParentAccount)
.WithMany(a => a.SubAccounts)
.HasForeignKey(a => a.ParentAccountId)
.OnDelete(DeleteBehavior.Restrict);
// Vendor → DefaultExpenseAccount (no cascade)
modelBuilder.Entity()
.HasOne(s => s.DefaultExpenseAccount)
.WithMany()
.HasForeignKey(s => s.DefaultExpenseAccountId)
.OnDelete(DeleteBehavior.SetNull);
// Bill → APAccount (no cascade to avoid cycles)
modelBuilder.Entity()
.HasOne(b => b.APAccount)
.WithMany(a => a.Bills)
.HasForeignKey(b => b.APAccountId)
.OnDelete(DeleteBehavior.Restrict);
// BillLineItem → Account
modelBuilder.Entity()
.HasOne(li => li.Account)
.WithMany(a => a.BillLineItems)
.HasForeignKey(li => li.AccountId)
.OnDelete(DeleteBehavior.Restrict);
// BillPayment → BankAccount
modelBuilder.Entity()
.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()
.HasOne(bp => bp.Vendor)
.WithMany(s => s.BillPayments)
.HasForeignKey(bp => bp.VendorId)
.OnDelete(DeleteBehavior.Restrict);
// Expense → ExpenseAccount
modelBuilder.Entity()
.HasOne(e => e.ExpenseAccount)
.WithMany(a => a.Expenses)
.HasForeignKey(e => e.ExpenseAccountId)
.OnDelete(DeleteBehavior.Restrict);
// Expense → PaymentAccount
modelBuilder.Entity()
.HasOne(e => e.PaymentAccount)
.WithMany(a => a.ExpensePaymentAccounts)
.HasForeignKey(e => e.PaymentAccountId)
.OnDelete(DeleteBehavior.Restrict);
// Payment → DepositAccount (nullable, no cascade)
modelBuilder.Entity()
.HasOne(p => p.DepositAccount)
.WithMany()
.HasForeignKey(p => p.DepositAccountId)
.OnDelete(DeleteBehavior.NoAction);
// Invoice → SalesTaxAccount (nullable, no cascade)
modelBuilder.Entity()
.HasOne(i => i.SalesTaxAccount)
.WithMany()
.HasForeignKey(i => i.SalesTaxAccountId)
.OnDelete(DeleteBehavior.NoAction);
// InvoiceItem → RevenueAccount (nullable, no cascade)
modelBuilder.Entity()
.HasOne(ii => ii.RevenueAccount)
.WithMany()
.HasForeignKey(ii => ii.RevenueAccountId)
.OnDelete(DeleteBehavior.NoAction);
// InventoryItem → InventoryAccount / CogsAccount (nullable, no cascade — accounts use soft delete)
modelBuilder.Entity()
.HasOne(i => i.InventoryAccount)
.WithMany()
.HasForeignKey(i => i.InventoryAccountId)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity()
.HasOne(i => i.CogsAccount)
.WithMany()
.HasForeignKey(i => i.CogsAccountId)
.OnDelete(DeleteBehavior.NoAction);
// CatalogItem → RevenueAccount / CogsAccount (nullable, no cascade — accounts use soft delete)
modelBuilder.Entity()
.HasOne(ci => ci.RevenueAccount)
.WithMany()
.HasForeignKey(ci => ci.RevenueAccountId)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity()
.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().HasIndex(j => j.DueDate);
modelBuilder.Entity().HasIndex(j => j.ScheduledDate);
modelBuilder.Entity().HasIndex(j => new { j.CompanyId, j.IsDeleted });
// Quotes — dashboard filters on expiry; list views filter by status
modelBuilder.Entity().HasIndex(q => q.ExpirationDate);
modelBuilder.Entity().HasIndex(q => new { q.CompanyId, q.IsDeleted });
// Invoices — dashboard and financial reports filter on status and dates
modelBuilder.Entity().HasIndex(i => i.Status);
modelBuilder.Entity().HasIndex(i => i.DueDate);
modelBuilder.Entity().HasIndex(i => i.InvoiceDate);
modelBuilder.Entity().HasIndex(i => new { i.CompanyId, i.IsDeleted });
// Unique invoice number per company (includes soft-deleted to prevent reuse)
modelBuilder.Entity()
.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()
.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()
.HasIndex(gc => new { gc.CompanyId, gc.CertificateCode })
.IsUnique()
.HasFilter(null);
// Payments — dashboard aggregates by payment date
modelBuilder.Entity().HasIndex(p => p.PaymentDate);
// Maintenance — dashboard filters on status and scheduled date
modelBuilder.Entity().HasIndex(m => m.Status);
modelBuilder.Entity().HasIndex(m => m.ScheduledDate);
// Oven scheduler — filters batches by date and status every page load
modelBuilder.Entity().HasIndex(o => new { o.ScheduledDate, o.Status });
// Jobs — FK to status/priority lookups hit on every list/analytics query
modelBuilder.Entity().HasIndex(j => j.JobStatusId);
modelBuilder.Entity().HasIndex(j => j.JobPriorityId);
// Quotes — FK to status lookup
modelBuilder.Entity().HasIndex(q => q.QuoteStatusId);
// Bills — status and due-date filtering in AP reports and overdue detection
modelBuilder.Entity().HasIndex(b => b.Status);
modelBuilder.Entity().HasIndex(b => b.DueDate);
modelBuilder.Entity().HasIndex(b => new { b.CompanyId, b.Status });
// Appointments — date + status filtering on list view and analytics
modelBuilder.Entity().HasIndex(a => a.ScheduledStartTime);
modelBuilder.Entity().HasIndex(a => new { a.CompanyId, a.AppointmentStatusId });
// Inventory — active-flag filtering on list view and stats queries
modelBuilder.Entity().HasIndex(i => i.IsActive);
modelBuilder.Entity().HasIndex(i => new { i.CompanyId, i.IsActive });
// Inventory transactions — powder usage analytics filters on type + date
modelBuilder.Entity().HasIndex(t => new { t.TransactionType, t.TransactionDate });
// AuditLog — no query filter; SuperAdmin controller queries directly
modelBuilder.Entity()
.HasIndex(a => new { a.CompanyId, a.Timestamp });
modelBuilder.Entity()
.HasIndex(a => new { a.EntityType, a.EntityId });
// Announcements — no tenant filter; visible based on Target logic in app layer
modelBuilder.Entity()
.HasOne(d => d.Announcement)
.WithMany(a => a.Dismissals)
.HasForeignKey(d => d.AnnouncementId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity()
.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()
.HasIndex(p => p.CredentialId)
.IsUnique();
// Configure relationships
ConfigureRelationships(modelBuilder);
// Seed initial data
SeedInitialData(modelBuilder);
}
///
/// 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.
///
/// Key design decisions encoded here:
///
/// - NoAction delete is used extensively on audit / history tables so that deleting a parent entity does not erase the historical record.
/// - Restrict is used on operational FKs to prevent orphaned data (e.g., deleting a Vendor while POs exist).
/// - SetNull is used on optional navigations where the child can meaningfully exist without the parent (e.g., a Payment whose recorder has been deleted).
/// - Cascade is used only where child rows have no independent meaning (e.g., InvoiceItem → Invoice).
/// - Self-referencing FKs (Account hierarchy, Job.OriginalJob) use Restrict or NoAction to avoid cycles.
///
///
///
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()
.HasOne(l => l.Company)
.WithMany()
.HasForeignKey(l => l.CompanyId)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity()
.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()
.HasOne(qi => qi.AiPrediction)
.WithMany()
.HasForeignKey(qi => qi.AiPredictionId)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity()
.HasOne(ji => ji.AiPrediction)
.WithMany()
.HasForeignKey(ji => ji.AiPredictionId)
.OnDelete(DeleteBehavior.NoAction);
// PowderUsageLog relationships — no cascade delete (logs are permanent audit data)
modelBuilder.Entity()
.HasOne(l => l.Job)
.WithMany()
.HasForeignKey(l => l.JobId)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity()
.HasOne(l => l.JobItem)
.WithMany()
.HasForeignKey(l => l.JobItemId)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity()
.HasOne(l => l.JobItemCoat)
.WithMany()
.HasForeignKey(l => l.JobItemCoatId)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity()
.HasOne(l => l.InventoryItem)
.WithMany()
.HasForeignKey(l => l.InventoryItemId)
.OnDelete(DeleteBehavior.NoAction);
modelBuilder.Entity()
.HasOne(l => l.InventoryTransaction)
.WithMany()
.HasForeignKey(l => l.InventoryTransactionId)
.OnDelete(DeleteBehavior.NoAction);
// Company relationships
modelBuilder.Entity()
.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()
.HasOne()
.WithMany(c => c.Customers)
.HasForeignKey(e => e.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity()
.HasOne()
.WithMany(c => c.Jobs)
.HasForeignKey(e => e.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity()
.HasOne()
.WithMany(c => c.Equipment)
.HasForeignKey(e => e.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity()
.HasOne()
.WithMany(c => c.Quotes)
.HasForeignKey(e => e.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity()
.HasOne()
.WithMany(c => c.InventoryItems)
.HasForeignKey(e => e.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity()
.HasOne()
.WithMany(c => c.Vendors)
.HasForeignKey(e => e.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity()
.HasOne()
.WithMany(c => c.PricingTiers)
.HasForeignKey(e => e.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
// CompanyOperatingCosts relationship (one-to-one)
modelBuilder.Entity()
.HasOne(c => c.Company)
.WithOne(c => c.OperatingCosts)
.HasForeignKey(c => c.CompanyId)
.OnDelete(DeleteBehavior.Cascade);
// CompanyPreferences relationship (one-to-one)
modelBuilder.Entity()
.HasOne(c => c.Company)
.WithOne(c => c.Preferences)
.HasForeignKey(c => c.CompanyId)
.OnDelete(DeleteBehavior.Cascade);
// Customer relationships
modelBuilder.Entity()
.HasOne(c => c.PricingTier)
.WithMany(p => p.Customers)
.HasForeignKey(c => c.PricingTierId)
.OnDelete(DeleteBehavior.SetNull);
// Job relationships
modelBuilder.Entity()
.HasOne(j => j.Customer)
.WithMany(c => c.Jobs)
.HasForeignKey(j => j.CustomerId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity()
.HasOne(j => j.Quote)
.WithOne(q => q.ConvertedToJob)
.HasForeignKey(j => j.QuoteId)
.OnDelete(DeleteBehavior.SetNull);
// JobChangeHistory relationships
modelBuilder.Entity()
.HasOne(h => h.Job)
.WithMany()
.HasForeignKey(h => h.JobId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity()
.HasOne(h => h.ChangedBy)
.WithMany()
.HasForeignKey(h => h.ChangedByUserId)
.IsRequired(false)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity()
.HasOne()
.WithMany()
.HasForeignKey(h => h.CompanyId)
.OnDelete(DeleteBehavior.Restrict);
// Quote relationships
modelBuilder.Entity