using Fitness_core_BE.Models; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Design; namespace Fitness_core_BE { // Per-tenant database: one gym's own operational data (staff, memberships, invoices, // access control, attendance, ...). One instance of this context per resolved TenantId // -- see Infra/Tenancy/TenantConnectionResolver.cs. Row-split entities (MealPlan/ // WorkoutPlan family, PaymentTransactions) hold this tenant's own client-authored plans // / member payments here -- CentralDbContext holds the same entity types for admin // templates / platform-billing rows. public class TenantDbContext : DbContext { public TenantDbContext(DbContextOptions options) : base(options) { } // The local anchor every tenant-scoped Customer-referencing table FKs to instead of // the central Customer.CusId directly (EF/MySQL can't enforce a FK across two // separate connections). public DbSet TenantCustomerLink => Set(); // Staff & RBAC public DbSet Staff => Set(); public DbSet StaffPayRates => Set(); public DbSet StaffPayments => Set(); public DbSet GymRoles => Set(); public DbSet GymRolePermissions => Set(); // Memberships public DbSet CustomerPlans => Set(); public DbSet CustomerSubscriptions => Set(); public DbSet SubscriptionMembers => Set(); public DbSet SubscriptionInstallments => Set(); public DbSet CustomerSubItems => Set(); public DbSet CustomerInvoices => Set(); public DbSet CustomerInvoiceItems => Set(); public DbSet CustomerCards => Set(); // Row-split: only rows with FK_CustomerInvoiceId set (member payments) live here -- // platform billing payments (FK_ClientInvoiceId set) live in CentralDbContext instead. public DbSet PaymentTransactions => Set(); public DbSet LedgerEntries => Set(); public DbSet ClientTerms => Set(); public DbSet CustomerTermsAcceptances => Set(); // Access control & devices public DbSet AccessDevices => Set(); public DbSet AccessDeviceBindings => Set(); public DbSet AccessProfiles => Set(); public DbSet UnlinkedDeviceEnrollments => Set(); public DbSet CustomerAccess => Set(); // Agent/PairingCode moved to CentralDbContext -- see its DbSet comment. FKAgentId on // AccessDevice below stays a plain scalar column (unenforced cross-schema reference). public DbSet DeviceCommands => Set(); public DbSet Attendances => Set(); // Client-authored (non-template) meal/workout plans + assignment join tables public DbSet MealPlans => Set(); public DbSet Meal => Set(); public DbSet CustomerMeals => Set(); public DbSet WorkoutPlans => Set(); public DbSet WorkoutItems => Set(); public DbSet WorkoutExercises => Set(); public DbSet CustomerWorkouts => Set(); public DbSet CustomerWorkoutExerciseCompletions => Set(); // Multi-facility session booking + RFID time-bank cards (pool/court/class booking, one // per club since single-tenant), and access-zone/device-scoping -- Facility merges what // used to be two separate concepts (bookable capacity + access zone), see Models/Facility.cs. public DbSet Facilities => Set(); public DbSet FacilitySessions => Set(); public DbSet Bookings => Set(); public DbSet TimeBankCards => Set(); public DbSet TimeBankCardUsageLogs => Set(); public DbSet CustomerPlanFacilities => Set(); // Booking-scoped RFID tag inventory (assigned to a Booking at check-in, released at // Complete/no-show sweep) -- see Models/RfidTag.cs. Not to be confused with // TimeBankCard's own, unrelated RfidTagId string field. public DbSet RfidTags => Set(); // Snapshot receipt created whenever a Booking's payment is recorded -- see // Models/BookingInvoice.cs. Deliberately separate from CustomerInvoice (membership-only). public DbSet BookingInvoices => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); // Central-only entity types must never be part of this model at all. Ignoring // individual navigations (below) isn't enough on its own: an unignored central // collection nav elsewhere (e.g. Customer.CustomerSubscriptions/.CustomerMeals/ // .CustomerWorkouts, Client.ClientBusinesses) pulls the whole // type into this context's model via EF's reachability-based discovery, which then // invents a *second*, bogus shadow-FK relationship (e.g. a phantom // "CustomerCusId" column) between that type and whatever tenant entity also // happens to have its own real FK to it (e.g. CustomerSubscription, CustomerMeal, // CustomerWorkout via TenantCustomerLink) -- breaking every query against that // tenant entity with "Unknown column" once EF tries to select the phantom column. // Blanket-ignoring the type at the root closes every such path at once, including // ones not yet hit by a specific per-navigation Ignore below. modelBuilder.Ignore(); modelBuilder.Ignore(); // These were previously missing from this list. On separate physical databases // (GYM's production layout) the gap was harmless -- the schema was hand-maintained/ // EnsureCreated, so these reachability-discovered phantom entities never actually // got materialized as tables. Generating a real migration from this context's model // (both contexts now share one DB) surfaced it immediately: each of these got its // own duplicate table (usually singular-named, since they have no explicit DbSet // here) alongside CentralDbContext's real, correctly-named one. Ignoring them here // closes every reachability path at once, same as Customer/Client/WorkoutCategory // above. modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); modelBuilder.Ignore(); // Plan categories are admin-authored and live centrally (CentralDbContext owns // and seeds them; WorkoutRepository/MealRepository read and write them via // _central). Left un-ignored, WorkoutPlan.WorkoutCategory / MealPlan.MealCategory // drag the type into this model, which materialises an empty tenant-side // "workoutcategory"/"mealcategory" table AND a real FK from the plan table to it // -- so creating a gym's own plan fails the constraint against a table that is // never populated. modelBuilder.Ignore(); modelBuilder.Ignore(); // Agent/PairingCode now live only in CentralDbContext (see its DbSet comment). modelBuilder.Ignore(); modelBuilder.Ignore(); // Central-pointing navigation properties this context can't resolve -- the FK // scalar column stays (it's still real data, just not a same-context relationship // once central/tenant are physically separate DBs). modelBuilder.Entity().Ignore(a => a.Client); modelBuilder.Entity().Ignore(a => a.User); modelBuilder.Entity().Ignore(p => p.Client); modelBuilder.Entity().Ignore(p => p.User); modelBuilder.Entity().Ignore(d => d.Client); modelBuilder.Entity().Ignore(c => c.Customer); // A followed/assigned plan can be an admin-authored template (central) or this // gym's own plan (tenant) -- GetWorkoutPlanListAsync serves customers the central // templates. An enforced FK to the tenant plan table therefore can't hold: it // rejects every follow of a template. The scalar id stays; which bucket it points // at is resolved in the repository, the same way TenantCustomerLink ids are. modelBuilder.Entity().Ignore(cw => cw.WorkoutPlan); modelBuilder.Entity().Ignore(cm => cm.MealPlan); modelBuilder.Entity().Ignore(c => c.Client); modelBuilder.Entity().Ignore(i => i.Client); modelBuilder.Entity().Ignore(i => i.User); modelBuilder.Entity().Ignore(p => p.Client); modelBuilder.Entity().Ignore(t => t.Client); modelBuilder.Entity().Ignore(l => l.Client); modelBuilder.Entity().Ignore(r => r.Client); modelBuilder.Entity().Ignore(s => s.User); modelBuilder.Entity().Ignore(s => s.Client); modelBuilder.Entity().Ignore(p => p.Client); modelBuilder.Entity().Ignore(p => p.Client); modelBuilder.Entity().Ignore(p => p.Client); modelBuilder.Entity().Ignore(p => p.Customer); modelBuilder.Entity().Ignore(p => p.ClientInvoice); // Row-split entities: real DbSets in both contexts (see class comment), but on one // shared database only one context's migration should physically create the table. // CentralDbContext owns creation here (it also seeds admin-authored templates into // MealPlan/WorkoutPlan* via HasData) -- ExcludeFromMigrations lets this context keep // querying/writing these tables normally without generating a conflicting duplicate // CreateTable for them. modelBuilder.Entity().ToTable(tb => tb.ExcludeFromMigrations()); modelBuilder.Entity().ToTable(tb => tb.ExcludeFromMigrations()); modelBuilder.Entity().ToTable(tb => tb.ExcludeFromMigrations()); modelBuilder.Entity().ToTable(tb => tb.ExcludeFromMigrations()); modelBuilder.Entity().ToTable(tb => tb.ExcludeFromMigrations()); modelBuilder.Entity().ToTable(tb => tb.ExcludeFromMigrations()); // Central-pointing scalar FKs on the new booking entities -- same pattern as every // other .Client nav in this context (FKClientId column stays, real relationship // doesn't, since Client lives only in CentralDbContext). modelBuilder.Entity().Ignore(f => f.Client); modelBuilder.Entity().Ignore(s => s.Client); modelBuilder.Entity().Ignore(b => b.Client); modelBuilder.Entity().Ignore(i => i.Client); modelBuilder.Entity().Ignore(t => t.Client); modelBuilder.Entity().Ignore(l => l.Client); // A session's capacity is enforced by counting non-cancelled bookings against it at // booking time, not by a unique index -- multiple guests/members can share one slot // up to Facility.DefaultCapacity. What must stay unique is a member double-booking // the same session twice. FKFacilitySessionId is null for every AdHoc booking, and // MySQL/InnoDB never treats two NULLs as duplicates in a unique index, so this index // is naturally a no-op for AdHoc bookings -- their own overlap-vs-NumberOfUnits check // lives in BookingRepository.GetOverlappingAdhocBookingCountAsync instead. modelBuilder.Entity() .HasIndex(b => new { b.FKFacilitySessionId, b.FKCustomerId }) .IsUnique() .HasFilter("`FKCustomerId` IS NOT NULL AND `Status` <> 'Cancelled'"); modelBuilder.Entity() .HasIndex(s => new { s.FKFacilityId, s.StartTime }); modelBuilder.Entity() .HasIndex(t => t.RfidTagId) .IsUnique(); modelBuilder.Entity() .HasIndex(b => b.OtpCode); // A customer can appear only once per subscription; installments are unique per position. modelBuilder.Entity() .HasIndex(sm => new { sm.FKSubscriptionId, sm.FKCustomerId }) .IsUnique(); modelBuilder.Entity() .HasIndex(si => new { si.FKSubscriptionId, si.InstallmentNo }) .IsUnique(); // An exercise can only be marked completed once per customer workout. modelBuilder.Entity() .HasIndex(c => new { c.FKCustomerWorkoutId, c.FKWorkoutExerciseId }) .IsUnique(); modelBuilder.Entity() .HasIndex(p => new { p.FKClientId, p.FKUserId }) .IsUnique(); modelBuilder.Entity() .HasIndex(b => new { b.FKAccessProfileId, b.FKDeviceId }) .IsUnique(); modelBuilder.Entity() .HasIndex(b => new { b.FKDeviceId, b.DeviceEmployeeNo }) .IsUnique(); modelBuilder.Entity() .HasOne(b => b.AccessProfile).WithMany(p => p.DeviceBindings) .HasForeignKey(b => b.FKAccessProfileId).OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasOne(b => b.AccessDevice).WithMany() .HasForeignKey(b => b.FKDeviceId).OnDelete(DeleteBehavior.Restrict); modelBuilder.Entity() .HasIndex(d => new { d.FKClientId, d.HardwareId }) .IsUnique(); // Agent now lives centrally -- FKAgentId stays a real column, just not a // same-context FK anymore (same pattern as every other central-pointing scalar). modelBuilder.Entity().Ignore(d => d.Agent); // One device per facility for now -- see AccessDevice.FKFacilityId comment. Lifting // this to many-devices-per-facility later is just dropping this index. modelBuilder.Entity() .HasIndex(d => d.FKFacilityId) .IsUnique() .HasFilter("`FKFacilityId` IS NOT NULL"); modelBuilder.Entity() .HasIndex(pf => new { pf.FKCustomerPlanId, pf.FKFacilityId }) .IsUnique(); modelBuilder.Entity() .HasOne(pf => pf.CustomerPlan).WithMany() .HasForeignKey(pf => pf.FKCustomerPlanId).OnDelete(DeleteBehavior.Cascade); modelBuilder.Entity() .HasOne(pf => pf.Facility).WithMany() .HasForeignKey(pf => pf.FKFacilityId).OnDelete(DeleteBehavior.Restrict); // RFID tag inventory is per-client; a tag code only needs to be unique within one // club's pool. modelBuilder.Entity() .HasIndex(t => new { t.FKClientId, t.TagCode }) .IsUnique(); // AdHoc bookings share a facility's NumberOfUnits, checked by counting overlapping // non-cancelled/non-completed bookings at creation/reschedule time (see // BookingRepository.GetOverlappingAdhocBookingCountAsync) rather than a DB // constraint -- an exclusion constraint over a time range isn't expressible as a // simple unique index, and MySQL/Pomelo has no range-overlap index type. This index // just makes that overlap query efficient. modelBuilder.Entity() .HasIndex(b => new { b.FKFacilityId, b.StartTime, b.EndTime }); modelBuilder.Entity() .HasIndex(i => new { i.FKClientId, i.IssuedAt }); modelBuilder.Entity() .HasIndex(e => new { e.FKDeviceId, e.DeviceEmployeeNo, e.LinkedAt }); modelBuilder.Entity() .HasIndex(a => a.SourceEventKey) .IsUnique(); modelBuilder.Entity() .HasIndex(c => new { c.FKClientId, c.Status, c.NextAttemptAt }); modelBuilder.Entity() .Property(c => c.MaxAttempts) .HasDefaultValue(5); modelBuilder.Entity() .HasKey(p => new { p.FKGymRoleId, p.NavItemKey }); modelBuilder.Entity().Ignore(p => p.NavItem); } } public class TenantDbContextFactory : IDesignTimeDbContextFactory { public TenantDbContext CreateDbContext(string[] args) { var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; var config = new ConfigurationBuilder() .SetBasePath(Directory.GetCurrentDirectory()) .AddJsonFile("appsettings.json", optional: false) .AddJsonFile($"appsettings.{env}.json", optional: true) .Build(); var optionsBuilder = new DbContextOptionsBuilder(); var connectionString = config.GetConnectionString("DefaultConnection"); optionsBuilder.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 0))); return new TenantDbContext(optionsBuilder.Options); } } }