using Fitness_core_BE.Models; using Fitness_core_BE.Interfaces; using Fitness_core_BE.DTOs.Workout; using Microsoft.EntityFrameworkCore; namespace Fitness_core_BE.Repos { public class WorkoutRepository : IWorkoutRepository { // Client-authored (non-template) workout plans + all CustomerWorkout assignment rows. private readonly TenantDbContext _dbContext; // Admin-authored templates (WorkoutCategories, and WorkoutPlans/Items/Exercises where IsTemplate=true). private readonly CentralDbContext _central; public WorkoutRepository(TenantDbContext dbContext, CentralDbContext central) { _dbContext = dbContext; _central = central; } // Workout Category operations -- global lookup, admin-owned, always central. public async Task> GetWorkoutCategoryListAsync(CancellationToken ct = default) { return await _central.WorkoutCategories .AsNoTracking() .Where(wc => wc.IsActive) .OrderBy(wc => wc.WorkoutCategoryName) .Select(wc => new WorkoutCategoryDto { WorkoutCategoryId = wc.WorkoutCategoryId, Name = wc.WorkoutCategoryName, Description = wc.Description, IsActive = wc.IsActive, CreateDate = wc.CreateDate }) .ToListAsync(ct); } // Categories are central; the plan row is tenant-side with no enforceable cross-DB // FK, so the id has to be checked here rather than left to the database. public Task WorkoutCategoryExistsAsync(Guid workoutCategoryId, CancellationToken ct = default) { return _central.WorkoutCategories .AsNoTracking() .AnyAsync(wc => wc.WorkoutCategoryId == workoutCategoryId && wc.IsActive, ct); } public async Task CreateWorkoutCategoryAsync(WorkoutCategory workoutCategory, CancellationToken ct = default) { await _central.WorkoutCategories.AddAsync(workoutCategory, ct); return workoutCategory; } // Workout Plan operations public async Task> GetWorkoutPlanListAsync(string userType, Guid clientId, CancellationToken ct = default) { // A customer browsing sees admin-authored templates (central); the coach/owner // dashboard only manages this gym's own custom plans (tenant) -- disjoint sets // post-split, never merged in one query. if (userType == "Customer") { return await _central.WorkoutPlans .AsNoTracking() .Include(wp => wp.WorkoutCategory) .Where(wp => wp.IsActive && wp.IsTemplate) .OrderByDescending(wp => wp.CreateDate) .Select(wp => new WorkoutPlanDto { WorkoutPlanId = wp.WorkoutPlanId, Name = wp.WorkoutPlanName, Description = wp.Description, IsTemplate = wp.IsTemplate, WorkoutCategoryId = wp.FKWorkoutCategoryId, WorkoutCategoryName = wp.WorkoutCategory != null ? wp.WorkoutCategory.WorkoutCategoryName : "", ClientId = wp.FKClientId, ClientName = null, IsActive = wp.IsActive, CreateDate = wp.CreateDate, AssignedCustomers = new List() }) .ToListAsync(ct); } // Categories live centrally, so the tenant query can't join them -- resolve the // names in a second round-trip and stitch them on below. var categoryNames = await _central.WorkoutCategories .AsNoTracking() .ToDictionaryAsync(wc => wc.WorkoutCategoryId, wc => wc.WorkoutCategoryName, ct); var tenantPlans = await _dbContext.WorkoutPlans .AsNoTracking() .Include(wp => wp.CustomerWorkouts.Where(cw => cw.IsActive)) .ThenInclude(cw => cw.TenantCustomerLink) .Where(wp => wp.IsActive && !wp.IsTemplate && wp.FKClientId == clientId) .OrderByDescending(wp => wp.CreateDate) .Select(wp => new WorkoutPlanDto { WorkoutPlanId = wp.WorkoutPlanId, Name = wp.WorkoutPlanName, Description = wp.Description, IsTemplate = wp.IsTemplate, WorkoutCategoryId = wp.FKWorkoutCategoryId, WorkoutCategoryName = "", ClientId = wp.FKClientId, ClientName = null, IsActive = wp.IsActive, CreateDate = wp.CreateDate, AssignedCustomers = wp.CustomerWorkouts .Where(cw => cw.IsActive) .Select(cw => new CustomerWorkoutDto { CustomerWorkoutId = cw.CustomerWorkoutId, CustomerId = cw.FKCustomerId, CustomerName = cw.TenantCustomerLink != null ? cw.TenantCustomerLink.DisplayName : null, WorkoutPlanId = cw.FKWorkoutPlanId, IsActive = cw.IsActive, IsFollowing = cw.IsFollowing, AssignedDate = cw.AssignedDate, CompletedDate = cw.CompletedDate, Notes = cw.Notes }).ToList() }) .ToListAsync(ct); foreach (var plan in tenantPlans) { plan.WorkoutCategoryName = categoryNames.TryGetValue(plan.WorkoutCategoryId, out var name) ? name : ""; } return tenantPlans; } public async Task GetWorkoutPlanByIdAsync(Guid id, CancellationToken ct = default) { // The id could belong to either bucket (an admin template or a client-owned // plan) -- try central first, fall back to tenant. var centralPlan = await _central.WorkoutPlans .AsNoTracking() .Include(wp => wp.WorkoutCategory) .Include(wp => wp.WorkoutItems) .ThenInclude(wi => wi.WorkoutExercises) .FirstOrDefaultAsync(wp => wp.WorkoutPlanId == id && wp.IsActive, ct); if (centralPlan != null) return MapDetail(centralPlan, Array.Empty()); var tenantPlan = await _dbContext.WorkoutPlans .AsNoTracking() .Include(wp => wp.CustomerWorkouts.Where(cw => cw.IsActive)) .ThenInclude(cw => cw.TenantCustomerLink) .Include(wp => wp.WorkoutItems) .ThenInclude(wi => wi.WorkoutExercises) .FirstOrDefaultAsync(wp => wp.WorkoutPlanId == id && wp.IsActive, ct); if (tenantPlan == null) return null; var detail = MapDetail(tenantPlan, tenantPlan.CustomerWorkouts.Where(cw => cw.IsActive).ToList()); // Category is central-only, so it can't be Include()d from the tenant context. detail.WorkoutCategoryName = await _central.WorkoutCategories .AsNoTracking() .Where(wc => wc.WorkoutCategoryId == tenantPlan.FKWorkoutCategoryId) .Select(wc => wc.WorkoutCategoryName) .FirstOrDefaultAsync(ct) ?? ""; return detail; } private static WorkoutPlanDetailDto MapDetail(WorkoutPlan workoutPlan, IReadOnlyCollection assigned) { return new WorkoutPlanDetailDto { WorkoutPlanId = workoutPlan.WorkoutPlanId, Name = workoutPlan.WorkoutPlanName, Description = workoutPlan.Description, IsTemplate = workoutPlan.IsTemplate, WorkoutCategoryId = workoutPlan.FKWorkoutCategoryId, WorkoutCategoryName = workoutPlan.WorkoutCategory != null ? workoutPlan.WorkoutCategory.WorkoutCategoryName : "", ClientId = workoutPlan.FKClientId, ClientName = null, IsActive = workoutPlan.IsActive, CreateDate = workoutPlan.CreateDate, WorkoutItems = workoutPlan.WorkoutItems .Where(wi => wi.IsActive) .Select(MapWorkoutItem) .ToList(), AssignedCustomers = assigned .Select(cw => new CustomerWorkoutDto { CustomerWorkoutId = cw.CustomerWorkoutId, CustomerId = cw.FKCustomerId, CustomerName = cw.TenantCustomerLink?.DisplayName, WorkoutPlanId = cw.FKWorkoutPlanId, IsActive = cw.IsActive, IsFollowing = cw.IsFollowing, AssignedDate = cw.AssignedDate, CompletedDate = cw.CompletedDate, Notes = cw.Notes }).ToList() }; } // Shared WorkoutItem -> WorkoutItemDto mapping (including structured exercises) for the // client/coach-facing read paths, which operate on already-materialized entity graphs // rather than server-translated queries. private static WorkoutItemDto MapWorkoutItem(WorkoutItem wi) { var exerciseDtos = wi.WorkoutExercises .Where(we => we.IsActive) .OrderBy(we => we.SortOrder) .Select(we => new WorkoutExerciseDto { WorkoutExerciseId = we.WorkoutExerciseId, Name = we.Name, MuscleGroup = we.MuscleGroup, Sets = we.Sets, Reps = we.Reps, Description = we.Description, BreathIn = we.BreathIn, BreathOut = we.BreathOut, VideoUrl = we.VideoUrl, SortOrder = we.SortOrder, IsCompleted = false }) .ToList(); return new WorkoutItemDto { WorkoutItemId = wi.WorkoutItemId, ExerciseDay = wi.ExerciseDay, Description = wi.Description, Exercises = wi.Exercises, HowToDo = wi.HowToDo, DoURL = wi.DoURL, Duration = wi.Duration, WormUp = wi.WormUp, Cooldown = wi.Cooldown, WorkoutPlanId = wi.FKWorkoutPlanId, IsActive = wi.IsActive, CreateDate = wi.CreateDate, UpdatedAt = wi.UpdatedAt, WorkoutExercises = exerciseDtos, TotalExercises = exerciseDtos.Count, CompletedExercises = 0, IsCompleted = false }; } public async Task> GetWorkoutPlansByCustomerIdAsync(Guid customerId, CancellationToken ct = default) { // CustomerWorkout (tenant) records which plans this customer follows at this gym; // each followed plan is resolved from whichever bucket it actually lives in. var follows = await _dbContext.CustomerWorkouts .AsNoTracking() .Where(cw => cw.FKCustomerId == customerId && cw.IsActive) .OrderByDescending(cw => cw.AssignedDate) .Select(cw => cw.FKWorkoutPlanId) .Distinct() .ToListAsync(ct); var result = new List(); foreach (var workoutPlanId in follows) { var detail = await GetWorkoutPlanByIdAsync(workoutPlanId, ct); if (detail != null && detail.IsActive) result.Add(detail); } return result; } public async Task GetWorkoutPlanEntityByIdAsync(Guid id, CancellationToken ct = default) { return await _dbContext.WorkoutPlans .FirstOrDefaultAsync(wp => wp.WorkoutPlanId == id && wp.IsActive, ct); } public async Task CreateWorkoutPlanAsync(WorkoutPlan workoutPlan, CancellationToken ct = default) { await _dbContext.WorkoutPlans.AddAsync(workoutPlan, ct); return workoutPlan; } public async Task UpdateWorkoutPlanAsync(WorkoutPlan workoutPlan, CancellationToken ct = default) { _dbContext.WorkoutPlans.Update(workoutPlan); return workoutPlan; } public async Task DeleteWorkoutPlanAsync(Guid id, CancellationToken ct = default) { var workoutPlan = await _dbContext.WorkoutPlans.FindAsync(new object[] { id }, ct); if (workoutPlan == null) { return false; } workoutPlan.IsActive = false; _dbContext.WorkoutPlans.Update(workoutPlan); return true; } public async Task GetWorkoutItemByIdAsync(Guid id, CancellationToken ct = default) { return await _dbContext.WorkoutItems .FirstOrDefaultAsync(wi => wi.WorkoutItemId == id && wi.IsActive, ct); } // Workout Item operations public async Task> GetWorkoutItemsByWorkoutPlanIdAsync(Guid workoutPlanId, CancellationToken ct = default) { var items = await _dbContext.WorkoutItems .AsNoTracking() .Include(wi => wi.WorkoutExercises) .Where(wi => wi.FKWorkoutPlanId == workoutPlanId && wi.IsActive) .OrderBy(wi => wi.CreateDate) .ToListAsync(ct); return items.Select(MapWorkoutItem).ToList(); } public async Task CreateWorkoutItemAsync(WorkoutItem workoutItem, CancellationToken ct = default) { await _dbContext.WorkoutItems.AddAsync(workoutItem, ct); return workoutItem; } public async Task UpdateWorkoutItemAsync(WorkoutItem workoutItem, CancellationToken ct = default) { _dbContext.WorkoutItems.Update(workoutItem); return workoutItem; } public async Task DeleteWorkoutItemAsync(Guid id, CancellationToken ct = default) { var workoutItem = await _dbContext.WorkoutItems.FindAsync(new object[] { id }, ct); if (workoutItem != null) { // Soft delete by setting IsActive to false workoutItem.IsActive = false; _dbContext.WorkoutItems.Update(workoutItem); } } // Customer Workout Assignment operations // Assigning a plan puts the customer on it the same way self-follow does: only one // plan can be "followed" at a time, and an assigned plan must show up under the // customer's Followed tab immediately. public async Task AssignWorkoutToCustomerAsync(CustomerWorkout customerWorkout, CancellationToken ct = default) { var currentFollow = await _dbContext.CustomerWorkouts .FirstOrDefaultAsync(cw => cw.FKCustomerId == customerWorkout.FKCustomerId && cw.IsFollowing, ct); if (currentFollow != null && currentFollow.FKWorkoutPlanId != customerWorkout.FKWorkoutPlanId) { currentFollow.IsFollowing = false; _dbContext.CustomerWorkouts.Update(currentFollow); } var existing = await _dbContext.CustomerWorkouts .FirstOrDefaultAsync(cw => cw.FKCustomerId == customerWorkout.FKCustomerId && cw.FKWorkoutPlanId == customerWorkout.FKWorkoutPlanId, ct); if (existing != null) { existing.IsFollowing = true; existing.IsActive = true; existing.AssignedDate = customerWorkout.AssignedDate; existing.Notes = customerWorkout.Notes; _dbContext.CustomerWorkouts.Update(existing); return existing; } customerWorkout.IsFollowing = true; await _dbContext.CustomerWorkouts.AddAsync(customerWorkout, ct); return customerWorkout; } public async Task UnassignWorkoutFromCustomerAsync(Guid customerWorkoutId, CancellationToken ct = default) { var customerWorkout = await _dbContext.CustomerWorkouts.FindAsync(new object[] { customerWorkoutId }, ct); if (customerWorkout == null) { return false; } // Soft delete by setting IsActive to false customerWorkout.IsActive = false; _dbContext.CustomerWorkouts.Update(customerWorkout); return true; } // A followed plan id may name a central template or a tenant plan, so names are // resolved from both buckets rather than through a (now un-enforced) navigation. private async Task> ResolvePlanNamesAsync( IReadOnlyCollection planIds, CancellationToken ct) { if (planIds.Count == 0) return new Dictionary(); var names = await _dbContext.WorkoutPlans .AsNoTracking() .Where(wp => planIds.Contains(wp.WorkoutPlanId)) .ToDictionaryAsync(wp => wp.WorkoutPlanId, wp => wp.WorkoutPlanName, ct); var missing = planIds.Where(id => !names.ContainsKey(id)).ToList(); if (missing.Count > 0) { var centralNames = await _central.WorkoutPlans .AsNoTracking() .Where(wp => missing.Contains(wp.WorkoutPlanId)) .ToDictionaryAsync(wp => wp.WorkoutPlanId, wp => wp.WorkoutPlanName, ct); foreach (var pair in centralNames) names[pair.Key] = pair.Value; } return names; } private async Task> BuildCustomerWorkoutDtosAsync( IQueryable query, CancellationToken ct) { var rows = await query .AsNoTracking() .Include(cw => cw.TenantCustomerLink) .OrderByDescending(cw => cw.AssignedDate) .Select(cw => new CustomerWorkoutDto { CustomerWorkoutId = cw.CustomerWorkoutId, CustomerId = cw.FKCustomerId, CustomerName = cw.TenantCustomerLink != null ? cw.TenantCustomerLink.DisplayName : null, WorkoutPlanId = cw.FKWorkoutPlanId, WorkoutPlanName = null, IsActive = cw.IsActive, IsFollowing = cw.IsFollowing, AssignedDate = cw.AssignedDate, CompletedDate = cw.CompletedDate, Notes = cw.Notes }) .ToListAsync(ct); var names = await ResolvePlanNamesAsync( rows.Select(r => r.WorkoutPlanId).Distinct().ToList(), ct); foreach (var row in rows) row.WorkoutPlanName = names.TryGetValue(row.WorkoutPlanId, out var name) ? name : null; return rows; } public Task> GetCustomerWorkoutsByPlanIdAsync(Guid workoutPlanId, CancellationToken ct = default) => BuildCustomerWorkoutDtosAsync( _dbContext.CustomerWorkouts.Where(cw => cw.FKWorkoutPlanId == workoutPlanId && cw.IsActive), ct); public Task> GetCustomerWorkoutsByCustomerIdAsync(Guid customerId, CancellationToken ct = default) => BuildCustomerWorkoutDtosAsync( _dbContext.CustomerWorkouts.Where(cw => cw.FKCustomerId == customerId && cw.IsActive), ct); } }