first commit
This commit is contained in:
@@ -0,0 +1,298 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user