827 lines
40 KiB
C#
827 lines
40 KiB
C#
using Fitness_core_BE.Domain;
|
|
using Fitness_core_BE.DTOs.Customer;
|
|
using Fitness_core_BE.DTOs.CustomerPlan;
|
|
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;
|
|
|
|
namespace Fitness_core_BE.Repos
|
|
{
|
|
|
|
public class ClientRepository : IClientRepository
|
|
{
|
|
// Tenant-scoped operational data (staff, memberships, invoices, access control, ...).
|
|
private readonly TenantDbContext _dbContext;
|
|
// Central identity/registry data (Customer, Client, ClientBusiness, admin templates,
|
|
// platform billing).
|
|
private readonly CentralDbContext _central;
|
|
|
|
public ClientRepository(TenantDbContext dbContext, CentralDbContext central)
|
|
{
|
|
_dbContext = dbContext;
|
|
_central = central;
|
|
}
|
|
|
|
|
|
public async Task<List<Customer>> GetAllCustomers()
|
|
{
|
|
return await _central.Customer
|
|
.Where(c => c.IsActive == true)
|
|
.ToListAsync();
|
|
}
|
|
|
|
public async Task<Customer?> GetCustomerById(Guid customerId)
|
|
{
|
|
var customer = await _central.Customer
|
|
.AsNoTracking()
|
|
.Include(c => c.User)
|
|
.Include(c => c.CustomerTargets.Where(ct => ct.IsActive))
|
|
.ThenInclude(ct => ct.WeeklyProgresses.Where(wp => wp.IsActive))
|
|
.FirstOrDefaultAsync(c => c.CusId == customerId && c.IsActive);
|
|
if (customer == null)
|
|
return null;
|
|
|
|
// CustomerSubscriptions/CustomerWorkouts/CustomerMeals are tenant-only -- load
|
|
// this tenant's slice separately and attach in memory (mirrors what a single
|
|
// cross-database Include could no longer do).
|
|
customer.CustomerSubscriptions = await _dbContext.CustomerSubscriptions
|
|
.AsNoTracking()
|
|
.Include(cs => cs.CustomerPlan)
|
|
.Where(cs => cs.FKCustomerId == customerId)
|
|
.ToListAsync();
|
|
customer.CustomerWorkouts = await _dbContext.CustomerWorkouts
|
|
.AsNoTracking()
|
|
.Where(cw => cw.FKCustomerId == customerId && cw.IsActive)
|
|
.ToListAsync();
|
|
customer.CustomerMeals = await _dbContext.CustomerMeals
|
|
.AsNoTracking()
|
|
.Where(cm => cm.FKCustomerId == customerId && cm.IsActive)
|
|
.ToListAsync();
|
|
|
|
return customer;
|
|
}
|
|
|
|
|
|
|
|
public async Task<List<Client>> GetAllClientsAsync(CancellationToken ct = default)
|
|
{
|
|
return await _central.Client
|
|
.AsNoTracking()
|
|
.Include(c => c.User)
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
public async Task<Client?> GetClientByIdAsync(Guid clientId, CancellationToken ct = default)
|
|
{
|
|
return await _central.Client
|
|
.AsNoTracking()
|
|
// Keep the profile read aligned with the same primary active
|
|
// business row that UpdateGymProfile mutates.
|
|
.Include(c => c.ClientBusinesses
|
|
.Where(b => b.IsActive)
|
|
.OrderBy(b => b.createdAt))
|
|
.FirstOrDefaultAsync(c => c.ClientId == clientId, ct);
|
|
}
|
|
|
|
public async Task<List<Customer>> GetClientListAsync(Guid clientId, CancellationToken ct = default)
|
|
{
|
|
var customers = await _central.Customer
|
|
|
|
.Where(c => c.FKUserId == clientId)
|
|
.ToListAsync(ct);
|
|
|
|
return customers;
|
|
}
|
|
|
|
public async Task<object> RegisterClientAsync(Client client, CancellationToken ct = default)
|
|
{
|
|
await _central.Client.AddAsync(client, ct);
|
|
return new { client.ClientId };
|
|
}
|
|
|
|
|
|
public async Task<List<MemberListRowDto>> GetCustomersByClientIdAsync(Guid clientId, CancellationToken ct = default)
|
|
{
|
|
// One row per member via SubscriptionMembers so group secondaries appear
|
|
// alongside primaries, each pointing at the shared subscription.
|
|
var memberships = await _dbContext.SubscriptionMembers
|
|
.AsNoTracking()
|
|
.Where(m => m.IsActive
|
|
&& m.TenantCustomerLink.IsActiveAtThisClient
|
|
&& m.Subscription.IsActive
|
|
&& m.Subscription.CustomerPlan.FKClientId == clientId)
|
|
.Include(m => m.TenantCustomerLink)
|
|
.Include(m => m.Subscription)
|
|
.ThenInclude(s => s.CustomerPlan)
|
|
.OrderByDescending(m => m.Subscription.OnboardDate)
|
|
.ToListAsync(ct);
|
|
|
|
// Latest subscription per member (mirrors the previous one-row-per-member semantic).
|
|
var latestPerMember = memberships
|
|
.GroupBy(m => m.FKCustomerId)
|
|
.Select(g => g.First())
|
|
.ToList();
|
|
|
|
var subscriptionIds = latestPerMember.Select(m => m.FKSubscriptionId).Distinct().ToList();
|
|
|
|
// Batch: latest current invoice per subscription. IsActive excludes an unapproved
|
|
// renewal invoice awaiting staff review; RolledOver excludes one already superseded
|
|
// by a newer invoice that carried its balance forward -- without both, this list
|
|
// could show either a not-yet-billed amount or a double-counted balance.
|
|
var invoices = await _dbContext.CustomerInvoices
|
|
.AsNoTracking()
|
|
.Where(i => subscriptionIds.Contains(i.FKCustomerSubscriptionId)
|
|
&& i.IsActive && i.Status != "Cancelled" && i.Status != "RolledOver")
|
|
.GroupBy(i => i.FKCustomerSubscriptionId)
|
|
.Select(g => g.OrderByDescending(i => i.CustomerInvoiceDate).First())
|
|
.ToListAsync(ct);
|
|
var invoiceBySub = invoices.ToDictionary(i => i.FKCustomerSubscriptionId);
|
|
|
|
// Batch: approved paid sums per invoice.
|
|
var invoiceIds = invoices.Select(i => i.CustomerInvoiceId).ToList();
|
|
var paidSums = await _dbContext.PaymentTransactions
|
|
.AsNoTracking()
|
|
.Where(p => p.FK_CustomerInvoiceId != null
|
|
&& invoiceIds.Contains(p.FK_CustomerInvoiceId.Value)
|
|
&& p.IsApproved == true
|
|
&& p.IsActive == true)
|
|
.GroupBy(p => p.FK_CustomerInvoiceId!.Value)
|
|
.Select(g => new { InvoiceId = g.Key, Paid = g.Sum(p => p.PayAmount) })
|
|
.ToDictionaryAsync(x => x.InvoiceId, x => x.Paid, ct);
|
|
|
|
// Batch: active member counts per subscription.
|
|
var memberCounts = await _dbContext.SubscriptionMembers
|
|
.AsNoTracking()
|
|
.Where(m => subscriptionIds.Contains(m.FKSubscriptionId) && m.IsActive && m.IsPlanAssigned)
|
|
.GroupBy(m => m.FKSubscriptionId)
|
|
.Select(g => new { SubscriptionId = g.Key, Count = g.Count() })
|
|
.ToDictionaryAsync(x => x.SubscriptionId, x => x.Count, ct);
|
|
|
|
// Batch: tenant-scoped access profiles and their terminal bindings.
|
|
var customerUserIds = latestPerMember
|
|
.Where(m => m.TenantCustomerLink != null)
|
|
.Select(m => m.TenantCustomerLink.CentralUserId)
|
|
.Distinct()
|
|
.ToList();
|
|
var accessByUser = await _dbContext.AccessProfiles
|
|
.AsNoTracking()
|
|
.Include(p => p.DeviceBindings)
|
|
.ThenInclude(b => b.AccessDevice)
|
|
.Where(p => p.FKClientId == clientId && customerUserIds.Contains(p.FKUserId))
|
|
.ToDictionaryAsync(p => p.FKUserId, p => p, ct);
|
|
|
|
var rows = latestPerMember.Select(m =>
|
|
{
|
|
var subscription = m.Subscription;
|
|
var plan = subscription.CustomerPlan;
|
|
invoiceBySub.TryGetValue(subscription.SubscriptionId, out var invoice);
|
|
|
|
// Legacy subscriptions without an invoice still show the plan price as owed.
|
|
var invoiceAmount = invoice?.Amount ?? plan?.PlanPrice ?? 0m;
|
|
var paid = invoice != null && paidSums.TryGetValue(invoice.CustomerInvoiceId, out var sum) ? sum : 0m;
|
|
var balance = invoiceAmount - paid;
|
|
accessByUser.TryGetValue(m.TenantCustomerLink.CentralUserId, out var access);
|
|
var bindings = access?.DeviceBindings
|
|
.Where(b => b.AccessDevice.IsActive && b.AccessDevice.FKAgentId != null)
|
|
.ToList() ?? new List<AccessDeviceBinding>();
|
|
var provision = access == null
|
|
? "NotConfigured"
|
|
: bindings.Count == 0 ? "NoDevice"
|
|
: bindings.Any(b => b.ProvisionStatus == "Failed") ? "Failed"
|
|
: bindings.All(b => b.ProvisionStatus == "Synced") ? "Synced" : "Pending";
|
|
var actual = bindings.Count == 0 ? "Unknown"
|
|
: bindings.All(b => b.ActualAccessState == access!.DesiredAccessState)
|
|
? access.DesiredAccessState
|
|
: bindings.All(b => b.ActualAccessState == "Unknown")
|
|
? "Unknown"
|
|
: "Mixed";
|
|
|
|
return new MemberListRowDto
|
|
{
|
|
FingerprintId = access != null && access.AccessProfileId <= int.MaxValue
|
|
? (int)access.AccessProfileId : null,
|
|
FingerprintName = access?.DisplayName,
|
|
AccessProfileId = access?.AccessProfileId,
|
|
AccessNumber = bindings.FirstOrDefault()?.DeviceEmployeeNo
|
|
?? (access == null ? null : $"HF{access.AccessProfileId}"),
|
|
AccessProvisionStatus = provision,
|
|
ActualAccessState = actual,
|
|
DesiredAccessState = access?.DesiredAccessState ?? "Enabled",
|
|
FingerprintCount = bindings.Count == 0 ? 0 : bindings.Max(b => b.FingerprintCount),
|
|
AccessLastError = bindings.FirstOrDefault(b => !string.IsNullOrWhiteSpace(b.LastError))?.LastError,
|
|
IsLocked = access?.DesiredAccessState == "Disabled",
|
|
LockReason = access?.LockReason,
|
|
HasFacePhoto = access?.HasFacePhoto ?? false,
|
|
FacePhotoStatus = access?.FacePhotoStatus ?? "None",
|
|
FacePhotoError = access?.FacePhotoError,
|
|
SubscriptionId = subscription.SubscriptionId,
|
|
FKCustomerPlanId = subscription.FKCustomerPlanId,
|
|
FKCustomerId = m.FKCustomerId,
|
|
OnboardDate = subscription.OnboardDate,
|
|
ExpiryDate = subscription.ExpiryDate,
|
|
RenewalDate = subscription.RenewalDate,
|
|
IsActive = subscription.IsActive,
|
|
CreatedAt = subscription.CreatedAt,
|
|
Customer = BuildDisplayCustomer(m.TenantCustomerLink),
|
|
IsPrimary = m.IsPrimary,
|
|
IsPlanAssigned = m.IsPlanAssigned,
|
|
PlanName = plan?.PlanName,
|
|
PlanScope = plan?.PlanScope ?? "Individual",
|
|
MemberCount = memberCounts.TryGetValue(subscription.SubscriptionId, out var count) ? count : 1,
|
|
MemberLimit = plan?.MemberLimit ?? 1,
|
|
InvoiceAmount = invoiceAmount,
|
|
PaidAmount = paid,
|
|
Balance = balance,
|
|
PaymentStatus = paid >= invoiceAmount && invoiceAmount > 0
|
|
? "Paid"
|
|
: paid > 0 ? "Partial" : "Pending",
|
|
AllowSplitPayments = plan?.AllowSplitPayments ?? false
|
|
};
|
|
}).ToList();
|
|
|
|
// Customers created without a plan yet have no SubscriptionMember row, so they'd
|
|
// otherwise be invisible here — they're still this gym's members (linked via the
|
|
// AccessProfile created at creation time, same as everyone else) and need to show
|
|
// up so staff can find them and assign a plan (as primary or as a group member).
|
|
var linkedCustomerIds = rows.Select(r => r.FKCustomerId).ToHashSet();
|
|
var planlessLinkIds = await _dbContext.AccessProfiles
|
|
.AsNoTracking()
|
|
.Where(p => p.FKClientId == clientId && p.PersonType == "Customer")
|
|
.Join(_dbContext.TenantCustomerLink.Where(l => l.IsActiveAtThisClient),
|
|
p => p.FKUserId, l => l.CentralUserId,
|
|
(p, l) => l.TenantCustomerLinkId)
|
|
.Where(cusId => !linkedCustomerIds.Contains(cusId))
|
|
.Distinct()
|
|
.ToListAsync(ct);
|
|
|
|
if (planlessLinkIds.Count > 0)
|
|
{
|
|
// Same tenant-only cache as the main list above -- no central round-trip.
|
|
var planlessLinks = await _dbContext.TenantCustomerLink
|
|
.AsNoTracking()
|
|
.Where(l => planlessLinkIds.Contains(l.TenantCustomerLinkId) && l.IsActiveAtThisClient)
|
|
.ToListAsync(ct);
|
|
|
|
rows.AddRange(planlessLinks.Select(l => new MemberListRowDto
|
|
{
|
|
SubscriptionId = Guid.Empty,
|
|
FKCustomerPlanId = Guid.Empty,
|
|
FKCustomerId = l.TenantCustomerLinkId,
|
|
OnboardDate = l.CreatedAt,
|
|
ExpiryDate = null,
|
|
RenewalDate = null,
|
|
IsActive = l.IsActiveAtThisClient,
|
|
CreatedAt = l.CreatedAt,
|
|
Customer = BuildDisplayCustomer(l),
|
|
IsPrimary = true,
|
|
PlanName = null,
|
|
PlanScope = "None",
|
|
MemberCount = 0,
|
|
MemberLimit = 0,
|
|
InvoiceAmount = 0,
|
|
PaidAmount = 0,
|
|
Balance = 0,
|
|
PaymentStatus = "NoPlan",
|
|
AllowSplitPayments = false
|
|
}));
|
|
}
|
|
|
|
return rows.OrderByDescending(r => r.CreatedAt).ToList();
|
|
}
|
|
|
|
// Builds a display-only Customer for the member list from this gym's own cached copy
|
|
// (TenantCustomerLink) instead of a central-DB round-trip. Only name/phone/email/active
|
|
// are cached here -- gender/DOB/NIC/login-username are central-only and are fetched by
|
|
// GetMemberById (client/customers/{id}) when a single member's full profile is needed
|
|
// (e.g. opening the edit form), not for every row in the list.
|
|
private static Models.Customer BuildDisplayCustomer(TenantCustomerLink link) => new()
|
|
{
|
|
CusId = link.TenantCustomerLinkId,
|
|
CusName = link.DisplayName,
|
|
PrimaryPH = link.PrimaryPhoneCached,
|
|
Email = link.EmailCached,
|
|
FKUserId = link.CentralUserId,
|
|
IsActive = link.IsActiveAtThisClient,
|
|
CreatedAt = link.CreatedAt,
|
|
};
|
|
|
|
public async Task<bool> CustomerBelongsToClientAsync(Guid customerId, Guid clientId, CancellationToken ct = default)
|
|
{
|
|
var hasSubscription = await _dbContext.SubscriptionMembers
|
|
.AsNoTracking()
|
|
.AnyAsync(member => member.FKCustomerId == customerId
|
|
&& member.IsActive
|
|
&& member.Subscription.IsActive
|
|
&& member.Subscription.CustomerPlan.FKClientId == clientId, ct);
|
|
if (hasSubscription)
|
|
return true;
|
|
|
|
// Plan-less customers have no SubscriptionMember row yet — fall back to the
|
|
// AccessProfile link created at member-creation time (see GetCustomersByClientIdAsync).
|
|
// Central lookup for the customer's shared login UserId, then a tenant-scoped check.
|
|
var userId = await _central.Customer
|
|
.AsNoTracking()
|
|
.Where(c => c.CusId == customerId)
|
|
.Select(c => (Guid?)c.FKUserId)
|
|
.FirstOrDefaultAsync(ct);
|
|
if (userId == null)
|
|
return false;
|
|
|
|
return await _dbContext.AccessProfiles
|
|
.AsNoTracking()
|
|
.AnyAsync(p => p.FKClientId == clientId && p.PersonType == "Customer" && p.FKUserId == userId.Value, ct);
|
|
}
|
|
|
|
public async Task<List<CustomerPlan>> GetCustomerPlansByClientIdAsync(Guid clientId, CancellationToken ct = default)
|
|
{
|
|
return await _dbContext.CustomerPlans
|
|
.Where(p => p.FKClientId == clientId && p.IsActive)
|
|
.AsNoTracking()
|
|
.OrderByDescending(p => p.CreatedAt)
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
public async Task<CustomerPlan?> GetCustomerPlanEntityForClientAsync(Guid customerPlanId, Guid clientId, CancellationToken ct = default)
|
|
{
|
|
return await _dbContext.CustomerPlans
|
|
.AsNoTracking()
|
|
.Where(p => p.CustomerPlanId == customerPlanId && p.FKClientId == clientId && p.IsActive)
|
|
.FirstOrDefaultAsync(ct);
|
|
}
|
|
|
|
// Unlike CustomerSubscription.FKCustomerId (which only ever points at the primary
|
|
// member), this resolves the caller's own SubscriptionMember row whether they're
|
|
// the primary or a secondary member of a group subscription.
|
|
public async Task<SubscriptionMember?> GetActiveMembershipForCustomerAsync(Guid customerId, CancellationToken ct = default)
|
|
{
|
|
return await _dbContext.SubscriptionMembers
|
|
.Include(m => m.Subscription)
|
|
.ThenInclude(s => s.CustomerPlan)
|
|
.Where(m => m.FKCustomerId == customerId && m.IsActive && m.Subscription.IsActive)
|
|
.OrderByDescending(m => m.Subscription.CreatedAt)
|
|
.FirstOrDefaultAsync(ct);
|
|
}
|
|
|
|
public async Task DeactivateSubscriptionAsync(Guid subscriptionId, CancellationToken ct = default)
|
|
{
|
|
var subscription = await _dbContext.CustomerSubscriptions
|
|
.FirstOrDefaultAsync(cs => cs.SubscriptionId == subscriptionId, ct);
|
|
if (subscription == null)
|
|
return;
|
|
|
|
subscription.IsActive = false;
|
|
|
|
var members = await _dbContext.SubscriptionMembers
|
|
.Where(m => m.FKSubscriptionId == subscriptionId && m.IsActive)
|
|
.ToListAsync(ct);
|
|
foreach (var member in members)
|
|
member.IsActive = false;
|
|
|
|
var subItems = await _dbContext.CustomerSubItems
|
|
.Where(si => si.FKCustomerSubscriptionId == subscriptionId && si.IsActive)
|
|
.ToListAsync(ct);
|
|
foreach (var item in subItems)
|
|
item.IsActive = false;
|
|
|
|
var installments = await _dbContext.SubscriptionInstallments
|
|
.Where(i => i.FKSubscriptionId == subscriptionId && i.IsActive)
|
|
.ToListAsync(ct);
|
|
foreach (var installment in installments)
|
|
installment.IsActive = false;
|
|
}
|
|
|
|
public async Task DeactivateSubscriptionMemberAsync(Guid subscriptionMemberId, CancellationToken ct = default)
|
|
{
|
|
var member = await _dbContext.SubscriptionMembers
|
|
.FirstOrDefaultAsync(m => m.SubscriptionMemberId == subscriptionMemberId && m.IsActive, ct);
|
|
if (member != null)
|
|
member.IsActive = false;
|
|
}
|
|
|
|
// Unassign/re-assign only moves IsPlanAssigned. IsActive is left alone so the member
|
|
// stays in the gym's member list (and keeps their device enrollment) while sitting
|
|
// without a plan, and so the same button can put them straight back on it.
|
|
public async Task SetMembershipPlanAssignedAsync(
|
|
Guid subscriptionId, bool assigned, bool wholeSubscription, Guid subscriptionMemberId,
|
|
CancellationToken ct = default)
|
|
{
|
|
var members = wholeSubscription
|
|
? await _dbContext.SubscriptionMembers
|
|
.Where(m => m.FKSubscriptionId == subscriptionId && m.IsActive)
|
|
.ToListAsync(ct)
|
|
: await _dbContext.SubscriptionMembers
|
|
.Where(m => m.SubscriptionMemberId == subscriptionMemberId && m.IsActive)
|
|
.ToListAsync(ct);
|
|
|
|
foreach (var member in members)
|
|
member.IsPlanAssigned = assigned;
|
|
}
|
|
|
|
// The membership to act on for assign/unassign, ignoring IsPlanAssigned so an
|
|
// already-unassigned member can be found again and put back on their plan.
|
|
public async Task<SubscriptionMember?> GetMembershipForCustomerAsync(
|
|
Guid customerId, CancellationToken ct = default)
|
|
{
|
|
return await _dbContext.SubscriptionMembers
|
|
.Include(m => m.Subscription)
|
|
.ThenInclude(s => s.CustomerPlan)
|
|
.Where(m => m.FKCustomerId == customerId && m.IsActive && m.Subscription.IsActive)
|
|
.OrderByDescending(m => m.Subscription.CreatedAt)
|
|
.FirstOrDefaultAsync(ct);
|
|
}
|
|
|
|
public async Task ReplaceCustomerSubscriptionAsync(SubscriptionMember? oldMembership, CustomerSubscriptionAggregate newAggregate, CancellationToken ct = default)
|
|
{
|
|
if (oldMembership != null)
|
|
{
|
|
if (oldMembership.IsPrimary)
|
|
await DeactivateSubscriptionAsync(oldMembership.FKSubscriptionId, ct);
|
|
else
|
|
await DeactivateSubscriptionMemberAsync(oldMembership.SubscriptionMemberId, ct);
|
|
}
|
|
|
|
await _dbContext.CustomerSubscriptions.AddAsync(newAggregate.Subscription, ct);
|
|
await _dbContext.CustomerSubItems.AddAsync(newAggregate.InitialSubItem, ct);
|
|
await _dbContext.SubscriptionMembers.AddAsync(newAggregate.PrimaryMember, ct);
|
|
await _dbContext.SubscriptionInstallments.AddRangeAsync(newAggregate.Installments, ct);
|
|
}
|
|
|
|
public async Task<CustomerPlanDto?> GetCustomerPlanByIdForClientAsync(Guid customerPlanId, Guid clientId, CancellationToken ct = default)
|
|
{
|
|
return await _dbContext.CustomerPlans
|
|
.AsNoTracking()
|
|
.Where(p => p.CustomerPlanId == customerPlanId && p.FKClientId == clientId && p.IsActive)
|
|
.Select(p => new CustomerPlanDto
|
|
{
|
|
CustomerPlanId = p.CustomerPlanId,
|
|
FKClientId = p.FKClientId,
|
|
ClientName = null,
|
|
PlanName = p.PlanName,
|
|
PlanDescription = p.PlanDescription,
|
|
PlanPrice = p.PlanPrice,
|
|
DurationInDays = p.DurationInDays,
|
|
IsActive = p.IsActive,
|
|
CreatedAt = p.CreatedAt
|
|
})
|
|
.FirstOrDefaultAsync(ct);
|
|
}
|
|
|
|
public async Task RegisterBussinessDetailAsync(ClientBusiness clientBusiness, CancellationToken ct = default)
|
|
{
|
|
await _central.ClientBusiness.AddAsync(clientBusiness, ct);
|
|
}
|
|
|
|
public async Task<ClientBusiness?> GetPrimaryBusinessByClientIdAsync(Guid clientId, CancellationToken ct = default)
|
|
{
|
|
// Tracked query — the profile editor mutates this entity and persists via UoW.
|
|
return await _central.ClientBusiness
|
|
.Where(b => b.FKClientId == clientId && b.IsActive)
|
|
.OrderBy(b => b.createdAt)
|
|
.FirstOrDefaultAsync(ct);
|
|
}
|
|
|
|
public async Task<Client?> GetClientEntityByIdAsync(Guid clientId, CancellationToken ct = default)
|
|
{
|
|
// Tracked — used to mirror contact-detail changes back onto the Client record.
|
|
return await _central.Client.FirstOrDefaultAsync(c => c.ClientId == clientId, ct);
|
|
}
|
|
|
|
public async Task<List<FollowedPlanDto>> GetFollowedWorkoutPlansAsync(Guid customerId, CancellationToken ct = default)
|
|
{
|
|
var follows = await _dbContext.CustomerWorkouts
|
|
.AsNoTracking()
|
|
.Where(cw => cw.FKCustomerId == customerId && cw.IsActive)
|
|
.Select(cw => new { cw.FKWorkoutPlanId, cw.IsFollowing, cw.CompletedDate })
|
|
.ToListAsync(ct);
|
|
if (follows.Count == 0)
|
|
return new List<FollowedPlanDto>();
|
|
|
|
var planIds = follows.Select(f => f.FKWorkoutPlanId).Distinct().ToList();
|
|
var names = await ResolveWorkoutPlanNamesAsync(planIds, ct);
|
|
|
|
return follows
|
|
.Select(f => new FollowedPlanDto(f.FKWorkoutPlanId, names.GetValueOrDefault(f.FKWorkoutPlanId), f.IsFollowing, f.CompletedDate))
|
|
.ToList();
|
|
}
|
|
|
|
public async Task<List<FollowedPlanDto>> GetFollowedMealPlansAsync(Guid customerId, CancellationToken ct = default)
|
|
{
|
|
var follows = await _dbContext.CustomerMeals
|
|
.AsNoTracking()
|
|
.Where(cm => cm.FKCustomerId == customerId && cm.IsActive)
|
|
.Select(cm => new { cm.FKMealPlanId, cm.IsFollowing, cm.CompletedDate })
|
|
.ToListAsync(ct);
|
|
if (follows.Count == 0)
|
|
return new List<FollowedPlanDto>();
|
|
|
|
var planIds = follows.Select(f => f.FKMealPlanId).Distinct().ToList();
|
|
var names = await ResolveMealPlanNamesAsync(planIds, ct);
|
|
|
|
return follows
|
|
.Select(f => new FollowedPlanDto(f.FKMealPlanId, names.GetValueOrDefault(f.FKMealPlanId), f.IsFollowing, f.CompletedDate))
|
|
.ToList();
|
|
}
|
|
|
|
// A followed WorkoutPlan/MealPlan id could be an admin template (central) or this
|
|
// gym's own custom plan (tenant) -- resolve names from both buckets and merge.
|
|
private async Task<Dictionary<Guid, string?>> ResolveWorkoutPlanNamesAsync(List<Guid> planIds, CancellationToken ct)
|
|
{
|
|
var central = await _central.WorkoutPlans
|
|
.AsNoTracking()
|
|
.Where(wp => planIds.Contains(wp.WorkoutPlanId))
|
|
.ToDictionaryAsync(wp => wp.WorkoutPlanId, wp => (string?)wp.WorkoutPlanName, ct);
|
|
var missing = planIds.Except(central.Keys).ToList();
|
|
if (missing.Count > 0)
|
|
{
|
|
var tenant = await _dbContext.WorkoutPlans
|
|
.AsNoTracking()
|
|
.Where(wp => missing.Contains(wp.WorkoutPlanId))
|
|
.ToDictionaryAsync(wp => wp.WorkoutPlanId, wp => (string?)wp.WorkoutPlanName, ct);
|
|
foreach (var kv in tenant)
|
|
central[kv.Key] = kv.Value;
|
|
}
|
|
return central;
|
|
}
|
|
|
|
private async Task<Dictionary<Guid, string?>> ResolveMealPlanNamesAsync(List<Guid> planIds, CancellationToken ct)
|
|
{
|
|
var central = await _central.MealPlans
|
|
.AsNoTracking()
|
|
.Where(mp => planIds.Contains(mp.MealPlanId))
|
|
.ToDictionaryAsync(mp => mp.MealPlanId, mp => (string?)mp.Name, ct);
|
|
var missing = planIds.Except(central.Keys).ToList();
|
|
if (missing.Count > 0)
|
|
{
|
|
var tenant = await _dbContext.MealPlans
|
|
.AsNoTracking()
|
|
.Where(mp => missing.Contains(mp.MealPlanId))
|
|
.ToDictionaryAsync(mp => mp.MealPlanId, mp => (string?)mp.Name, ct);
|
|
foreach (var kv in tenant)
|
|
central[kv.Key] = kv.Value;
|
|
}
|
|
return central;
|
|
}
|
|
|
|
public async Task<CustomerHealth?> GetCustomerHealthAsync(Guid customerId, CancellationToken ct = default)
|
|
{
|
|
return await _central.CustomerHealth
|
|
.AsNoTracking()
|
|
.Where(h => h.FKCusId == customerId && h.IsActive)
|
|
.OrderByDescending(h => h.CreatedAt)
|
|
.FirstOrDefaultAsync(ct);
|
|
}
|
|
|
|
// Client Package operations
|
|
public async Task<List<ClientPackage>> GetPackageListAsync(CancellationToken ct = default)
|
|
{
|
|
return await _central.ClientPackages
|
|
.AsNoTracking()
|
|
.Where(cp => cp.IsActive)
|
|
.OrderBy(cp => cp.PackageName)
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
public async Task<ClientPackage?> GetPackageByIdAsync(Guid id, CancellationToken ct = default)
|
|
{
|
|
return await _central.ClientPackages
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(cp => cp.ClientPackageId == id && cp.IsActive, ct);
|
|
}
|
|
|
|
public async Task<List<ClientBusiness>> GetActiveGymsAsync(CancellationToken ct = default)
|
|
{
|
|
return await _central.ClientBusiness
|
|
.Where(x => x.IsActive == true
|
|
&& x.Latitude != null
|
|
&& x.Longitude != null)
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
public async Task<List<WorkoutPlanDto>> GetWorkoutPlansByClientIdAsync(Guid clientId, CancellationToken ct = default)
|
|
{
|
|
return await _dbContext.WorkoutPlans
|
|
.AsNoTracking()
|
|
.Include(wp => wp.WorkoutCategory)
|
|
.Include(wp => wp.CustomerWorkouts.Where(cw => cw.IsActive))
|
|
.ThenInclude(cw => cw.TenantCustomerLink)
|
|
.Where(wp => wp.IsActive && 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 = wp.WorkoutCategory != null ? wp.WorkoutCategory.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);
|
|
}
|
|
|
|
public async Task<WorkoutPlanDetailDto?> GetWorkoutPlanByIdForClientAsync(Guid workoutPlanId, Guid clientId, CancellationToken ct = default)
|
|
{
|
|
var workoutPlan = await _dbContext.WorkoutPlans
|
|
.AsNoTracking()
|
|
.Include(wp => wp.WorkoutCategory)
|
|
.Include(wp => wp.CustomerWorkouts.Where(cw => cw.IsActive))
|
|
.ThenInclude(cw => cw.TenantCustomerLink)
|
|
.Include(wp => wp.WorkoutItems)
|
|
.FirstOrDefaultAsync(wp => wp.WorkoutPlanId == workoutPlanId && wp.FKClientId == clientId && wp.IsActive, ct);
|
|
|
|
if (workoutPlan == null)
|
|
return null;
|
|
|
|
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(wi => 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
|
|
})
|
|
.ToList(),
|
|
AssignedCustomers = workoutPlan.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()
|
|
};
|
|
}
|
|
|
|
public async Task<List<MealPlanDto>> GetMealPlansByClientIdAsync(Guid clientId, CancellationToken ct = default)
|
|
{
|
|
return await _dbContext.MealPlans
|
|
.AsNoTracking()
|
|
.Include(mp => mp.MealCategory)
|
|
.Include(mp => mp.CustomerMeals.Where(cm => cm.IsActive))
|
|
.ThenInclude(cm => cm.TenantCustomerLink)
|
|
.Where(mp => mp.IsActive && 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 = mp.MealCategory != null ? mp.MealCategory.Name : "",
|
|
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);
|
|
}
|
|
|
|
public async Task<MealPlanDetailDto?> GetMealPlanByIdForClientAsync(Guid mealPlanId, Guid clientId, CancellationToken ct = default)
|
|
{
|
|
var mealPlan = await _dbContext.MealPlans
|
|
.AsNoTracking()
|
|
.Include(mp => mp.MealCategory)
|
|
.Include(mp => mp.CustomerMeals.Where(cm => cm.IsActive))
|
|
.ThenInclude(cm => cm.TenantCustomerLink)
|
|
.Include(mp => mp.MealItems)
|
|
.FirstOrDefaultAsync(mp => mp.MealPlanId == mealPlanId && mp.FKClientId == clientId && mp.IsActive, ct);
|
|
|
|
if (mealPlan == null)
|
|
return null;
|
|
|
|
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 = mealPlan.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(),
|
|
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<CustomerCards>> GetCardsByClientIdAsync(Guid clientId, CancellationToken ct = default)
|
|
{
|
|
return await _dbContext.CustomerCards
|
|
.AsNoTracking()
|
|
.Where(c => c.FK_ClientId == clientId && c.IsActive)
|
|
.ToListAsync(ct);
|
|
}
|
|
|
|
// Scoped to a single member, and to the caller's own gym — used by the dashboard to show
|
|
// whether a member has a consented card before offering the auto-charge toggle.
|
|
public async Task<List<CustomerCards>> GetCardsByCustomerForClientAsync(Guid customerId, Guid clientId, CancellationToken ct = default)
|
|
{
|
|
return await _dbContext.CustomerCards
|
|
.AsNoTracking()
|
|
.Where(c => c.FK_CustomerId == customerId && c.FK_ClientId == clientId && c.IsActive)
|
|
.ToListAsync(ct);
|
|
}
|
|
}
|
|
}
|