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() .HasOne(q => q.Customer) .WithMany(c => c.Quotes) .HasForeignKey(q => q.CustomerId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(q => q.PreparedBy) .WithMany(u => u.PreparedQuotes) .HasForeignKey(q => q.PreparedById) .OnDelete(DeleteBehavior.SetNull); // CompanyBlastSetup relationships modelBuilder.Entity() .HasOne(b => b.Company) .WithMany() .HasForeignKey(b => b.CompanyId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(q => q.BlastSetup) .WithMany() .HasForeignKey(q => q.BlastSetupId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne(j => j.BlastSetup) .WithMany() .HasForeignKey(j => j.BlastSetupId) .OnDelete(DeleteBehavior.SetNull); // OvenCost relationships modelBuilder.Entity() .HasOne(o => o.Company) .WithMany() .HasForeignKey(o => o.CompanyId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(q => q.OvenCost) .WithMany(o => o.Quotes) .HasForeignKey(q => q.OvenCostId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(j => j.OvenCost) .WithMany(o => o.Jobs) .HasForeignKey(j => j.OvenCostId) .OnDelete(DeleteBehavior.Restrict); // QuoteChangeHistory relationships modelBuilder.Entity() .HasOne(h => h.Quote) .WithMany() .HasForeignKey(h => h.QuoteId) .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); // QuoteItemCoat relationships modelBuilder.Entity() .HasOne(c => c.QuoteItem) .WithMany(qi => qi.Coats) .HasForeignKey(c => c.QuoteItemId) .OnDelete(DeleteBehavior.Cascade); // QuoteItemPrepService relationships modelBuilder.Entity() .HasOne(ps => ps.QuoteItem) .WithMany(qi => qi.PrepServices) .HasForeignKey(ps => ps.QuoteItemId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasOne(ps => ps.PrepService) .WithMany() .HasForeignKey(ps => ps.PrepServiceId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne() .WithMany() .HasForeignKey(ps => ps.CompanyId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(c => c.InventoryItem) .WithMany() .HasForeignKey(c => c.InventoryItemId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne() .WithMany() .HasForeignKey(c => c.CompanyId) .OnDelete(DeleteBehavior.Restrict); // Inventory relationships modelBuilder.Entity() .HasOne(i => i.PrimaryVendor) .WithMany(s => s.InventoryItems) .HasForeignKey(i => i.PrimaryVendorId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne(i => i.InventoryCategory) .WithMany(c => c.InventoryItems) .HasForeignKey(i => i.InventoryCategoryId) .OnDelete(DeleteBehavior.Restrict); // Equipment relationships modelBuilder.Entity() .HasOne(m => m.Equipment) .WithMany(e => e.MaintenanceRecords) .HasForeignKey(m => m.EquipmentId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(m => m.PerformedBy) .WithMany(u => u.PerformedMaintenances) .HasForeignKey(m => m.PerformedById) .OnDelete(DeleteBehavior.SetNull); // ShopWorker relationships modelBuilder.Entity() .HasOne() .WithMany(c => c.ShopWorkers) .HasForeignKey(e => e.CompanyId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(j => j.AssignedUser) .WithMany() .HasForeignKey(j => j.AssignedUserId) .OnDelete(DeleteBehavior.NoAction); modelBuilder.Entity() .HasOne(m => m.AssignedUser) .WithMany() .HasForeignKey(m => m.AssignedUserId) .OnDelete(DeleteBehavior.NoAction); modelBuilder.Entity() .HasOne(m => m.RecurrenceParent) .WithMany() .HasForeignKey(m => m.RecurrenceParentId) .OnDelete(DeleteBehavior.ClientSetNull); modelBuilder.Entity() .HasOne(a => a.AssignedUser) .WithMany() .HasForeignKey(a => a.AssignedUserId) .OnDelete(DeleteBehavior.NoAction); // Catalog relationships modelBuilder.Entity() .HasOne() .WithMany() .HasForeignKey(e => e.CompanyId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(c => c.ParentCategory) .WithMany(c => c.SubCategories) .HasForeignKey(c => c.ParentCategoryId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne() .WithMany() .HasForeignKey(e => e.CompanyId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(i => i.Category) .WithMany(c => c.Items) .HasForeignKey(i => i.CategoryId) .OnDelete(DeleteBehavior.Restrict); // Configure indexes // Multi-tenancy indexes modelBuilder.Entity() .HasIndex(c => c.CompanyId); modelBuilder.Entity() .HasIndex(j => j.CompanyId); modelBuilder.Entity() .HasIndex(e => e.CompanyId); modelBuilder.Entity() .HasIndex(q => q.CompanyId); modelBuilder.Entity() .HasIndex(i => i.CompanyId); modelBuilder.Entity() .HasIndex(s => s.CompanyId); modelBuilder.Entity() .HasIndex(p => p.CompanyId); modelBuilder.Entity() .HasIndex(w => w.CompanyId); modelBuilder.Entity() .HasIndex(c => c.CompanyId); modelBuilder.Entity() .HasIndex(c => c.ParentCategoryId); modelBuilder.Entity() .HasIndex(i => i.CompanyId); modelBuilder.Entity() .HasIndex(i => i.CategoryId); // Unique indexes (scoped to company where applicable) modelBuilder.Entity() .HasIndex(c => new { c.CompanyId, c.Email }) .IsUnique() .HasFilter("[Email] IS NOT NULL"); modelBuilder.Entity() .HasIndex(c => c.CompanyName); modelBuilder.Entity() .Property(c => c.UnsubscribeToken) .HasDefaultValueSql("REPLACE(NEWID(),'-','')"); modelBuilder.Entity() .HasIndex(c => c.UnsubscribeToken) .IsUnique() .HasDatabaseName("IX_Customers_UnsubscribeToken"); modelBuilder.Entity() .HasIndex(j => new { j.CompanyId, j.JobNumber }) .IsUnique() .HasDatabaseName("IX_Jobs_CompanyId_JobNumber"); modelBuilder.Entity() .HasIndex(r => new { r.CompanyId, r.Role }) .IsUnique() .HasDatabaseName("IX_ShopWorkerRoleCosts_CompanyId_Role"); modelBuilder.Entity() .Property(j => j.ShopAccessCode) .HasDefaultValueSql("NEWID()"); modelBuilder.Entity() .HasIndex(j => new { j.CompanyId, j.ShopAccessCode }) .IsUnique() .HasDatabaseName("IX_Jobs_CompanyId_ShopAccessCode"); modelBuilder.Entity() .HasIndex(q => new { q.CompanyId, q.QuoteNumber }) .IsUnique() .HasDatabaseName("IX_Quotes_CompanyId_QuoteNumber"); modelBuilder.Entity() .HasIndex(i => new { i.CompanyId, i.SKU }) .IsUnique() .HasDatabaseName("IX_InventoryItems_CompanyId_SKU"); modelBuilder.Entity() .HasIndex(c => c.CompanyCode) .IsUnique(); // Lookup table relationships and indexes // JobStatusLookup relationships modelBuilder.Entity() .HasOne() .WithMany() .HasForeignKey(e => e.CompanyId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(j => j.JobStatus) .WithMany(s => s.Jobs) .HasForeignKey(j => j.JobStatusId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasIndex(s => new { s.CompanyId, s.StatusCode }) .IsUnique(); modelBuilder.Entity() .HasIndex(s => s.CompanyId); // JobPriorityLookup relationships modelBuilder.Entity() .HasOne() .WithMany() .HasForeignKey(e => e.CompanyId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(j => j.JobPriority) .WithMany(p => p.Jobs) .HasForeignKey(j => j.JobPriorityId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasIndex(p => new { p.CompanyId, p.PriorityCode }) .IsUnique(); modelBuilder.Entity() .HasIndex(p => p.CompanyId); // QuoteStatusLookup relationships modelBuilder.Entity() .HasOne() .WithMany() .HasForeignKey(e => e.CompanyId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(q => q.QuoteStatus) .WithMany(s => s.Quotes) .HasForeignKey(q => q.QuoteStatusId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasIndex(s => new { s.CompanyId, s.StatusCode }) .IsUnique(); modelBuilder.Entity() .HasIndex(s => s.CompanyId); // =================================================================== // PERFORMANCE OPTIMIZATION: Composite Indexes for Frequent Queries // =================================================================== // Job composite indexes for filtering operations modelBuilder.Entity() .HasIndex(j => new { j.CompanyId, j.JobStatusId }) .HasDatabaseName("IX_Jobs_CompanyId_JobStatusId"); modelBuilder.Entity() .HasIndex(j => new { j.CompanyId, j.JobPriorityId }) .HasDatabaseName("IX_Jobs_CompanyId_JobPriorityId"); modelBuilder.Entity() .HasIndex(j => new { j.CompanyId, j.CustomerId }) .HasDatabaseName("IX_Jobs_CompanyId_CustomerId"); modelBuilder.Entity() .HasIndex(j => new { j.CompanyId, j.DueDate }) .HasDatabaseName("IX_Jobs_CompanyId_DueDate"); modelBuilder.Entity() .HasIndex(j => new { j.CompanyId, j.ScheduledDate }) .HasDatabaseName("IX_Jobs_CompanyId_ScheduledDate"); // Quote composite indexes modelBuilder.Entity() .HasIndex(q => new { q.CompanyId, q.QuoteStatusId }) .HasDatabaseName("IX_Quotes_CompanyId_QuoteStatusId"); modelBuilder.Entity() .HasIndex(q => new { q.CompanyId, q.ExpirationDate }) .HasDatabaseName("IX_Quotes_CompanyId_ExpirationDate"); modelBuilder.Entity() .HasIndex(q => q.ApprovalToken) .IsUnique() .HasFilter("[ApprovalToken] IS NOT NULL") .HasDatabaseName("IX_Quotes_ApprovalToken"); // Inventory composite indexes for low-stock queries modelBuilder.Entity() .HasIndex(i => new { i.CompanyId, i.QuantityOnHand, i.ReorderPoint }) .HasDatabaseName("IX_InventoryItems_CompanyId_Quantity_Reorder"); // Maintenance composite index modelBuilder.Entity() .HasIndex(m => new { m.CompanyId, m.Status }) .HasDatabaseName("IX_MaintenanceRecords_CompanyId_Status"); modelBuilder.Entity() .HasIndex(m => new { m.CompanyId, m.ScheduledDate }) .HasDatabaseName("IX_MaintenanceRecords_CompanyId_ScheduledDate"); // Appointment composite indexes for calendar queries modelBuilder.Entity() .HasIndex(a => new { a.CompanyId, a.ScheduledStartTime }) .HasDatabaseName("IX_Appointments_CompanyId_ScheduledStartTime"); modelBuilder.Entity() .HasIndex(a => new { a.CompanyId, a.AppointmentStatusId }) .HasDatabaseName("IX_Appointments_CompanyId_AppointmentStatusId"); // Equipment composite index for status filtering modelBuilder.Entity() .HasIndex(e => new { e.CompanyId, e.Status }) .HasDatabaseName("IX_Equipment_CompanyId_Status"); // JobItemPrepService relationships modelBuilder.Entity() .HasOne(ps => ps.JobItem) .WithMany(ji => ji.PrepServices) .HasForeignKey(ps => ps.JobItemId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasOne(ps => ps.PrepService) .WithMany() .HasForeignKey(ps => ps.PrepServiceId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne() .WithMany() .HasForeignKey(ps => ps.CompanyId) .OnDelete(DeleteBehavior.Restrict); // JobItem → CatalogItem relationship modelBuilder.Entity() .HasOne(ji => ji.CatalogItem) .WithMany() .HasForeignKey(ji => ji.CatalogItemId) .OnDelete(DeleteBehavior.SetNull); // JobItem composite index for job detail queries modelBuilder.Entity() .HasIndex(ji => new { ji.JobId, ji.IsDeleted }) .HasDatabaseName("IX_JobItems_JobId_IsDeleted"); // JobPhoto composite index for photo gallery queries modelBuilder.Entity() .HasIndex(jp => new { jp.JobId, jp.IsDeleted, jp.DisplayOrder }) .HasDatabaseName("IX_JobPhotos_JobId_IsDeleted_DisplayOrder"); // Additional frequently-queried foreign keys modelBuilder.Entity() .HasIndex(ji => ji.JobId) .HasDatabaseName("IX_JobItems_JobId"); modelBuilder.Entity() .HasIndex(qi => qi.QuoteId) .HasDatabaseName("IX_QuoteItems_QuoteId"); modelBuilder.Entity() .HasIndex(qic => qic.QuoteItemId) .HasDatabaseName("IX_QuoteItemCoats_QuoteItemId"); modelBuilder.Entity() .HasIndex(qic => qic.InventoryItemId) .HasDatabaseName("IX_QuoteItemCoats_InventoryItemId"); modelBuilder.Entity() .HasIndex(qic => qic.CompanyId) .HasDatabaseName("IX_QuoteItemCoats_CompanyId"); modelBuilder.Entity() .HasIndex(ps => ps.JobItemId) .HasDatabaseName("IX_JobItemPrepServices_JobItemId"); modelBuilder.Entity() .HasIndex(ps => ps.PrepServiceId) .HasDatabaseName("IX_JobItemPrepServices_PrepServiceId"); modelBuilder.Entity() .HasIndex(ps => ps.CompanyId) .HasDatabaseName("IX_JobItemPrepServices_CompanyId"); modelBuilder.Entity() .HasIndex(qips => qips.QuoteItemId) .HasDatabaseName("IX_QuoteItemPrepServices_QuoteItemId"); modelBuilder.Entity() .HasIndex(qips => qips.PrepServiceId) .HasDatabaseName("IX_QuoteItemPrepServices_PrepServiceId"); modelBuilder.Entity() .HasIndex(qips => qips.CompanyId) .HasDatabaseName("IX_QuoteItemPrepServices_CompanyId"); modelBuilder.Entity() .HasIndex(jn => new { jn.JobId, jn.CreatedAt }) .HasDatabaseName("IX_JobNotes_JobId_CreatedAt"); modelBuilder.Entity() .HasIndex(cn => new { cn.CustomerId, cn.CreatedAt }) .HasDatabaseName("IX_CustomerNotes_CustomerId_CreatedAt"); // =================================================================== // END PERFORMANCE OPTIMIZATION INDEXES // =================================================================== // InventoryCategoryLookup relationships modelBuilder.Entity() .HasOne() .WithMany() .HasForeignKey(e => e.CompanyId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasIndex(c => new { c.CompanyId, c.CategoryCode }) .IsUnique(); modelBuilder.Entity() .HasIndex(c => c.CompanyId); // JobStatusHistory relationships (with lookup tables) modelBuilder.Entity() .HasOne(h => h.FromStatus) .WithMany(s => s.FromStatusHistory) .HasForeignKey(h => h.FromStatusId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(h => h.ToStatus) .WithMany(s => s.ToStatusHistory) .HasForeignKey(h => h.ToStatusId) .OnDelete(DeleteBehavior.Restrict); // NotificationLog relationships modelBuilder.Entity() .HasOne(n => n.Customer) .WithMany(c => c.NotificationLogs) .HasForeignKey(n => n.CustomerId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne(n => n.Job) .WithMany() .HasForeignKey(n => n.JobId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne(n => n.Quote) .WithMany() .HasForeignKey(n => n.QuoteId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne(n => n.Invoice) .WithMany() .HasForeignKey(n => n.InvoiceId) .OnDelete(DeleteBehavior.SetNull); // Invoice relationships modelBuilder.Entity() .HasOne(i => i.Job) .WithOne(j => j.Invoice) .HasForeignKey(i => i.JobId) .IsRequired(false) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(i => i.Customer) .WithMany(c => c.Invoices) .HasForeignKey(i => i.CustomerId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(i => i.PreparedBy) .WithMany() .HasForeignKey(i => i.PreparedById) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne() .WithMany() .HasForeignKey(i => i.CompanyId) .OnDelete(DeleteBehavior.Restrict); // InvoiceItem relationships modelBuilder.Entity() .HasOne(ii => ii.Invoice) .WithMany(i => i.InvoiceItems) .HasForeignKey(ii => ii.InvoiceId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasOne(ii => ii.SourceJobItem) .WithMany() .HasForeignKey(ii => ii.SourceJobItemId) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne() .WithMany() .HasForeignKey(ii => ii.CompanyId) .OnDelete(DeleteBehavior.Restrict); // Payment relationships modelBuilder.Entity() .HasOne(p => p.Invoice) .WithMany(i => i.Payments) .HasForeignKey(p => p.InvoiceId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasOne(p => p.RecordedBy) .WithMany() .HasForeignKey(p => p.RecordedById) .OnDelete(DeleteBehavior.SetNull); modelBuilder.Entity() .HasOne() .WithMany() .HasForeignKey(p => p.CompanyId) .OnDelete(DeleteBehavior.Restrict); // Invoice indexes modelBuilder.Entity() .HasIndex(i => new { i.CompanyId, i.JobId }) .IsUnique() .HasDatabaseName("IX_Invoices_CompanyId_JobId"); modelBuilder.Entity() .HasIndex(i => new { i.CompanyId, i.InvoiceNumber }) .IsUnique() .HasDatabaseName("IX_Invoices_CompanyId_InvoiceNumber"); modelBuilder.Entity() .HasIndex(i => new { i.CompanyId, i.Status }) .HasDatabaseName("IX_Invoices_CompanyId_Status"); modelBuilder.Entity() .HasIndex(i => new { i.CompanyId, i.ExternalReference }) .HasDatabaseName("IX_Invoices_CompanyId_ExternalReference"); modelBuilder.Entity() .HasIndex(i => new { i.CompanyId, i.DueDate }) .HasDatabaseName("IX_Invoices_CompanyId_DueDate"); modelBuilder.Entity() .HasIndex(i => new { i.CompanyId, i.CustomerId }) .HasDatabaseName("IX_Invoices_CompanyId_CustomerId"); modelBuilder.Entity() .HasIndex(ii => ii.InvoiceId) .HasDatabaseName("IX_InvoiceItems_InvoiceId"); modelBuilder.Entity() .HasIndex(p => p.InvoiceId) .HasDatabaseName("IX_Payments_InvoiceId"); modelBuilder.Entity() .HasIndex(p => new { p.CompanyId, p.PaymentDate }) .HasDatabaseName("IX_Payments_CompanyId_PaymentDate"); modelBuilder.Entity() .HasOne() .WithMany() .HasForeignKey(n => n.CompanyId) .OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasIndex(n => new { n.CompanyId, n.SentAt }) .HasDatabaseName("IX_NotificationLogs_CompanyId_SentAt"); modelBuilder.Entity() .HasIndex(n => new { n.CompanyId, n.Status }) .HasDatabaseName("IX_NotificationLogs_CompanyId_Status"); // NotificationTemplate relationships modelBuilder.Entity() .HasOne(t => t.Company) .WithMany() .HasForeignKey(t => t.CompanyId) .OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasIndex(t => new { t.CompanyId, t.NotificationType, t.Channel }) .IsUnique() .HasDatabaseName("IX_NotificationTemplates_Company_Type_Channel"); // PowderCatalogItem — platform-level, no tenant filter, unique on (VendorName, Sku) modelBuilder.Entity() .HasIndex(p => new { p.VendorName, p.Sku }) .IsUnique() .HasDatabaseName("IX_PowderCatalogItems_Vendor_Sku"); modelBuilder.Entity() .HasIndex(p => p.ColorName) .HasDatabaseName("IX_PowderCatalogItems_ColorName"); // OvenBatch → Equipment (nullable, legacy — batches are historical records) modelBuilder.Entity() .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() .HasOne(b => b.OvenCost) .WithMany() .HasForeignKey(b => b.OvenCostId) .IsRequired(false) .OnDelete(DeleteBehavior.Restrict); // OvenBatchItem → OvenBatch (cascade delete items when batch deleted) modelBuilder.Entity() .HasOne(i => i.Batch) .WithMany(b => b.Items) .HasForeignKey(i => i.OvenBatchId) .OnDelete(DeleteBehavior.Cascade); // OvenBatchItem → Job (no cascade) modelBuilder.Entity() .HasOne(i => i.Job) .WithMany() .HasForeignKey(i => i.JobId) .OnDelete(DeleteBehavior.NoAction); // OvenBatchItem → JobItem (no cascade) modelBuilder.Entity() .HasOne(i => i.JobItem) .WithMany() .HasForeignKey(i => i.JobItemId) .OnDelete(DeleteBehavior.NoAction); // OvenBatchItem → JobItemCoat (no cascade) modelBuilder.Entity() .HasOne(i => i.JobItemCoat) .WithMany() .HasForeignKey(i => i.JobItemCoatId) .OnDelete(DeleteBehavior.NoAction); // PurchaseOrder → Vendor (Restrict; vendor deletion blocked while POs exist) modelBuilder.Entity() .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() .HasOne(po => po.Bill) .WithMany() .HasForeignKey(po => po.BillId) .OnDelete(DeleteBehavior.SetNull); // PurchaseOrderItem → PurchaseOrder (Cascade) modelBuilder.Entity() .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() .HasOne(poi => poi.InventoryItem) .WithMany() .HasForeignKey(poi => poi.InventoryItemId) .IsRequired(false) .OnDelete(DeleteBehavior.SetNull); // InventoryTransaction → PurchaseOrder (NoAction; transactions are audit data) modelBuilder.Entity() .HasOne(t => t.PurchaseOrder) .WithMany() .HasForeignKey(t => t.PurchaseOrderId) .OnDelete(DeleteBehavior.NoAction); // InventoryTransaction → Job (NoAction; transactions are audit data, job deletion independent) modelBuilder.Entity() .HasOne(t => t.Job) .WithMany() .HasForeignKey(t => t.JobId) .OnDelete(DeleteBehavior.NoAction); // ReworkRecord → original Job (Restrict; job deletion blocked while rework exists) modelBuilder.Entity() .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() .HasOne(r => r.JobItem) .WithMany() .HasForeignKey(r => r.JobItemId) .OnDelete(DeleteBehavior.NoAction); // ReworkRecord → optional rework Job (NoAction; rework job can exist independently) modelBuilder.Entity() .HasOne(r => r.ReworkJob) .WithMany() .HasForeignKey(r => r.ReworkJobId) .OnDelete(DeleteBehavior.NoAction); // Job.OriginalJobId → Job (self-referential; NoAction) modelBuilder.Entity() .HasOne(j => j.OriginalJob) .WithMany() .HasForeignKey(j => j.OriginalJobId) .OnDelete(DeleteBehavior.NoAction); // Refund → Invoice modelBuilder.Entity() .HasOne(r => r.Invoice) .WithMany(i => i.Refunds) .HasForeignKey(r => r.InvoiceId) .OnDelete(DeleteBehavior.Restrict); // Refund → Payment (optional) modelBuilder.Entity() .HasOne(r => r.Payment) .WithMany() .HasForeignKey(r => r.PaymentId) .OnDelete(DeleteBehavior.NoAction); // CreditMemo → Customer modelBuilder.Entity() .HasOne(c => c.Customer) .WithMany() .HasForeignKey(c => c.CustomerId) .OnDelete(DeleteBehavior.Restrict); // CreditMemo → original Invoice (optional) modelBuilder.Entity() .HasOne(c => c.OriginalInvoice) .WithMany() .HasForeignKey(c => c.OriginalInvoiceId) .OnDelete(DeleteBehavior.NoAction); // CreditMemo → ReworkRecord (optional) modelBuilder.Entity() .HasOne(c => c.ReworkRecord) .WithMany() .HasForeignKey(c => c.ReworkRecordId) .OnDelete(DeleteBehavior.NoAction); // CreditMemoApplication → CreditMemo modelBuilder.Entity() .HasOne(a => a.CreditMemo) .WithMany(c => c.Applications) .HasForeignKey(a => a.CreditMemoId) .OnDelete(DeleteBehavior.Restrict); // CreditMemoApplication → Invoice modelBuilder.Entity() .HasOne(a => a.Invoice) .WithMany(i => i.CreditApplications) .HasForeignKey(a => a.InvoiceId) .OnDelete(DeleteBehavior.Restrict); // CreditMemo number unique per company modelBuilder.Entity() .HasIndex(c => new { c.CompanyId, c.MemoNumber }) .IsUnique() .HasDatabaseName("IX_CreditMemos_CompanyId_MemoNumber"); } /// /// Seeds static reference data baked directly into the migration history via /// . /// /// Only truly global, immutable reference rows belong here (e.g., the three default /// 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. /// /// /// Note: HasData rows use hard-coded IDs and are owned by the migration system. /// Never delete or renumber these rows without creating a corresponding data migration. /// /// private void SeedInitialData(ModelBuilder modelBuilder) { // Seed default pricing tiers modelBuilder.Entity().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 } ); } /// /// Intercepts all saves to stamp audit fields and auto-assign CompanyId before /// delegating to the EF Core base implementation. /// Calling first ensures that no caller needs to /// manually set CreatedAt, UpdatedAt, CreatedBy, or CompanyId. /// public override Task SaveChangesAsync(CancellationToken cancellationToken = default) { UpdateTimestampsAndTenancy(); return base.SaveChangesAsync(cancellationToken); } /// /// Iterates all Added/Modified entries in the change tracker and /// stamps audit fields and multi-tenancy data automatically. /// /// For Added entities: /// /// Sets CreatedAt to UTC now and CreatedBy to the current user's Identity name. /// If CompanyId is still 0 (default), auto-assigns the current tenant's company ID from . Controllers can override this by setting CompanyId explicitly before saving — useful for SuperAdmin cross-tenant operations. /// Generates a unique UnsubscribeToken for new records if one was not already provided. /// /// /// /// For Modified entities: /// /// Sets UpdatedAt to UTC now and UpdatedBy to the current user's Identity name. /// /// /// /// Using (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 IHttpContextAccessor /// returns null. /// /// 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; } } } }