460 lines
20 KiB
C#
460 lines
20 KiB
C#
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<List<WorkoutCategoryDto>> 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<bool> WorkoutCategoryExistsAsync(Guid workoutCategoryId, CancellationToken ct = default)
|
|
{
|
|
return _central.WorkoutCategories
|
|
.AsNoTracking()
|
|
.AnyAsync(wc => wc.WorkoutCategoryId == workoutCategoryId && wc.IsActive, ct);
|
|
}
|
|
|
|
public async Task<WorkoutCategory> CreateWorkoutCategoryAsync(WorkoutCategory workoutCategory, CancellationToken ct = default)
|
|
{
|
|
await _central.WorkoutCategories.AddAsync(workoutCategory, ct);
|
|
return workoutCategory;
|
|
}
|
|
|
|
// Workout Plan operations
|
|
public async Task<List<WorkoutPlanDto>> 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<CustomerWorkoutDto>()
|
|
})
|
|
.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<WorkoutPlanDetailDto?> 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<CustomerWorkout>());
|
|
|
|
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<CustomerWorkout> 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<List<WorkoutPlanDetailDto>> 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<WorkoutPlanDetailDto>();
|
|
foreach (var workoutPlanId in follows)
|
|
{
|
|
var detail = await GetWorkoutPlanByIdAsync(workoutPlanId, ct);
|
|
if (detail != null && detail.IsActive)
|
|
result.Add(detail);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
public async Task<WorkoutPlan?> GetWorkoutPlanEntityByIdAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
return await _dbContext.WorkoutPlans
|
|
.FirstOrDefaultAsync(wp => wp.WorkoutPlanId == id && wp.IsActive, ct);
|
|
}
|
|
|
|
public async Task<WorkoutPlan> CreateWorkoutPlanAsync(WorkoutPlan workoutPlan, CancellationToken ct = default)
|
|
{
|
|
await _dbContext.WorkoutPlans.AddAsync(workoutPlan, ct);
|
|
return workoutPlan;
|
|
}
|
|
|
|
public async Task<WorkoutPlan> UpdateWorkoutPlanAsync(WorkoutPlan workoutPlan, CancellationToken ct = default)
|
|
{
|
|
_dbContext.WorkoutPlans.Update(workoutPlan);
|
|
return workoutPlan;
|
|
}
|
|
|
|
public async Task<bool> 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<WorkoutItem?> GetWorkoutItemByIdAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
return await _dbContext.WorkoutItems
|
|
.FirstOrDefaultAsync(wi => wi.WorkoutItemId == id && wi.IsActive, ct);
|
|
}
|
|
|
|
// Workout Item operations
|
|
public async Task<List<WorkoutItemDto>> 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<WorkoutItem> CreateWorkoutItemAsync(WorkoutItem workoutItem, CancellationToken ct = default)
|
|
{
|
|
await _dbContext.WorkoutItems.AddAsync(workoutItem, ct);
|
|
return workoutItem;
|
|
}
|
|
|
|
public async Task<WorkoutItem> 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<CustomerWorkout> 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<bool> 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<Dictionary<Guid, string>> ResolvePlanNamesAsync(
|
|
IReadOnlyCollection<Guid> planIds, CancellationToken ct)
|
|
{
|
|
if (planIds.Count == 0)
|
|
return new Dictionary<Guid, string>();
|
|
|
|
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<List<CustomerWorkoutDto>> BuildCustomerWorkoutDtosAsync(
|
|
IQueryable<CustomerWorkout> 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<List<CustomerWorkoutDto>> GetCustomerWorkoutsByPlanIdAsync(Guid workoutPlanId, CancellationToken ct = default)
|
|
=> BuildCustomerWorkoutDtosAsync(
|
|
_dbContext.CustomerWorkouts.Where(cw => cw.FKWorkoutPlanId == workoutPlanId && cw.IsActive), ct);
|
|
|
|
public Task<List<CustomerWorkoutDto>> GetCustomerWorkoutsByCustomerIdAsync(Guid customerId, CancellationToken ct = default)
|
|
=> BuildCustomerWorkoutDtosAsync(
|
|
_dbContext.CustomerWorkouts.Where(cw => cw.FKCustomerId == customerId && cw.IsActive), ct);
|
|
}
|
|
}
|