Files
Core_Be/Repos/CustomerRepository.cs
T
HarithaRandunu 51f0021e27 feat: Enhance booking functionality with invoice management and dashboard summary
- Added customerId to guest booking requests for better identification.
- Implemented new endpoints for managing booking invoices and dashboard summaries.
- Created BookingInvoice and BookingDashboardSummary DTOs to support new features.
- Updated Booking and BookingInvoice models to include payment method and timestamps.
- Enhanced BookingService to handle invoice creation upon payment confirmation.
- Modified BookingRepository to support CRUD operations for BookingInvoice.
- Updated TenantDbContext to include BookingInvoice DbSet and relevant configurations.
- Improved error handling and validation for booking completion and payment processes.
2026-10-01 03:46:03 +05:30

1043 lines
47 KiB
C#

using Fitness_core_BE.DTOs.Meal;
using Fitness_core_BE.DTOs.Workout;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
using Fitness_core_BE.Exceptions;
namespace Fitness_core_BE.Repos
{
public class CustomerRepository : ICustomerRepository
{
// Tenant-scoped operational data (memberships, plan assignments, cards, ...).
private readonly TenantDbContext _context;
// Central identity data (Customer, health/targets, admin templates, ClientBusiness).
private readonly CentralDbContext _central;
public CustomerRepository(TenantDbContext context, CentralDbContext central)
{
_context = context;
_central = central;
}
public async Task<Customer?> GetCustomerById(Guid customerId)
{
return await _central.Customer
.Include(c => c.User)
.FirstOrDefaultAsync(c => c.CusId == customerId && c.IsActive);
}
public async Task<Customer?> GetCustomerByIdentifier(string identifier)
{
if (string.IsNullOrWhiteSpace(identifier))
return null;
var id = identifier.Trim();
return await _central.Customer
.Include(c => c.User)
.FirstOrDefaultAsync(c =>
c.PrimaryPH == id || c.SecondaryPH == id || c.Email == id ||
(c.User != null && (c.User.PhoneNumber == id || c.User.Email == id)));
}
public async Task<Customer> CreateCustomer(Customer customer)
{
if (customer.User != null)
{
_central.Users.Add(customer.User);
}
else
{
var userExists = await _central.Users.AnyAsync(u => u.UserId == customer.FKUserId);
if (!userExists)
{
throw new Exception("User not found");
}
}
_central.Customer.Add(customer);
return customer;
}
// Every tenant-side customer FK (CustomerSubscriptions, SubscriptionMembers, cards,
// attendance, ...) targets TenantCustomerLink, not the central Customer table, so the
// anchor row has to exist in this tenant DB before any of them can be inserted. The
// link id IS the central Customer.CusId (see Models/TenantCustomerLink.cs), so this is
// a pure upsert keyed on that id -- no surrogate key to allocate.
public async Task EnsureTenantCustomerLink(Customer customer)
{
var userId = customer.User?.UserId ?? customer.FKUserId;
// Local-first: on the create path the Customer/User rows are still pending in the
// central context, and the link row may itself already be pending from an earlier
// call in this same unit of work -- FindAsync checks the change tracker before
// hitting the DB, so a second call in one request updates rather than re-adds.
var link = await _context.TenantCustomerLink.FindAsync(customer.CusId);
if (link == null)
{
_context.TenantCustomerLink.Add(new TenantCustomerLink
{
TenantCustomerLinkId = customer.CusId,
CentralUserId = userId,
DisplayName = customer.CusName,
PrimaryPhoneCached = customer.PrimaryPH,
EmailCached = customer.Email,
IsActiveAtThisClient = true,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
});
return;
}
// Re-enrolment at this gym: revive the link and refresh the cached display fields
// so list/report queries that read them don't serve stale values.
link.CentralUserId = userId;
link.DisplayName = customer.CusName;
link.PrimaryPhoneCached = customer.PrimaryPH;
link.EmailCached = customer.Email;
link.IsActiveAtThisClient = true;
link.UpdatedAt = DateTime.UtcNow;
}
// Own-gym duplicate check for customer creation: this tenant's own TenantCustomerLink
// table already holds exactly this gym's active customers, so no clientId is needed.
public async Task<TenantCustomerLink?> FindActiveLinkByIdentifierAsync(string identifier)
{
if (string.IsNullOrWhiteSpace(identifier))
return null;
var id = identifier.Trim();
return await _context.TenantCustomerLink
.FirstOrDefaultAsync(l =>
l.IsActiveAtThisClient &&
(l.PrimaryPhoneCached == id || l.EmailCached == id));
}
// Staff picking an existing member to link a walk-in booking to (identification only,
// see BookingService.CreateGuestBooking/CreateGuestAdhocBooking) -- tenant-scoped by
// construction, same as FindActiveLinkByIdentifierAsync above.
public Task<TenantCustomerLink?> GetTenantCustomerLinkById(Guid id)
=> _context.TenantCustomerLink
.FirstOrDefaultAsync(l => l.TenantCustomerLinkId == id && l.IsActiveAtThisClient);
public async Task<Customer?> UpdateCustomer(Guid customerId, Customer updatedCustomer)
{
// updatedCustomer is the same tracked instance GetCustomerById(customerId) returned
// (Customer + Include(User)), so any field the caller set directly on it — including
// updatedCustomer.User.* — is already picked up by SaveChanges via change tracking.
_central.Customer.Update(updatedCustomer);
return updatedCustomer;
}
// Self-service full-account delete (legacy generic-dispatch path, CustomerService.
// DeleteCustomer): unlike the client-portal delete below, this really does deactivate
// the shared central identity -- the customer themselves is asking to leave the
// platform entirely, not just one gym.
public async Task<bool> DeleteCustomer(Guid customerId)
{
var customer = await _central.Customer.FindAsync(customerId);
if (customer == null)
{
return false;
}
customer.IsActive = false;
// A deleted customer must no longer retain an active membership. CustomerPlan is
// a reusable plan definition, so do not deactivate/delete it; only deactivate the
// customer's subscription and its active related records.
var ownedSubscriptionIds = await _context.CustomerSubscriptions
.Where(subscription => subscription.FKCustomerId == customerId)
.Select(subscription => subscription.SubscriptionId)
.ToListAsync();
var ownedSubscriptions = await _context.CustomerSubscriptions
.Where(subscription => ownedSubscriptionIds.Contains(subscription.SubscriptionId) &&
subscription.IsActive)
.ToListAsync();
foreach (var subscription in ownedSubscriptions)
subscription.IsActive = false;
var membersToDeactivate = await _context.SubscriptionMembers
.Where(member => member.IsActive &&
(member.FKCustomerId == customerId ||
ownedSubscriptionIds.Contains(member.FKSubscriptionId)))
.ToListAsync();
foreach (var member in membersToDeactivate)
member.IsActive = false;
var subItemsToDeactivate = await _context.CustomerSubItems
.Where(item => item.IsActive &&
ownedSubscriptionIds.Contains(item.FKCustomerSubscriptionId))
.ToListAsync();
foreach (var item in subItemsToDeactivate)
item.IsActive = false;
var installmentsToDeactivate = await _context.SubscriptionInstallments
.Where(installment => installment.IsActive &&
ownedSubscriptionIds.Contains(installment.FKSubscriptionId))
.ToListAsync();
foreach (var installment in installmentsToDeactivate)
installment.IsActive = false;
// A deleted customer must also drop off their assigned workout/meal plans. If this
// customer is ever re-added later (see CreateCustomerWithPlans reactivation), these
// rows stay inactive — re-adding assigns fresh plans rather than reviving old ones.
var workoutsToDeactivate = await _context.CustomerWorkouts
.Where(cw => cw.IsActive && cw.FKCustomerId == customerId)
.ToListAsync();
foreach (var workout in workoutsToDeactivate)
workout.IsActive = false;
var mealsToDeactivate = await _context.CustomerMeals
.Where(cm => cm.IsActive && cm.FKCustomerId == customerId)
.ToListAsync();
foreach (var meal in mealsToDeactivate)
meal.IsActive = false;
return true;
}
// Client-initiated delete: only deactivates this customer's subscriptions/plan
// assignments at the calling client's gym — the shared central Customer.IsActive flag
// is never touched here, since the same person may still be an active member elsewhere.
public async Task<bool> DeactivateCustomerForClient(Guid customerId, Guid clientId)
{
var customerExists = await _central.Customer.AnyAsync(c => c.CusId == customerId);
if (!customerExists)
return false;
var ownedSubscriptionIds = await _context.CustomerSubscriptions
.Where(subscription => subscription.FKCustomerId == customerId &&
subscription.CustomerPlan.FKClientId == clientId)
.Select(subscription => subscription.SubscriptionId)
.ToListAsync();
var ownedSubscriptions = await _context.CustomerSubscriptions
.Where(subscription => ownedSubscriptionIds.Contains(subscription.SubscriptionId) &&
subscription.IsActive)
.ToListAsync();
foreach (var subscription in ownedSubscriptions)
subscription.IsActive = false;
var membersToDeactivate = await _context.SubscriptionMembers
.Where(member => member.IsActive && ownedSubscriptionIds.Contains(member.FKSubscriptionId))
.ToListAsync();
foreach (var member in membersToDeactivate)
member.IsActive = false;
var subItemsToDeactivate = await _context.CustomerSubItems
.Where(item => item.IsActive &&
ownedSubscriptionIds.Contains(item.FKCustomerSubscriptionId))
.ToListAsync();
foreach (var item in subItemsToDeactivate)
item.IsActive = false;
var installmentsToDeactivate = await _context.SubscriptionInstallments
.Where(installment => installment.IsActive &&
ownedSubscriptionIds.Contains(installment.FKSubscriptionId))
.ToListAsync();
foreach (var installment in installmentsToDeactivate)
installment.IsActive = false;
// Only drop workout/meal assignments tied to this client's own plan, or to a
// shared admin template (FKClientId == null) — never another client's custom plan.
// The plan may live in either bucket (templates are central), so the eligible ids
// are gathered from both and matched on the scalar FK.
var eligibleWorkoutPlanIds = await _context.WorkoutPlans
.Where(wp => wp.FKClientId == clientId || wp.FKClientId == null)
.Select(wp => wp.WorkoutPlanId)
.ToListAsync();
eligibleWorkoutPlanIds.AddRange(await _central.WorkoutPlans
.Where(wp => wp.FKClientId == clientId || wp.FKClientId == null)
.Select(wp => wp.WorkoutPlanId)
.ToListAsync());
var workoutsToDeactivate = await _context.CustomerWorkouts
.Where(cw => cw.IsActive && cw.FKCustomerId == customerId &&
eligibleWorkoutPlanIds.Contains(cw.FKWorkoutPlanId))
.ToListAsync();
foreach (var workout in workoutsToDeactivate)
workout.IsActive = false;
var eligibleMealPlanIds = await _context.MealPlans
.Where(mp => mp.FKClientId == clientId || mp.FKClientId == null)
.Select(mp => mp.MealPlanId)
.ToListAsync();
eligibleMealPlanIds.AddRange(await _central.MealPlans
.Where(mp => mp.FKClientId == clientId || mp.FKClientId == null)
.Select(mp => mp.MealPlanId)
.ToListAsync());
var mealsToDeactivate = await _context.CustomerMeals
.Where(cm => cm.IsActive && cm.FKCustomerId == customerId &&
eligibleMealPlanIds.Contains(cm.FKMealPlanId))
.ToListAsync();
foreach (var meal in mealsToDeactivate)
meal.IsActive = false;
return true;
}
public async Task<Guid?> GetCustomerClientId(Guid customerId)
{
CustomerSubscription? cusSub = await _context.CustomerSubscriptions
.Include(CustomerPlan => CustomerPlan.CustomerPlan)
.Where(cs => cs.FKCustomerId == customerId && cs.IsActive)
.FirstOrDefaultAsync();
if (cusSub == null)
return null;
return cusSub.CustomerPlan.FKClientId;
}
// A MealPlanId/WorkoutPlanId referenced from a tenant CustomerMeal/CustomerWorkout row
// could be an admin template (central) or this gym's own custom plan (tenant) -- try
// central first, fall back to tenant.
private async Task<MealPlan?> ResolveMealPlanAsync(Guid mealPlanId, bool includeItems = false)
{
IQueryable<MealPlan> centralQuery = _central.MealPlans.AsNoTracking();
if (includeItems) centralQuery = centralQuery.Include(mp => mp.MealItems);
var central = await centralQuery.FirstOrDefaultAsync(mp => mp.MealPlanId == mealPlanId);
if (central != null) return central;
IQueryable<MealPlan> tenantQuery = _context.MealPlans.AsNoTracking();
if (includeItems) tenantQuery = tenantQuery.Include(mp => mp.MealItems);
return await tenantQuery.FirstOrDefaultAsync(mp => mp.MealPlanId == mealPlanId);
}
private async Task<WorkoutPlan?> ResolveWorkoutPlanAsync(Guid workoutPlanId, bool includeItems = false)
{
IQueryable<WorkoutPlan> centralQuery = _central.WorkoutPlans.AsNoTracking();
if (includeItems) centralQuery = centralQuery.Include(wp => wp.WorkoutItems).ThenInclude(wi => wi.WorkoutExercises);
var central = await centralQuery.FirstOrDefaultAsync(wp => wp.WorkoutPlanId == workoutPlanId);
if (central != null) return central;
IQueryable<WorkoutPlan> tenantQuery = _context.WorkoutPlans.AsNoTracking();
if (includeItems) tenantQuery = tenantQuery.Include(wp => wp.WorkoutItems).ThenInclude(wi => wi.WorkoutExercises);
return await tenantQuery.FirstOrDefaultAsync(wp => wp.WorkoutPlanId == workoutPlanId);
}
public async Task<MealPlan?> GetActiveMealPlanAsync(Guid? clientId = null, Guid? cusId = null)
{
if (clientId == null && cusId == null)
throw new ArgumentException("Either clientId or cusId must be provided.");
Guid? mealPlanId;
if (clientId != null)
{
// The plan may be a central template or this gym's own, so match on the
// scalar id against plan ids gathered from both buckets.
var clientMealPlanIds = await _context.MealPlans
.Where(mp => mp.FKClientId == clientId)
.Select(mp => mp.MealPlanId)
.ToListAsync();
clientMealPlanIds.AddRange(await _central.MealPlans
.Where(mp => mp.FKClientId == clientId)
.Select(mp => mp.MealPlanId)
.ToListAsync());
mealPlanId = await _context.CustomerMeals
.Where(cm => clientMealPlanIds.Contains(cm.FKMealPlanId) && cm.IsActive && cm.FKCustomerId == cusId)
.Select(cm => (Guid?)cm.FKMealPlanId)
.FirstOrDefaultAsync();
}
else
{
mealPlanId = await _context.CustomerMeals
.Where(cm => cm.FKCustomerId == cusId && cm.IsActive)
.Select(cm => (Guid?)cm.FKMealPlanId)
.FirstOrDefaultAsync();
}
return mealPlanId.HasValue ? await ResolveMealPlanAsync(mealPlanId.Value) : null;
}
// Shared plan-detail assembly for the meal-plan read paths below: resolves each plan
// from whichever bucket it lives in, then batches this tenant's CustomerMeal
// assignment rows for those plans (assignment rows are always tenant-local, even when
// the plan itself is a central template).
private async Task<List<MealPlanDetailDto>> BuildMealPlanDetailsAsync(
IReadOnlyCollection<Guid> mealPlanIds, Guid? assignedCustomerFilter)
{
if (mealPlanIds.Count == 0)
return new List<MealPlanDetailDto>();
var centralPlans = await _central.MealPlans
.AsNoTracking()
.Include(mp => mp.MealCategory)
.Include(mp => mp.MealItems)
.Where(mp => mealPlanIds.Contains(mp.MealPlanId) && mp.IsActive)
.ToListAsync();
var foundIds = centralPlans.Select(mp => mp.MealPlanId).ToHashSet();
var missingIds = mealPlanIds.Where(id => !foundIds.Contains(id)).ToList();
var tenantPlans = missingIds.Count == 0
? new List<MealPlan>()
: await _context.MealPlans
.AsNoTracking()
.Include(mp => mp.MealCategory)
.Include(mp => mp.MealItems)
.Where(mp => missingIds.Contains(mp.MealPlanId) && mp.IsActive)
.ToListAsync();
var plans = centralPlans.Concat(tenantPlans).ToList();
if (plans.Count == 0)
return new List<MealPlanDetailDto>();
var assignmentQuery = _context.CustomerMeals
.AsNoTracking()
.Include(cm => cm.TenantCustomerLink)
.Where(cm => plans.Select(p => p.MealPlanId).Contains(cm.FKMealPlanId) && cm.IsActive);
if (assignedCustomerFilter.HasValue)
assignmentQuery = assignmentQuery.Where(cm => cm.FKCustomerId == assignedCustomerFilter.Value);
var assignments = await assignmentQuery.ToListAsync();
var assignmentsByPlan = assignments.GroupBy(a => a.FKMealPlanId).ToDictionary(g => g.Key, g => g.ToList());
return plans.Select(mealPlan =>
{
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();
assignmentsByPlan.TryGetValue(mealPlan.MealPlanId, out var planAssignments);
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 = (planAssignments ?? new List<CustomerMeal>())
.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)
};
}).ToList();
}
public async Task<List<MealPlanDetailDto>> GetFollowedMealPlansByCustomerIdAsync(Guid customerId)
{
var planIds = await _context.CustomerMeals
.AsNoTracking()
.Where(cm => cm.FKCustomerId == customerId && cm.IsActive && cm.IsFollowing)
.OrderByDescending(cm => cm.AssignedDate)
.Select(cm => cm.FKMealPlanId)
.Distinct()
.ToListAsync();
return await BuildMealPlanDetailsAsync(planIds, customerId);
}
// All of the customer's own AI-generated meal plans, whether currently followed or
// not — i.e. every non-template plan this customer has ever created/been assigned via
// CreateCustomerMealPlan. Distinct from admin/client-authored templates (IsTemplate=true).
public async Task<List<MealPlanDetailDto>> GetMyMealPlansByCustomerIdAsync(Guid customerId)
{
var planIds = await _context.CustomerMeals
.AsNoTracking()
.Where(cm => cm.FKCustomerId == customerId && cm.IsActive)
.OrderByDescending(cm => cm.AssignedDate)
.Select(cm => cm.FKMealPlanId)
.Distinct()
.ToListAsync();
var details = await BuildMealPlanDetailsAsync(planIds, customerId);
return details.Where(d => !d.IsTemplate).ToList();
}
public async Task<MealPlanDetailDto?> GetMealPlanByIdForCustomerAsync(Guid mealPlanId)
{
var details = await BuildMealPlanDetailsAsync(new[] { mealPlanId }, assignedCustomerFilter: null);
return details.FirstOrDefault();
}
// Projects a customer's completed WorkoutExerciseIds, keyed by CustomerWorkoutId, for
// the given set of CustomerWorkout rows — used to stamp IsCompleted on exercise DTOs.
private async Task<Dictionary<Guid, HashSet<Guid>>> GetCompletedExerciseIdsByCustomerWorkoutAsync(IEnumerable<Guid> customerWorkoutIds)
{
var ids = customerWorkoutIds.Distinct().ToList();
if (ids.Count == 0) return new Dictionary<Guid, HashSet<Guid>>();
var completions = await _context.CustomerWorkoutExerciseCompletions
.AsNoTracking()
.Where(c => ids.Contains(c.FKCustomerWorkoutId))
.Select(c => new { c.FKCustomerWorkoutId, c.FKWorkoutExerciseId })
.ToListAsync();
return completions
.GroupBy(c => c.FKCustomerWorkoutId)
.ToDictionary(g => g.Key, g => g.Select(c => c.FKWorkoutExerciseId).ToHashSet());
}
private static List<WorkoutItemDto> MapWorkoutItemsWithExercises(IEnumerable<WorkoutItem> items, HashSet<Guid>? completedExerciseIds, bool onlyActive = true)
{
var completed = completedExerciseIds ?? new HashSet<Guid>();
var filtered = onlyActive ? items.Where(wi => wi.IsActive) : items;
return filtered.Select(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 = completed.Contains(we.WorkoutExerciseId)
})
.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 = exerciseDtos.Count(e => e.IsCompleted),
IsCompleted = exerciseDtos.Count > 0 && exerciseDtos.All(e => e.IsCompleted)
};
}).ToList();
}
// Shared plan-detail assembly for the workout-plan read paths below -- same
// central-or-tenant resolution + tenant-only assignment batching as meal plans.
private async Task<List<WorkoutPlanDetailDto>> BuildWorkoutPlanDetailsAsync(
IReadOnlyCollection<Guid> workoutPlanIds, Guid? assignedCustomerFilter)
{
if (workoutPlanIds.Count == 0)
return new List<WorkoutPlanDetailDto>();
var centralPlans = await _central.WorkoutPlans
.AsNoTracking()
.Include(wp => wp.WorkoutCategory)
.Include(wp => wp.WorkoutItems).ThenInclude(wi => wi.WorkoutExercises)
.Where(wp => workoutPlanIds.Contains(wp.WorkoutPlanId) && wp.IsActive)
.ToListAsync();
var foundIds = centralPlans.Select(wp => wp.WorkoutPlanId).ToHashSet();
var missingIds = workoutPlanIds.Where(id => !foundIds.Contains(id)).ToList();
var tenantPlans = missingIds.Count == 0
? new List<WorkoutPlan>()
: await _context.WorkoutPlans
.AsNoTracking()
.Include(wp => wp.WorkoutCategory)
.Include(wp => wp.WorkoutItems).ThenInclude(wi => wi.WorkoutExercises)
.Where(wp => missingIds.Contains(wp.WorkoutPlanId) && wp.IsActive)
.ToListAsync();
var plans = centralPlans.Concat(tenantPlans).ToList();
if (plans.Count == 0)
return new List<WorkoutPlanDetailDto>();
var assignmentQuery = _context.CustomerWorkouts
.AsNoTracking()
.Include(cw => cw.TenantCustomerLink)
.Where(cw => plans.Select(p => p.WorkoutPlanId).Contains(cw.FKWorkoutPlanId) && cw.IsActive);
if (assignedCustomerFilter.HasValue)
assignmentQuery = assignmentQuery.Where(cw => cw.FKCustomerId == assignedCustomerFilter.Value);
var assignments = await assignmentQuery.ToListAsync();
var assignmentsByPlan = assignments.GroupBy(a => a.FKWorkoutPlanId).ToDictionary(g => g.Key, g => g.ToList());
var completedByWorkout = await GetCompletedExerciseIdsByCustomerWorkoutAsync(
assignments.Where(a => !assignedCustomerFilter.HasValue || a.FKCustomerId == assignedCustomerFilter.Value)
.Select(a => a.CustomerWorkoutId));
return plans.Select(workoutPlan =>
{
assignmentsByPlan.TryGetValue(workoutPlan.WorkoutPlanId, out var planAssignments);
planAssignments ??= new List<CustomerWorkout>();
var focusWorkoutId = assignedCustomerFilter.HasValue
? planAssignments.FirstOrDefault(a => a.FKCustomerId == assignedCustomerFilter.Value)?.CustomerWorkoutId
: null;
completedByWorkout.TryGetValue(focusWorkoutId ?? Guid.Empty, out var completedExerciseIds);
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 = MapWorkoutItemsWithExercises(workoutPlan.WorkoutItems, completedExerciseIds, onlyActive: assignedCustomerFilter.HasValue),
AssignedCustomers = planAssignments
.Select(cw => new CustomerWorkoutDto
{
CustomerWorkoutId = cw.CustomerWorkoutId,
CustomerId = cw.FKCustomerId,
CustomerName = cw.TenantCustomerLink?.DisplayName,
WorkoutPlanId = cw.FKWorkoutPlanId,
WorkoutPlanName = workoutPlan.WorkoutPlanName,
IsActive = cw.IsActive,
IsFollowing = cw.IsFollowing,
AssignedDate = cw.AssignedDate,
CompletedDate = cw.CompletedDate,
Notes = cw.Notes
}).ToList()
};
}).ToList();
}
public async Task<List<WorkoutPlanDetailDto>> GetFollowedWorkoutPlansByCustomerIdAsync(Guid customerId)
{
var planIds = await _context.CustomerWorkouts
.AsNoTracking()
.Where(cw => cw.FKCustomerId == customerId && cw.IsActive && cw.IsFollowing)
.OrderByDescending(cw => cw.AssignedDate)
.Select(cw => cw.FKWorkoutPlanId)
.Distinct()
.ToListAsync();
return await BuildWorkoutPlanDetailsAsync(planIds, customerId);
}
// All of the customer's own AI-generated workout plans, whether currently followed or
// not. Distinct from admin/client-authored templates (IsTemplate=true).
public async Task<List<WorkoutPlanDetailDto>> GetMyWorkoutPlansByCustomerIdAsync(Guid customerId)
{
var planIds = await _context.CustomerWorkouts
.AsNoTracking()
.Where(cw => cw.FKCustomerId == customerId && cw.IsActive)
.OrderByDescending(cw => cw.AssignedDate)
.Select(cw => cw.FKWorkoutPlanId)
.Distinct()
.ToListAsync();
var details = await BuildWorkoutPlanDetailsAsync(planIds, customerId);
return details.Where(d => !d.IsTemplate).ToList();
}
public async Task<WorkoutPlanDetailDto?> GetWorkoutPlanByIdForCustomerAsync(Guid workoutPlanId, Guid customerId)
{
var details = await BuildWorkoutPlanDetailsAsync(new[] { workoutPlanId }, customerId);
return details.FirstOrDefault();
}
public async Task<CustomerWorkout?> GetActiveCustomerWorkoutAsync(Guid customerId, Guid workoutPlanId)
{
return await _context.CustomerWorkouts
.FirstOrDefaultAsync(cw => cw.FKCustomerId == customerId && cw.FKWorkoutPlanId == workoutPlanId && cw.IsActive);
}
public async Task SetExerciseCompletionAsync(Guid customerWorkoutId, Guid workoutExerciseId, bool completed)
{
var existing = await _context.CustomerWorkoutExerciseCompletions
.FirstOrDefaultAsync(c => c.FKCustomerWorkoutId == customerWorkoutId && c.FKWorkoutExerciseId == workoutExerciseId);
if (completed)
{
if (existing == null)
{
await _context.CustomerWorkoutExerciseCompletions.AddAsync(new CustomerWorkoutExerciseCompletion
{
CustomerWorkoutExerciseCompletionId = Guid.NewGuid(),
FKCustomerWorkoutId = customerWorkoutId,
FKWorkoutExerciseId = workoutExerciseId,
CompletedDate = DateTime.UtcNow
});
}
}
else if (existing != null)
{
_context.CustomerWorkoutExerciseCompletions.Remove(existing);
}
}
public async Task<WorkoutPlan?> GetActiveWorkOutAsync(Guid? clientId = null, Guid? cusId = null)
{
if (clientId == null && cusId == null)
throw new ArgumentException("Either clientId or cusId must be provided.");
Guid? workoutPlanId;
if (clientId != null)
{
// The plan may be a central template or this gym's own, so match on the
// scalar id against plan ids gathered from both buckets.
var clientPlanIds = await _context.WorkoutPlans
.Where(wp => wp.FKClientId == clientId)
.Select(wp => wp.WorkoutPlanId)
.ToListAsync();
clientPlanIds.AddRange(await _central.WorkoutPlans
.Where(wp => wp.FKClientId == clientId)
.Select(wp => wp.WorkoutPlanId)
.ToListAsync());
workoutPlanId = await _context.CustomerWorkouts
.Where(cm => clientPlanIds.Contains(cm.FKWorkoutPlanId) && cm.IsActive && cm.FKCustomerId == cusId)
.Select(cm => (Guid?)cm.FKWorkoutPlanId)
.FirstOrDefaultAsync();
}
else
{
workoutPlanId = await _context.CustomerWorkouts
.Where(cm => cm.FKCustomerId == cusId && cm.IsActive)
.Select(cm => (Guid?)cm.FKWorkoutPlanId)
.FirstOrDefaultAsync();
}
return workoutPlanId.HasValue ? await ResolveWorkoutPlanAsync(workoutPlanId.Value) : null;
}
public async Task<object> FollowMealPlan(Guid customerId, Guid mealPlanId)
{
var currentFollow = await _context.CustomerMeals
.FirstOrDefaultAsync(cm => cm.FKCustomerId == customerId && cm.IsFollowing);
if (currentFollow != null && currentFollow.FKMealPlanId == mealPlanId)
{
return new { message = "Already following this meal plan" };
}
if (currentFollow != null)
{
currentFollow.IsFollowing = false;
_context.CustomerMeals.Update(currentFollow);
}
var existingFollow = await _context.CustomerMeals
.FirstOrDefaultAsync(cm => cm.FKCustomerId == customerId && cm.FKMealPlanId == mealPlanId);
if (existingFollow != null)
{
existingFollow.IsFollowing = true;
existingFollow.IsActive = true;
_context.CustomerMeals.Update(existingFollow);
}
else
{
var newFollow = new CustomerMeal
{
FKCustomerId = customerId,
FKMealPlanId = mealPlanId,
IsFollowing = true,
IsActive = true
};
_context.CustomerMeals.Add(newFollow);
}
return new { success = true, message = "Now following the selected meal plan" };
}
public async Task<object> FollowWorkoutPlan(Guid customerId, Guid workOutPlanId)
{
var currentFollow = await _context.CustomerWorkouts
.FirstOrDefaultAsync(cm => cm.FKCustomerId == customerId && cm.IsFollowing);
if (currentFollow != null && currentFollow.FKWorkoutPlanId == workOutPlanId)
{
return new { message = "Already following this workout plan" };
}
if (currentFollow != null)
{
currentFollow.IsFollowing = false;
_context.CustomerWorkouts.Update(currentFollow);
}
var existingFollow = await _context.CustomerWorkouts
.FirstOrDefaultAsync(cm => cm.FKCustomerId == customerId && cm.FKWorkoutPlanId == workOutPlanId);
if (existingFollow != null)
{
existingFollow.IsFollowing = true;
existingFollow.IsActive = true;
_context.CustomerWorkouts.Update(existingFollow);
}
else
{
var newFollow = new CustomerWorkout
{
FKCustomerId = customerId,
FKWorkoutPlanId = workOutPlanId,
IsFollowing = true,
IsActive = true
};
_context.CustomerWorkouts.Add(newFollow);
}
return new { success = true, message = "Now following the selected workout plan" };
}
public async Task<List<ClientBusiness>> GetAllClentShopsAsync()
{
return await _central.ClientBusiness
.Where(x => x.Latitude != null && x.Longitude != null)
.ToListAsync();
}
public async Task<object> GetClientBizDataAsync(Guid ClientBizId)
{
var biz = await _central.ClientBusiness
.Include(c => c.Client)
.Where(c => c.Bid == ClientBizId && c.IsActive == true)
.FirstOrDefaultAsync();
if (biz == null)
throw new NotFoundException($"{ClientBizId} business not found");
return biz;
}
public async Task<CustomerHealth> GetCusHealthRec(Guid cusID)
{
CustomerHealth? customerHealth = await _central.CustomerHealth
.Where(ch => ch.FKCusId == cusID && ch.IsActive == true)
.FirstOrDefaultAsync();
if (customerHealth == null)
return null;
return customerHealth;
}
public async Task<List<CustomerTarget>> GetCusProgressRec(Guid cusID)
{
var customerTargets = await _central.CustomerTargets
.Where(ch => ch.FKCusId == cusID && ch.IsActive == true)
.OrderByDescending(ch => ch.CreatedAt)
.ToListAsync();
if (!customerTargets.Any())
throw new NotFoundException("No progress records found");
return customerTargets;
}
public async Task<object> CreateCusHealthRecAsync(CustomerHealth customerHealth)
{
var cushealth = await _central.CustomerHealth
.AddAsync(customerHealth);
return customerHealth;
}
public async Task<object> UpdateCusHealthRecAsync(CustomerHealth newCushealth)
{
_central.CustomerHealth.Update(newCushealth);
return newCushealth;
}
public async Task<object> CreateCustomerTargetAsync(CustomerTarget customerTarget)
{
await _central.CustomerTargets.AddAsync(customerTarget);
return customerTarget;
}
public async Task<CustomerTarget?> GetLatestCustomerTargetAsync(Guid customerId)
{
return await _central.CustomerTargets
.Where(ct => ct.FKCusId == customerId && ct.IsActive)
.OrderByDescending(ct => ct.CreatedAt)
.FirstOrDefaultAsync();
}
public async Task<List<CustomerTarget>> GetCustomerTargetsAsync(Guid customerId)
{
return await _central.CustomerTargets
.Where(ct => ct.FKCusId == customerId && ct.IsActive)
.OrderByDescending(ct => ct.CreatedAt)
.ToListAsync();
}
public async Task<CustomerTarget> UpdateLatestCustomerTargetNutritionsAsync(Guid customerId, string predictedNutritionsJson)
{
var target = await _central.CustomerTargets
.Where(ct => ct.FKCusId == customerId && ct.IsActive)
.OrderByDescending(ct => ct.CreatedAt)
.FirstOrDefaultAsync();
if (target == null)
throw new NotFoundException("Customer target not found");
target.PredictedNutritionsJson = predictedNutritionsJson;
target.IsChanged = true;
_central.CustomerTargets.Update(target);
return target;
}
public async Task<Dictionary<string, List<CustomerTarget>>> GetCustomerProgressGroupedByGoalAsync(Guid cusID)
{
var progressList = await _central.CustomerTargets
.Where(cp => cp.FKCusId == cusID && cp.IsActive)
.OrderByDescending(cp => cp.CreatedAt)
.ToListAsync();
var grouped = progressList
.GroupBy(p => string.IsNullOrWhiteSpace(p.Goal) ? "Unknown" : p.Goal)
.ToDictionary(g => g.Key, g => g.ToList());
return grouped;
}
public async Task<CusWeightLossProgress> UpdateWeeklyProgressAsync(Guid customerId, Guid customerTargetId, decimal weight, DateTime? recordedAt)
{
var target = await _central.CustomerTargets
.FirstOrDefaultAsync(ct => ct.CustomerTargetId == customerTargetId && ct.FKCusId == customerId && ct.IsActive);
if (target == null)
throw new NotFoundException("Customer target not found");
var weeklyProgress = new CusWeightLossProgress
{
FKCusId = customerId,
CustomerTargetId = customerTargetId,
Weight = weight,
RecordedAt = recordedAt ?? DateTime.UtcNow,
IsActive = true
};
await _central.CusWeightLossProgress.AddAsync(weeklyProgress);
return weeklyProgress;
}
public async Task<List<CusWeightLossProgress>> GetWeeklyProgressForCustomerAsync(Guid customerId, Guid customerTargetId)
{
var targetExists = await _central.CustomerTargets
.AnyAsync(ct => ct.CustomerTargetId == customerTargetId && ct.FKCusId == customerId && ct.IsActive);
if (!targetExists)
throw new NotFoundException("Customer target not found");
return await _central.CusWeightLossProgress
.Where(wp => wp.FKCusId == customerId && wp.CustomerTargetId == customerTargetId && wp.IsActive)
.OrderByDescending(wp => wp.RecordedAt)
.ToListAsync();
}
public async Task<bool> VerifyCustomer(Guid userId, string type, string identifire)
{
Customer customer = await _central.Customer
.Where(c => c.FKUserId == userId && c.IsActive)
.FirstOrDefaultAsync();
if (customer == null)
return false;
switch (type)
{
case "PrimaryPH":
customer.IsPrimaryPHVerfied = true;
customer.PrimaryPH = identifire;
break;
case "SecondaryPH":
customer.IsSecondaryPHVerfied = true;
customer.SecondaryPH = identifire;
break;
case "Email":
customer.IsEmailVerfied = true;
customer.Email = identifire;
break;
default:
return false;
}
_central.Customer.Update(customer);
return true;
}
public async Task<bool> IsverifiedCus(Guid cusId)
{
Customer? verifiedCus = await _central.Customer
.Where(c => c.CusId == cusId && (c.IsEmailVerfied || c.IsPrimaryPHVerfied || c.IsSecondaryPHVerfied) && c.IsActive)
.FirstOrDefaultAsync();
if(verifiedCus == null) return false;
return true;
}
public async Task<List<CustomerCards>> GetCardsByCustomerIdAsync(Guid customerId, CancellationToken ct = default)
{
return await _context.CustomerCards
.AsNoTracking()
.Where(c => c.FK_CustomerId == customerId && c.IsActive)
.ToListAsync(ct);
}
public async Task<CustomerCards?> GetCardByIdAsync(Guid cardId, CancellationToken ct = default)
{
return await _context.CustomerCards
.AsNoTracking()
.FirstOrDefaultAsync(c => c.JobId == cardId, ct);
}
public async Task<CustomerCards> AddCardAsync(CustomerCards card, CancellationToken ct = default)
{
card.JobId = Guid.NewGuid();
_context.CustomerCards.Add(card);
return card;
}
}
}