443 lines
19 KiB
C#
443 lines
19 KiB
C#
using Fitness_core_BE.Models;
|
|
using Fitness_core_BE.Interfaces;
|
|
using Fitness_core_BE.DTOs.Meal;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Fitness_core_BE.Repos
|
|
{
|
|
public class MealRepository : IMealRepository
|
|
{
|
|
// Client-authored (non-template) meal plans + all CustomerMeal assignment rows.
|
|
private readonly TenantDbContext _dbContext;
|
|
// Admin-authored templates (MealCategories, and MealPlans/Meal where IsTemplate=true).
|
|
private readonly CentralDbContext _central;
|
|
|
|
public MealRepository(TenantDbContext dbContext, CentralDbContext central)
|
|
{
|
|
_dbContext = dbContext;
|
|
_central = central;
|
|
}
|
|
|
|
// Meal Category operations -- global lookup, admin-owned, always central.
|
|
public async Task<List<MealCategoryDto>> GetMealCategoryListAsync(CancellationToken ct = default)
|
|
{
|
|
return await _central.MealCategories
|
|
.AsNoTracking()
|
|
.Where(mc => mc.IsActive)
|
|
.OrderBy(mc => mc.Name)
|
|
.Select(mc => new MealCategoryDto
|
|
{
|
|
MealCategoryId = mc.MealCategoryId,
|
|
Name = mc.Name,
|
|
Description = mc.Description,
|
|
IsActive = mc.IsActive,
|
|
CreateDate = mc.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> MealCategoryExistsAsync(Guid mealCategoryId, CancellationToken ct = default)
|
|
{
|
|
return _central.MealCategories
|
|
.AsNoTracking()
|
|
.AnyAsync(mc => mc.MealCategoryId == mealCategoryId && mc.IsActive, ct);
|
|
}
|
|
|
|
public async Task<MealCategory> CreateMealCategoryAsync(MealCategory mealCategory, CancellationToken ct = default)
|
|
{
|
|
await _central.MealCategories.AddAsync(mealCategory, ct);
|
|
return mealCategory;
|
|
}
|
|
|
|
// Meal Plan operations
|
|
public async Task<List<MealPlanDto>> GetMealPlanListAsync(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) -- these are two
|
|
// disjoint sets post-split, never merged in one query.
|
|
if (userType == "Customer")
|
|
{
|
|
return await _central.MealPlans
|
|
.AsNoTracking()
|
|
.Include(mp => mp.MealCategory)
|
|
.Where(mp => mp.IsActive && mp.IsTemplate)
|
|
.OrderByDescending(mp => mp.CreateDate)
|
|
.Select(mp => new MealPlanDto
|
|
{
|
|
MealPlanId = mp.MealPlanId,
|
|
Name = mp.Name,
|
|
Description = mp.Description,
|
|
IsTemplate = mp.IsTemplate,
|
|
MealCategoryId = mp.FKMealCategoryId,
|
|
MealCategoryName = mp.MealCategory != null ? mp.MealCategory.Name : "",
|
|
ClientId = mp.FKClientId,
|
|
ClientName = null,
|
|
IsActive = mp.IsActive,
|
|
CreateDate = mp.CreateDate,
|
|
AssignedCustomers = new List<CustomerMealDto>()
|
|
})
|
|
.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.MealCategories
|
|
.AsNoTracking()
|
|
.ToDictionaryAsync(mc => mc.MealCategoryId, mc => mc.Name, ct);
|
|
|
|
var tenantPlans = await _dbContext.MealPlans
|
|
.AsNoTracking()
|
|
.Include(mp => mp.CustomerMeals.Where(cm => cm.IsActive))
|
|
.ThenInclude(cm => cm.TenantCustomerLink)
|
|
.Where(mp => mp.IsActive && !mp.IsTemplate && mp.FKClientId == clientId)
|
|
.OrderByDescending(mp => mp.CreateDate)
|
|
.Select(mp => new MealPlanDto
|
|
{
|
|
MealPlanId = mp.MealPlanId,
|
|
Name = mp.Name,
|
|
Description = mp.Description,
|
|
IsTemplate = mp.IsTemplate,
|
|
MealCategoryId = mp.FKMealCategoryId,
|
|
MealCategoryName = "",
|
|
ClientId = mp.FKClientId,
|
|
ClientName = null,
|
|
IsActive = mp.IsActive,
|
|
CreateDate = mp.CreateDate,
|
|
AssignedCustomers = mp.CustomerMeals
|
|
.Where(cm => cm.IsActive)
|
|
.Select(cm => new CustomerMealDto
|
|
{
|
|
CustomerMealId = cm.CustomerMealId,
|
|
CustomerId = cm.FKCustomerId,
|
|
CustomerName = cm.TenantCustomerLink != null ? cm.TenantCustomerLink.DisplayName : null,
|
|
MealPlanId = cm.FKMealPlanId,
|
|
IsActive = cm.IsActive,
|
|
IsFollowing = cm.IsFollowing,
|
|
AssignedDate = cm.AssignedDate,
|
|
CompletedDate = cm.CompletedDate,
|
|
Notes = cm.Notes
|
|
}).ToList()
|
|
})
|
|
.ToListAsync(ct);
|
|
|
|
foreach (var plan in tenantPlans)
|
|
{
|
|
plan.MealCategoryName =
|
|
categoryNames.TryGetValue(plan.MealCategoryId, out var name) ? name : "";
|
|
}
|
|
|
|
return tenantPlans;
|
|
}
|
|
|
|
public async Task<MealPlanDetailDto?> GetMealPlanByIdAsync(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.MealPlans
|
|
.AsNoTracking()
|
|
.Include(mp => mp.MealCategory)
|
|
.Include(mp => mp.MealItems)
|
|
.FirstOrDefaultAsync(mp => mp.MealPlanId == id && mp.IsActive, ct);
|
|
if (centralPlan != null)
|
|
return MapDetail(centralPlan, Array.Empty<CustomerMeal>());
|
|
|
|
var tenantPlan = await _dbContext.MealPlans
|
|
.AsNoTracking()
|
|
.Include(mp => mp.CustomerMeals.Where(cm => cm.IsActive))
|
|
.ThenInclude(cm => cm.TenantCustomerLink)
|
|
.Include(mp => mp.MealItems)
|
|
.FirstOrDefaultAsync(mp => mp.MealPlanId == id && mp.IsActive, ct);
|
|
if (tenantPlan == null)
|
|
return null;
|
|
|
|
var detail = MapDetail(tenantPlan, tenantPlan.CustomerMeals.Where(cm => cm.IsActive).ToList());
|
|
// Category is central-only, so it can't be Include()d from the tenant context.
|
|
detail.MealCategoryName = await _central.MealCategories
|
|
.AsNoTracking()
|
|
.Where(mc => mc.MealCategoryId == tenantPlan.FKMealCategoryId)
|
|
.Select(mc => mc.Name)
|
|
.FirstOrDefaultAsync(ct) ?? "";
|
|
return detail;
|
|
}
|
|
|
|
private static MealPlanDetailDto MapDetail(MealPlan mealPlan, IReadOnlyCollection<CustomerMeal> assigned)
|
|
{
|
|
var mealItems = mealPlan.MealItems
|
|
.Where(mi => mi.IsActive)
|
|
.Select(mi => new MealDto
|
|
{
|
|
MealId = mi.MealId,
|
|
Name = mi.Name,
|
|
Description = mi.Description,
|
|
Calories = mi.Calories,
|
|
Protein = mi.Protein,
|
|
Carbohydrates = mi.Carbohydrates,
|
|
Fats = mi.Fats,
|
|
ServingSize = mi.ServingSize,
|
|
MealItems = mi.MealItems,
|
|
PreparationNotes = mi.PreparationNotes,
|
|
MealPlanId = mi.FKMealPlanId,
|
|
IsActive = mi.IsActive,
|
|
CreateDate = mi.CreateDate
|
|
})
|
|
.ToList();
|
|
|
|
return new MealPlanDetailDto
|
|
{
|
|
MealPlanId = mealPlan.MealPlanId,
|
|
Name = mealPlan.Name,
|
|
Description = mealPlan.Description,
|
|
IsTemplate = mealPlan.IsTemplate,
|
|
MealCategoryId = mealPlan.FKMealCategoryId,
|
|
MealCategoryName = mealPlan.MealCategory != null ? mealPlan.MealCategory.Name : "",
|
|
ClientId = mealPlan.FKClientId,
|
|
ClientName = null,
|
|
IsActive = mealPlan.IsActive,
|
|
CreateDate = mealPlan.CreateDate,
|
|
MealItems = mealItems,
|
|
AssignedCustomers = assigned
|
|
.Select(cm => new CustomerMealDto
|
|
{
|
|
CustomerMealId = cm.CustomerMealId,
|
|
CustomerId = cm.FKCustomerId,
|
|
CustomerName = cm.TenantCustomerLink?.DisplayName,
|
|
MealPlanId = cm.FKMealPlanId,
|
|
IsActive = cm.IsActive,
|
|
IsFollowing = cm.IsFollowing,
|
|
AssignedDate = cm.AssignedDate,
|
|
CompletedDate = cm.CompletedDate,
|
|
Notes = cm.Notes
|
|
}).ToList(),
|
|
TotalCalories = mealItems.Sum(mi => mi.Calories ?? 0),
|
|
TotalProtein = mealItems.Sum(mi => mi.Protein ?? 0),
|
|
TotalCarbohydrates = mealItems.Sum(mi => mi.Carbohydrates ?? 0),
|
|
TotalFats = mealItems.Sum(mi => mi.Fats ?? 0)
|
|
};
|
|
}
|
|
|
|
public async Task<List<MealPlanDetailDto>> GetMealPlansByCustomerIdAsync(Guid customerId, CancellationToken ct = default)
|
|
{
|
|
// CustomerMeal (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.CustomerMeals
|
|
.AsNoTracking()
|
|
.Where(cm => cm.FKCustomerId == customerId && cm.IsActive)
|
|
.OrderByDescending(cm => cm.AssignedDate)
|
|
.Select(cm => cm.FKMealPlanId)
|
|
.Distinct()
|
|
.ToListAsync(ct);
|
|
|
|
var result = new List<MealPlanDetailDto>();
|
|
foreach (var mealPlanId in follows)
|
|
{
|
|
var detail = await GetMealPlanByIdAsync(mealPlanId, ct);
|
|
if (detail != null && detail.IsActive)
|
|
result.Add(detail);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
public async Task<MealPlan?> GetMealPlanEntityByIdAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
return await _dbContext.MealPlans
|
|
.FirstOrDefaultAsync(mp => mp.MealPlanId == id && mp.IsActive, ct);
|
|
}
|
|
|
|
public async Task<MealPlan> CreateMealPlanAsync(MealPlan mealPlan, CancellationToken ct = default)
|
|
{
|
|
await _dbContext.MealPlans.AddAsync(mealPlan, ct);
|
|
return mealPlan;
|
|
}
|
|
|
|
public async Task<MealPlan> UpdateMealPlanAsync(MealPlan mealPlan, CancellationToken ct = default)
|
|
{
|
|
_dbContext.MealPlans.Update(mealPlan);
|
|
return mealPlan;
|
|
}
|
|
|
|
public async Task DeleteMealPlanAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
var mealPlan = await _dbContext.MealPlans.FindAsync(new object[] { id }, ct);
|
|
if (mealPlan != null)
|
|
{
|
|
// Soft delete by setting IsActive to false
|
|
mealPlan.IsActive = false;
|
|
_dbContext.MealPlans.Update(mealPlan);
|
|
}
|
|
}
|
|
|
|
// Meal Item operations
|
|
public async Task<List<MealDto>> GetMealsByMealPlanIdAsync(Guid mealPlanId, CancellationToken ct = default)
|
|
{
|
|
return await _dbContext.Meal
|
|
.AsNoTracking()
|
|
.Where(mi => mi.FKMealPlanId == mealPlanId && mi.IsActive)
|
|
.OrderBy(mi => mi.CreateDate)
|
|
.Select(mi => new MealDto
|
|
{
|
|
MealId = mi.MealId,
|
|
Name = mi.Name,
|
|
Description = mi.Description,
|
|
Calories = mi.Calories,
|
|
Protein = mi.Protein,
|
|
Carbohydrates = mi.Carbohydrates,
|
|
Fats = mi.Fats,
|
|
ServingSize = mi.ServingSize,
|
|
MealItems = mi.MealItems,
|
|
PreparationNotes = mi.PreparationNotes,
|
|
MealPlanId = mi.FKMealPlanId,
|
|
IsActive = mi.IsActive,
|
|
CreateDate = mi.CreateDate
|
|
})
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
public async Task<Meal?> GetMealByIdAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
return await _dbContext.Meal
|
|
.FirstOrDefaultAsync(mi => mi.MealId == id && mi.IsActive, ct);
|
|
}
|
|
|
|
public async Task<Meal> CreateMealAsync(Meal mealItem, CancellationToken ct = default)
|
|
{
|
|
await _dbContext.Meal.AddAsync(mealItem, ct);
|
|
return mealItem;
|
|
}
|
|
|
|
public async Task<Meal> UpdateMealAsync(Meal mealItem, CancellationToken ct = default)
|
|
{
|
|
_dbContext.Meal.Update(mealItem);
|
|
return mealItem;
|
|
}
|
|
|
|
public async Task DeleteMealAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
var meal = await _dbContext.Meal.FindAsync(new object[] { id }, ct);
|
|
if (meal != null)
|
|
{
|
|
// Soft delete by setting IsActive to false
|
|
meal.IsActive = false;
|
|
_dbContext.Meal.Update(meal);
|
|
}
|
|
}
|
|
|
|
// Customer Meal Assignment operations
|
|
public async Task<CustomerMeal> AssignMealToCustomerAsync(CustomerMeal customerMeal, CancellationToken ct = default)
|
|
{
|
|
// Mirrors WorkoutRepository.AssignWorkoutToCustomerAsync: a coach assigning a plan
|
|
// must become the customer's "Followed" plan, the same as self-following one from
|
|
// Suggestions — otherwise an admin-suggested plan the customer already follows keeps
|
|
// showing instead of the one the client just assigned.
|
|
var currentFollow = await _dbContext.CustomerMeals
|
|
.FirstOrDefaultAsync(cm => cm.FKCustomerId == customerMeal.FKCustomerId && cm.IsFollowing, ct);
|
|
|
|
if (currentFollow != null && currentFollow.FKMealPlanId != customerMeal.FKMealPlanId)
|
|
{
|
|
currentFollow.IsFollowing = false;
|
|
_dbContext.CustomerMeals.Update(currentFollow);
|
|
}
|
|
|
|
var existing = await _dbContext.CustomerMeals
|
|
.FirstOrDefaultAsync(cm => cm.FKCustomerId == customerMeal.FKCustomerId && cm.FKMealPlanId == customerMeal.FKMealPlanId, ct);
|
|
|
|
if (existing != null)
|
|
{
|
|
existing.IsFollowing = true;
|
|
existing.IsActive = true;
|
|
existing.AssignedDate = customerMeal.AssignedDate;
|
|
existing.Notes = customerMeal.Notes;
|
|
_dbContext.CustomerMeals.Update(existing);
|
|
return existing;
|
|
}
|
|
|
|
customerMeal.IsFollowing = true;
|
|
await _dbContext.CustomerMeals.AddAsync(customerMeal, ct);
|
|
return customerMeal;
|
|
}
|
|
|
|
public async Task<bool> UnassignMealFromCustomerAsync(Guid customerMealId, CancellationToken ct = default)
|
|
{
|
|
var customerMeal = await _dbContext.CustomerMeals.FindAsync(new object[] { customerMealId }, ct);
|
|
|
|
if (customerMeal == null)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Soft delete by setting IsActive to false
|
|
customerMeal.IsActive = false;
|
|
_dbContext.CustomerMeals.Update(customerMeal);
|
|
|
|
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>> ResolveMealPlanNamesAsync(
|
|
IReadOnlyCollection<Guid> planIds, CancellationToken ct)
|
|
{
|
|
if (planIds.Count == 0)
|
|
return new Dictionary<Guid, string>();
|
|
|
|
var names = await _dbContext.MealPlans
|
|
.AsNoTracking()
|
|
.Where(mp => planIds.Contains(mp.MealPlanId))
|
|
.ToDictionaryAsync(mp => mp.MealPlanId, mp => mp.Name, ct);
|
|
|
|
var missing = planIds.Where(id => !names.ContainsKey(id)).ToList();
|
|
if (missing.Count > 0)
|
|
{
|
|
var centralNames = await _central.MealPlans
|
|
.AsNoTracking()
|
|
.Where(mp => missing.Contains(mp.MealPlanId))
|
|
.ToDictionaryAsync(mp => mp.MealPlanId, mp => mp.Name, ct);
|
|
foreach (var pair in centralNames)
|
|
names[pair.Key] = pair.Value;
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
private async Task<List<CustomerMealDto>> BuildCustomerMealDtosAsync(
|
|
IQueryable<CustomerMeal> query, CancellationToken ct)
|
|
{
|
|
var rows = await query
|
|
.AsNoTracking()
|
|
.Include(cm => cm.TenantCustomerLink)
|
|
.OrderByDescending(cm => cm.AssignedDate)
|
|
.Select(cm => new CustomerMealDto
|
|
{
|
|
CustomerMealId = cm.CustomerMealId,
|
|
CustomerId = cm.FKCustomerId,
|
|
CustomerName = cm.TenantCustomerLink != null ? cm.TenantCustomerLink.DisplayName : null,
|
|
MealPlanId = cm.FKMealPlanId,
|
|
MealPlanName = null,
|
|
IsActive = cm.IsActive,
|
|
IsFollowing = cm.IsFollowing,
|
|
AssignedDate = cm.AssignedDate,
|
|
CompletedDate = cm.CompletedDate,
|
|
Notes = cm.Notes
|
|
})
|
|
.ToListAsync(ct);
|
|
|
|
var names = await ResolveMealPlanNamesAsync(
|
|
rows.Select(r => r.MealPlanId).Distinct().ToList(), ct);
|
|
|
|
foreach (var row in rows)
|
|
row.MealPlanName = names.TryGetValue(row.MealPlanId, out var name) ? name : null;
|
|
|
|
return rows;
|
|
}
|
|
|
|
public Task<List<CustomerMealDto>> GetCustomerMealsByPlanIdAsync(Guid mealPlanId, CancellationToken ct = default)
|
|
=> BuildCustomerMealDtosAsync(
|
|
_dbContext.CustomerMeals.Where(cm => cm.FKMealPlanId == mealPlanId && cm.IsActive), ct);
|
|
|
|
public Task<List<CustomerMealDto>> GetCustomerMealsByCustomerIdAsync(Guid customerId, CancellationToken ct = default)
|
|
=> BuildCustomerMealDtosAsync(
|
|
_dbContext.CustomerMeals.Where(cm => cm.FKCustomerId == customerId && cm.IsActive), ct);
|
|
}
|
|
}
|