Files
Core_Be/TenantDbContext.cs
T
Dhananjaya99 bffc9b1686 first commit
2026-09-28 11:44:26 +05:30

299 lines
18 KiB
C#

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<TenantDbContext> 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> TenantCustomerLink => Set<TenantCustomerLink>();
// Staff & RBAC
public DbSet<Staff> Staff => Set<Staff>();
public DbSet<StaffPayRate> StaffPayRates => Set<StaffPayRate>();
public DbSet<StaffPayment> StaffPayments => Set<StaffPayment>();
public DbSet<GymRole> GymRoles => Set<GymRole>();
public DbSet<GymRolePermission> GymRolePermissions => Set<GymRolePermission>();
// Memberships
public DbSet<CustomerPlan> CustomerPlans => Set<CustomerPlan>();
public DbSet<CustomerSubscription> CustomerSubscriptions => Set<CustomerSubscription>();
public DbSet<SubscriptionMember> SubscriptionMembers => Set<SubscriptionMember>();
public DbSet<SubscriptionInstallment> SubscriptionInstallments => Set<SubscriptionInstallment>();
public DbSet<CustomerSubItem> CustomerSubItems => Set<CustomerSubItem>();
public DbSet<CustomerInvoice> CustomerInvoices => Set<CustomerInvoice>();
public DbSet<CustomerInvoiceItem> CustomerInvoiceItems => Set<CustomerInvoiceItem>();
public DbSet<CustomerCards> CustomerCards => Set<CustomerCards>();
// Row-split: only rows with FK_CustomerInvoiceId set (member payments) live here --
// platform billing payments (FK_ClientInvoiceId set) live in CentralDbContext instead.
public DbSet<PaymentTransaction> PaymentTransactions => Set<PaymentTransaction>();
public DbSet<LedgerEntry> LedgerEntries => Set<LedgerEntry>();
public DbSet<ClientTerms> ClientTerms => Set<ClientTerms>();
public DbSet<CustomerTermsAcceptance> CustomerTermsAcceptances => Set<CustomerTermsAcceptance>();
// Access control & devices
public DbSet<AccessDevice> AccessDevices => Set<AccessDevice>();
public DbSet<AccessDeviceBinding> AccessDeviceBindings => Set<AccessDeviceBinding>();
public DbSet<AccessProfile> AccessProfiles => Set<AccessProfile>();
public DbSet<UnlinkedDeviceEnrollment> UnlinkedDeviceEnrollments => Set<UnlinkedDeviceEnrollment>();
public DbSet<CustomerAccess> CustomerAccess => Set<CustomerAccess>();
// Agent/PairingCode moved to CentralDbContext -- see its DbSet comment. FKAgentId on
// AccessDevice below stays a plain scalar column (unenforced cross-schema reference).
public DbSet<DeviceCommand> DeviceCommands => Set<DeviceCommand>();
public DbSet<Attendance> Attendances => Set<Attendance>();
// Client-authored (non-template) meal/workout plans + assignment join tables
public DbSet<MealPlan> MealPlans => Set<MealPlan>();
public DbSet<Meal> Meal => Set<Meal>();
public DbSet<CustomerMeal> CustomerMeals => Set<CustomerMeal>();
public DbSet<WorkoutPlan> WorkoutPlans => Set<WorkoutPlan>();
public DbSet<WorkoutItem> WorkoutItems => Set<WorkoutItem>();
public DbSet<WorkoutExercise> WorkoutExercises => Set<WorkoutExercise>();
public DbSet<CustomerWorkout> CustomerWorkouts => Set<CustomerWorkout>();
public DbSet<CustomerWorkoutExerciseCompletion> CustomerWorkoutExerciseCompletions => Set<CustomerWorkoutExerciseCompletion>();
// Multi-facility session booking + RFID time-bank cards (pool/court/class booking, one
// per club since single-tenant -- see Models/Facility.cs and friends).
public DbSet<Facility> Facilities => Set<Facility>();
public DbSet<FacilitySession> FacilitySessions => Set<FacilitySession>();
public DbSet<Booking> Bookings => Set<Booking>();
public DbSet<TimeBankCard> TimeBankCards => Set<TimeBankCard>();
public DbSet<TimeBankCardUsageLog> TimeBankCardUsageLogs => Set<TimeBankCardUsageLog>();
// Physical access zones (Pool Area, Indoor Gym, ...) -- see Models/Place.cs.
public DbSet<Place> Places => Set<Place>();
public DbSet<CustomerPlanPlace> CustomerPlanPlaces => Set<CustomerPlanPlace>();
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<Customer>();
modelBuilder.Ignore<Client>();
// 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<Admin>();
modelBuilder.Ignore<User>();
modelBuilder.Ignore<Ticket>();
modelBuilder.Ignore<TicketCategory>();
modelBuilder.Ignore<TicketMessage>();
modelBuilder.Ignore<UserDocument>();
modelBuilder.Ignore<DocumentTypes>();
modelBuilder.Ignore<ClientPackage>();
modelBuilder.Ignore<ClientCard>();
modelBuilder.Ignore<ClientBusiness>();
modelBuilder.Ignore<ClientSubscription>();
modelBuilder.Ignore<ClientInvoice>();
modelBuilder.Ignore<ClientInvoiceItem>();
modelBuilder.Ignore<NavItem>();
modelBuilder.Ignore<CusWeightLossProgress>();
modelBuilder.Ignore<CustomerTarget>();
// 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<WorkoutCategory>();
modelBuilder.Ignore<MealCategory>();
// Agent/PairingCode now live only in CentralDbContext (see its DbSet comment).
modelBuilder.Ignore<Agent>();
modelBuilder.Ignore<PairingCode>();
// 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<Attendance>().Ignore(a => a.Client);
modelBuilder.Entity<Attendance>().Ignore(a => a.User);
modelBuilder.Entity<AccessProfile>().Ignore(p => p.Client);
modelBuilder.Entity<AccessProfile>().Ignore(p => p.User);
modelBuilder.Entity<AccessDevice>().Ignore(d => d.Client);
modelBuilder.Entity<CustomerAccess>().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<CustomerWorkout>().Ignore(cw => cw.WorkoutPlan);
modelBuilder.Entity<CustomerMeal>().Ignore(cm => cm.MealPlan);
modelBuilder.Entity<DeviceCommand>().Ignore(c => c.Client);
modelBuilder.Entity<CustomerInvoice>().Ignore(i => i.Client);
modelBuilder.Entity<CustomerInvoice>().Ignore(i => i.User);
modelBuilder.Entity<CustomerPlan>().Ignore(p => p.Client);
modelBuilder.Entity<CustomerTermsAcceptance>().Ignore(t => t.Client);
modelBuilder.Entity<LedgerEntry>().Ignore(l => l.Client);
modelBuilder.Entity<GymRole>().Ignore(r => r.Client);
modelBuilder.Entity<Staff>().Ignore(s => s.User);
modelBuilder.Entity<Staff>().Ignore(s => s.Client);
modelBuilder.Entity<MealPlan>().Ignore(p => p.Client);
modelBuilder.Entity<WorkoutPlan>().Ignore(p => p.Client);
modelBuilder.Entity<PaymentTransaction>().Ignore(p => p.Client);
modelBuilder.Entity<PaymentTransaction>().Ignore(p => p.Customer);
modelBuilder.Entity<PaymentTransaction>().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<MealPlan>().ToTable(tb => tb.ExcludeFromMigrations());
modelBuilder.Entity<Meal>().ToTable(tb => tb.ExcludeFromMigrations());
modelBuilder.Entity<WorkoutPlan>().ToTable(tb => tb.ExcludeFromMigrations());
modelBuilder.Entity<WorkoutItem>().ToTable(tb => tb.ExcludeFromMigrations());
modelBuilder.Entity<WorkoutExercise>().ToTable(tb => tb.ExcludeFromMigrations());
modelBuilder.Entity<PaymentTransaction>().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<Facility>().Ignore(f => f.Client);
modelBuilder.Entity<FacilitySession>().Ignore(s => s.Client);
modelBuilder.Entity<Booking>().Ignore(b => b.Client);
modelBuilder.Entity<TimeBankCard>().Ignore(t => t.Client);
modelBuilder.Entity<TimeBankCardUsageLog>().Ignore(l => l.Client);
modelBuilder.Entity<Place>().Ignore(p => p.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.
modelBuilder.Entity<Booking>()
.HasIndex(b => new { b.FKFacilitySessionId, b.FKCustomerId })
.IsUnique()
.HasFilter("`FKCustomerId` IS NOT NULL AND `Status` <> 'Cancelled'");
modelBuilder.Entity<FacilitySession>()
.HasIndex(s => new { s.FKFacilityId, s.StartTime });
modelBuilder.Entity<TimeBankCard>()
.HasIndex(t => t.RfidTagId)
.IsUnique();
modelBuilder.Entity<Booking>()
.HasIndex(b => b.OtpCode);
// A customer can appear only once per subscription; installments are unique per position.
modelBuilder.Entity<SubscriptionMember>()
.HasIndex(sm => new { sm.FKSubscriptionId, sm.FKCustomerId })
.IsUnique();
modelBuilder.Entity<SubscriptionInstallment>()
.HasIndex(si => new { si.FKSubscriptionId, si.InstallmentNo })
.IsUnique();
// An exercise can only be marked completed once per customer workout.
modelBuilder.Entity<CustomerWorkoutExerciseCompletion>()
.HasIndex(c => new { c.FKCustomerWorkoutId, c.FKWorkoutExerciseId })
.IsUnique();
modelBuilder.Entity<AccessProfile>()
.HasIndex(p => new { p.FKClientId, p.FKUserId })
.IsUnique();
modelBuilder.Entity<AccessDeviceBinding>()
.HasIndex(b => new { b.FKAccessProfileId, b.FKDeviceId })
.IsUnique();
modelBuilder.Entity<AccessDeviceBinding>()
.HasIndex(b => new { b.FKDeviceId, b.DeviceEmployeeNo })
.IsUnique();
modelBuilder.Entity<AccessDeviceBinding>()
.HasOne(b => b.AccessProfile).WithMany(p => p.DeviceBindings)
.HasForeignKey(b => b.FKAccessProfileId).OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<AccessDeviceBinding>()
.HasOne(b => b.AccessDevice).WithMany()
.HasForeignKey(b => b.FKDeviceId).OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<AccessDevice>()
.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<AccessDevice>().Ignore(d => d.Agent);
// One device per place for now -- see AccessDevice.FKPlaceId comment. Lifting
// this to many-devices-per-place later is just dropping this index.
modelBuilder.Entity<AccessDevice>()
.HasIndex(d => d.FKPlaceId)
.IsUnique()
.HasFilter("`FKPlaceId` IS NOT NULL");
modelBuilder.Entity<CustomerPlanPlace>()
.HasIndex(pp => new { pp.FKCustomerPlanId, pp.FKPlaceId })
.IsUnique();
modelBuilder.Entity<CustomerPlanPlace>()
.HasOne(pp => pp.CustomerPlan).WithMany()
.HasForeignKey(pp => pp.FKCustomerPlanId).OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<CustomerPlanPlace>()
.HasOne(pp => pp.Place).WithMany()
.HasForeignKey(pp => pp.FKPlaceId).OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<UnlinkedDeviceEnrollment>()
.HasIndex(e => new { e.FKDeviceId, e.DeviceEmployeeNo, e.LinkedAt });
modelBuilder.Entity<Attendance>()
.HasIndex(a => a.SourceEventKey)
.IsUnique();
modelBuilder.Entity<DeviceCommand>()
.HasIndex(c => new { c.FKClientId, c.Status, c.NextAttemptAt });
modelBuilder.Entity<DeviceCommand>()
.Property(c => c.MaxAttempts)
.HasDefaultValue(5);
modelBuilder.Entity<GymRolePermission>()
.HasKey(p => new { p.FKGymRoleId, p.NavItemKey });
modelBuilder.Entity<GymRolePermission>().Ignore(p => p.NavItem);
}
}
public class TenantDbContextFactory : IDesignTimeDbContextFactory<TenantDbContext>
{
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<TenantDbContext>();
var connectionString = config.GetConnectionString("DefaultConnection");
optionsBuilder.UseMySql(connectionString, new MySqlServerVersion(new Version(8, 0, 0)));
return new TenantDbContext(optionsBuilder.Options);
}
}
}