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> 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 MealCategoryExistsAsync(Guid mealCategoryId, CancellationToken ct = default) { return _central.MealCategories .AsNoTracking() .AnyAsync(mc => mc.MealCategoryId == mealCategoryId && mc.IsActive, ct); } public async Task CreateMealCategoryAsync(MealCategory mealCategory, CancellationToken ct = default) { await _central.MealCategories.AddAsync(mealCategory, ct); return mealCategory; } // Meal Plan operations public async Task> 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() }) .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 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()); 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 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> 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(); foreach (var mealPlanId in follows) { var detail = await GetMealPlanByIdAsync(mealPlanId, ct); if (detail != null && detail.IsActive) result.Add(detail); } return result; } public async Task GetMealPlanEntityByIdAsync(Guid id, CancellationToken ct = default) { return await _dbContext.MealPlans .FirstOrDefaultAsync(mp => mp.MealPlanId == id && mp.IsActive, ct); } public async Task CreateMealPlanAsync(MealPlan mealPlan, CancellationToken ct = default) { await _dbContext.MealPlans.AddAsync(mealPlan, ct); return mealPlan; } public async Task 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> 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 GetMealByIdAsync(Guid id, CancellationToken ct = default) { return await _dbContext.Meal .FirstOrDefaultAsync(mi => mi.MealId == id && mi.IsActive, ct); } public async Task CreateMealAsync(Meal mealItem, CancellationToken ct = default) { await _dbContext.Meal.AddAsync(mealItem, ct); return mealItem; } public async Task 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 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 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> ResolveMealPlanNamesAsync( IReadOnlyCollection planIds, CancellationToken ct) { if (planIds.Count == 0) return new Dictionary(); 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> BuildCustomerMealDtosAsync( IQueryable 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> GetCustomerMealsByPlanIdAsync(Guid mealPlanId, CancellationToken ct = default) => BuildCustomerMealDtosAsync( _dbContext.CustomerMeals.Where(cm => cm.FKMealPlanId == mealPlanId && cm.IsActive), ct); public Task> GetCustomerMealsByCustomerIdAsync(Guid customerId, CancellationToken ct = default) => BuildCustomerMealDtosAsync( _dbContext.CustomerMeals.Where(cm => cm.FKCustomerId == customerId && cm.IsActive), ct); } }