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