first commit

This commit is contained in:
Dhananjaya99
2026-09-28 11:44:26 +05:30
parent 721743ae6b
commit bffc9b1686
424 changed files with 46474 additions and 0 deletions
+487
View File
@@ -0,0 +1,487 @@
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class AccessDeviceRepo : IAccessDeviceRepo
{
private readonly TenantDbContext _dbContext;
private readonly CentralDbContext _central;
public AccessDeviceRepo(TenantDbContext dbContext, CentralDbContext central)
{
_dbContext = dbContext;
_central = central;
}
public async Task<object> AddDeviceAsync(AccessDevice device)
{
var result = await _dbContext.AddAsync(device);
return result;
}
public async Task<AccessDevice> RemoveDeviceAsync(Guid clientId)
{
AccessDevice? device = await _dbContext.AccessDevices
.Where(ad => ad.FKClientId == clientId)
.FirstOrDefaultAsync();
if (device == null)
throw new NotFoundException($"{clientId} don't have a Device in the System");
device.IsActive = false;
_dbContext.Update(device);
return device;
}
public async Task<AccessDevice> GetDeviceByDId(Guid deviceId)
{
AccessDevice? device = await _dbContext.AccessDevices
.Where(d => d.DeviceId == deviceId && d.IsActive == true)
.FirstOrDefaultAsync();
if (device == null)
throw new NotFoundException($"{deviceId} is not found");
return device;
}
public Task<AccessDevice?> GetDeviceByIdAsync(
Guid deviceId,
CancellationToken ct = default)
{
return _dbContext.AccessDevices
.FirstOrDefaultAsync(device => device.DeviceId == deviceId, ct);
}
public Task<AccessDevice?> GetDeviceByHardwareIdAsync(
Guid clientId,
string hardwareId,
CancellationToken ct = default)
{
return _dbContext.AccessDevices.FirstOrDefaultAsync(device =>
device.FKClientId == clientId &&
device.HardwareId == hardwareId, ct);
}
public Task<AccessDevice?> GetActiveDeviceForClientAsync(
Guid clientId,
Guid deviceId,
CancellationToken ct = default)
{
return _dbContext.AccessDevices.FirstOrDefaultAsync(device =>
device.DeviceId == deviceId &&
device.FKClientId == clientId &&
device.IsActive, ct);
}
public Task<List<AccessDevice>> GetAgentDevicesAsync(
Guid clientId,
Guid agentId,
IReadOnlyCollection<Guid> deviceIds,
CancellationToken ct = default)
{
return _dbContext.AccessDevices
.Where(device =>
device.FKClientId == clientId &&
device.FKAgentId == agentId &&
deviceIds.Contains(device.DeviceId))
.ToListAsync(ct);
}
public async Task<AccessDevice> GetDeviceByCId(Guid clientId)
{
AccessDevice? device = await _dbContext.AccessDevices
.Where(d => d.FKClientId == clientId && d.IsActive == true)
.FirstOrDefaultAsync();
if (device == null)
throw new NotFoundException($"{clientId} don't have a device in the system");
return device;
}
public async Task<CustomerAccess> AddCustomerAccessAsync(CustomerAccess customerAccess)
{
await _dbContext.CustomerAccess.AddAsync(customerAccess);
return customerAccess;
}
public async Task<CustomerAccess> GetByIdAsync(int deviceUserId)
{
CustomerAccess? record = await _dbContext.CustomerAccess
.FindAsync(deviceUserId);
if (record == null)
throw new NotFoundException($"{deviceUserId} cannot find in the system");
return record;
}
// Maps a device's employeeNo (== CustomerAccess.Id) back to its CustomerAccess,
// scoped to the device so the same employeeNo on a different panel can't collide.
// Returns null (rather than throwing) when there is no mapping for this device user.
public async Task<CustomerAccess?> GetByDeviceAndUserIdAsync(Guid deviceId, int deviceUserId)
{
return await _dbContext.CustomerAccess
.FirstOrDefaultAsync(c => c.FKDeviceId == deviceId && c.Id == deviceUserId);
}
public async Task<CustomerAccess> GetByCusGuidAsync(Guid CusId)
{
// No IsActive filter here: this is the lookup WhiteListMember/BlackListMember use
// to find the record whose IsActive flag they're about to flip. Filtering on
// IsActive == true made it impossible to ever find (and re-activate) a customer
// who had already been locked, since locking sets IsActive = false.
CustomerAccess? record = await _dbContext.CustomerAccess
.Where(c => c.FKCustomerId == CusId)
.FirstOrDefaultAsync();
if (record == null)
throw new NotFoundException($"record cannot find in the system");
return record;
}
public async Task<CustomerAccess> UpdateAsync(CustomerAccess customerAccess)
{
_dbContext.CustomerAccess.Update(customerAccess);
return customerAccess;
}
public async Task<List<AccessDevice>> GetDevicesByClientAsync(Guid clientId)
{
return await _dbContext.AccessDevices
.AsNoTracking()
.Where(d => d.FKClientId == clientId && d.IsActive == true)
.ToListAsync();
}
public async Task<bool> DeviceBelongsToClientAsync(Guid deviceId, Guid clientId)
{
return await _dbContext.AccessDevices
.AnyAsync(d => d.DeviceId == deviceId && d.FKClientId == clientId);
}
public Task<Customer?> GetCustomerByIdAsync(
Guid customerId,
CancellationToken ct = default)
{
return _central.Customer
.FirstOrDefaultAsync(customer => customer.CusId == customerId, ct);
}
public async Task<Staff?> GetStaffByIdAsync(
Guid clientId,
Guid staffId,
CancellationToken ct = default)
{
var staff = await _dbContext.Staff
.AsNoTracking()
.FirstOrDefaultAsync(s =>
s.StaffId == staffId &&
s.FKClientId == clientId, ct);
if (staff == null)
return null;
// Staff.User is central-bucket -- attach it in memory so callers reading
// staff.User.* still see the right data.
staff.User = await _central.Users.AsNoTracking().FirstOrDefaultAsync(u => u.UserId == staff.FKUserId, ct);
return staff;
}
// Access on a shared plan is all-or-nothing: the subscription is the unit that was
// paid for, so locking one person on a couple/group plan has to take their co-members
// with it, or the locked member simply walks in on their partner's fingerprint.
public async Task<List<Guid>> GetCoMembersOnSharedSubscriptionsAsync(
Guid clientId,
Guid customerId,
CancellationToken ct = default)
{
var sharedSubscriptionIds = await _dbContext.SubscriptionMembers
.AsNoTracking()
.Where(member =>
member.FKCustomerId == customerId &&
member.IsActive &&
member.Subscription.IsActive &&
member.Subscription.CustomerPlan.FKClientId == clientId &&
member.Subscription.CustomerPlan.PlanScope == "Group")
.Select(member => member.FKSubscriptionId)
.Distinct()
.ToListAsync(ct);
if (sharedSubscriptionIds.Count == 0)
return new List<Guid>();
return await _dbContext.SubscriptionMembers
.AsNoTracking()
.Where(member =>
sharedSubscriptionIds.Contains(member.FKSubscriptionId) &&
member.FKCustomerId != customerId &&
member.IsActive)
.Select(member => member.FKCustomerId)
.Distinct()
.ToListAsync(ct);
}
public Task<bool> IsActiveCustomerMemberAsync(
Guid clientId,
Guid customerId,
CancellationToken ct = default)
{
return _dbContext.SubscriptionMembers.AnyAsync(member =>
member.FKCustomerId == customerId &&
member.IsActive &&
member.Subscription.IsActive &&
member.Subscription.CustomerPlan.FKClientId == clientId, ct);
}
public async Task<Guid?> GetLatestActiveClientIdForCustomerAsync(
Guid customerId,
CancellationToken ct = default)
{
var clientId = await _dbContext.SubscriptionMembers
.Where(member =>
member.FKCustomerId == customerId &&
member.IsActive &&
member.Subscription.IsActive)
.OrderByDescending(member => member.Subscription.OnboardDate)
.Select(member => member.Subscription.CustomerPlan.FKClientId)
.FirstOrDefaultAsync(ct);
return clientId == Guid.Empty ? null : clientId;
}
public Task<AccessProfile?> GetAccessProfileAsync(
Guid clientId,
Guid userId,
CancellationToken ct = default)
{
return _dbContext.AccessProfiles
.Include(profile => profile.DeviceBindings)
.FirstOrDefaultAsync(profile =>
profile.FKClientId == clientId &&
profile.FKUserId == userId, ct);
}
public Task<AccessProfile?> GetAccessStatusProfileAsync(
Guid clientId,
Guid userId,
CancellationToken ct = default)
{
return _dbContext.AccessProfiles
.AsNoTracking()
.Include(profile => profile.DeviceBindings)
.ThenInclude(binding => binding.AccessDevice)
.FirstOrDefaultAsync(profile =>
profile.FKClientId == clientId &&
profile.FKUserId == userId, ct);
}
public Task<List<AccessProfile>> GetAccessProfilesByClientAsync(
Guid clientId,
CancellationToken ct = default)
{
return _dbContext.AccessProfiles
.Include(profile => profile.DeviceBindings)
.Where(profile => profile.FKClientId == clientId)
.ToListAsync(ct);
}
public async Task AddAccessProfileAsync(
AccessProfile profile,
CancellationToken ct = default)
{
await _dbContext.AccessProfiles.AddAsync(profile, ct);
}
public Task<AccessDeviceBinding?> GetBindingAsync(
long accessProfileId,
Guid deviceId,
CancellationToken ct = default)
{
return _dbContext.AccessDeviceBindings.FirstOrDefaultAsync(binding =>
binding.FKAccessProfileId == accessProfileId &&
binding.FKDeviceId == deviceId, ct);
}
public Task<AccessDeviceBinding?> GetBindingByEmployeeNoAsync(
Guid deviceId,
string employeeNo,
CancellationToken ct = default)
{
return _dbContext.AccessDeviceBindings
.Include(binding => binding.AccessProfile)
.FirstOrDefaultAsync(binding =>
binding.FKDeviceId == deviceId &&
binding.DeviceEmployeeNo == employeeNo, ct);
}
public Task<List<AccessDeviceBinding>> GetBindingsForDeviceAsync(
Guid deviceId,
CancellationToken ct = default)
{
return _dbContext.AccessDeviceBindings
.Include(binding => binding.AccessProfile)
.Where(binding => binding.FKDeviceId == deviceId)
.ToListAsync(ct);
}
public async Task AddBindingAsync(
AccessDeviceBinding binding,
CancellationToken ct = default)
{
await _dbContext.AccessDeviceBindings.AddAsync(binding, ct);
}
public Task<bool> IsDeviceEmployeeNoOccupiedAsync(
Guid deviceId,
string employeeNo,
Guid exceptUserId,
CancellationToken ct = default)
{
return _dbContext.AccessDeviceBindings.AnyAsync(binding =>
binding.FKDeviceId == deviceId &&
binding.DeviceEmployeeNo == employeeNo &&
binding.AccessProfile.FKUserId != exceptUserId, ct);
}
public Task<List<UnlinkedDeviceEnrollment>> GetDuplicateUnlinkedEnrollmentsAsync(
Guid deviceId,
string employeeNo,
int deviceUserId,
CancellationToken ct = default)
{
return _dbContext.UnlinkedDeviceEnrollments
.Where(enrollment =>
enrollment.FKDeviceId == deviceId &&
enrollment.LinkedAt == null &&
(enrollment.DeviceEmployeeNo == employeeNo ||
(enrollment.DeviceEmployeeNo == "" &&
enrollment.DeviceUserId == deviceUserId)))
.ToListAsync(ct);
}
public Task<UnlinkedDeviceEnrollment?> GetUnlinkedEnrollmentByEmployeeNoAsync(
Guid deviceId,
string employeeNo,
CancellationToken ct = default)
{
var tracked = _dbContext.UnlinkedDeviceEnrollments.Local
.FirstOrDefault(enrollment =>
enrollment.FKDeviceId == deviceId &&
enrollment.DeviceEmployeeNo == employeeNo &&
enrollment.LinkedAt == null);
return tracked != null
? Task.FromResult<UnlinkedDeviceEnrollment?>(tracked)
: _dbContext.UnlinkedDeviceEnrollments.FirstOrDefaultAsync(enrollment =>
enrollment.FKDeviceId == deviceId &&
enrollment.DeviceEmployeeNo == employeeNo &&
enrollment.LinkedAt == null, ct);
}
public async Task AddUnlinkedEnrollmentAsync(
UnlinkedDeviceEnrollment enrollment,
CancellationToken ct = default)
{
await _dbContext.UnlinkedDeviceEnrollments.AddAsync(enrollment, ct);
}
public async Task<Guid?> GetCustomerIdForUserAtClientAsync(
Guid userId,
Guid clientId,
CancellationToken ct = default)
{
// Central lookup for the customer identity, then a tenant-scoped membership
// check -- these used to be a single cross-table query before the split.
var cusId = await _central.Customer
.Where(customer => customer.FKUserId == userId)
.Select(customer => (Guid?)customer.CusId)
.FirstOrDefaultAsync(ct);
if (cusId == null)
return null;
var belongs = await _dbContext.SubscriptionMembers.AnyAsync(member =>
member.FKCustomerId == cusId.Value &&
member.IsActive &&
member.Subscription.IsActive &&
member.Subscription.CustomerPlan.FKClientId == clientId, ct);
return belongs ? cusId : null;
}
public Task<bool> IsDeviceCommandableAsync(
Guid deviceId,
CancellationToken ct = default)
{
return _dbContext.AccessDevices.AnyAsync(device =>
device.DeviceId == deviceId &&
device.IsActive &&
device.FKAgentId != null, ct);
}
public async Task UpsertUnlinkedEnrollmentAsync(Guid deviceId, int deviceUserId, string? reportedName)
{
// Check already-tracked-but-unsaved entities first: a single attendance-ingest
// batch can carry several events for the same not-yet-linked device user (e.g.
// multiple punches since the last sync), and SaveChanges only runs once after the
// whole batch. A DB-only lookup wouldn't see an earlier Add() in this same batch
// and would insert a duplicate row per repeated punch.
var existing = _dbContext.UnlinkedDeviceEnrollments.Local
.FirstOrDefault(e => e.FKDeviceId == deviceId && e.DeviceUserId == deviceUserId && e.LinkedAt == null)
?? await _dbContext.UnlinkedDeviceEnrollments
.FirstOrDefaultAsync(e => e.FKDeviceId == deviceId && e.DeviceUserId == deviceUserId && e.LinkedAt == null);
if (existing != null)
{
if (!string.IsNullOrWhiteSpace(reportedName))
existing.DeviceReportedName = reportedName;
return;
}
await _dbContext.UnlinkedDeviceEnrollments.AddAsync(new UnlinkedDeviceEnrollment
{
Id = Guid.NewGuid(),
FKDeviceId = deviceId,
DeviceUserId = deviceUserId,
DeviceReportedName = reportedName,
DiscoveredAt = DateTime.UtcNow
});
}
public async Task<List<UnlinkedDeviceEnrollment>> GetUnlinkedEnrollmentsByClientAsync(Guid clientId)
{
var enrollments = await _dbContext.UnlinkedDeviceEnrollments
.AsNoTracking()
.Include(e => e.AccessDevice)
.Where(e => e.LinkedAt == null && e.AccessDevice.FKClientId == clientId)
.OrderByDescending(e => e.DiscoveredAt)
.ToListAsync();
// Collapse any duplicate rows already sitting in the table (from before the
// upsert dedup fix above) so the same physical fingerprint doesn't show up
// multiple times in the link picker; keep the most recently discovered one.
return enrollments
.GroupBy(e => (
e.FKDeviceId,
EmployeeNo: string.IsNullOrWhiteSpace(e.DeviceEmployeeNo)
? e.DeviceUserId.ToString()
: e.DeviceEmployeeNo))
.Select(g => g.First())
.ToList();
}
public async Task<UnlinkedDeviceEnrollment?> GetUnlinkedEnrollmentByIdAsync(Guid id)
{
return await _dbContext.UnlinkedDeviceEnrollments
.Include(e => e.AccessDevice)
.FirstOrDefaultAsync(e => e.Id == id);
}
public async Task<bool> CustomerAccessIdExistsAsync(int id)
{
return await _dbContext.CustomerAccess.AnyAsync(c => c.Id == id);
}
}
}
+88
View File
@@ -0,0 +1,88 @@
using Fitness_core_BE.Models;
using Fitness_core_BE.Interfaces;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class ActivityLogRepository : IActivityLogRepository
{
private readonly CentralDbContext _dbContext;
public ActivityLogRepository(CentralDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task AddActivityLogAsync(ActivityLog log, CancellationToken ct = default)
{
try
{
_dbContext.ActivityLogs.Add(log);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to add activity log: {ex.Message}");
}
}
public async Task<List<ActivityLog>> GetActivityLogsAsync(
string searchTerm = null,
DateTime? startDate = null,
DateTime? endDate = null,
CancellationToken ct = default)
{
try
{
var query = _dbContext.ActivityLogs
.AsNoTracking()
.Include(a => a.User)
.AsQueryable();
// Apply search filter
if (!string.IsNullOrEmpty(searchTerm))
{
query = query.Where(a =>
a.Description.Contains(searchTerm) ||
a.UserName.Contains(searchTerm));
}
// Apply date range filters
if (startDate.HasValue)
{
query = query.Where(a => a.Timestamp >= startDate.Value);
}
if (endDate.HasValue)
{
query = query.Where(a => a.Timestamp <= endDate.Value);
}
return await query
.OrderByDescending(a => a.Timestamp)
.ToListAsync(ct);
}
catch (Exception ex)
{
Console.WriteLine($"Failed to fetch activity logs: {ex.Message}");
return new List<ActivityLog>();
}
}
public async Task<string> GetUserNameAsync(Guid userId, CancellationToken ct = default)
{
try
{
var user = await _dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.UserId == userId, ct);
return user?.UserName ?? "Unknown User";
}
catch (Exception ex)
{
Console.WriteLine($"Failed to fetch username: {ex.Message}");
return "Unknown User";
}
}
}
}
+52
View File
@@ -0,0 +1,52 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class AdminClientDirectoryRepository : IAdminClientDirectoryRepository
{
private readonly CentralDbContext _context;
public AdminClientDirectoryRepository(CentralDbContext context)
{
_context = context;
}
public async Task<(List<Client> Items, int Total)> ListAsync(string? search, int page, int pageSize, CancellationToken ct = default)
{
var query = _context.Client
.AsNoTracking()
.Include(c => c.ClientBusinesses)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(search))
query = query.Where(c => c.ClientName != null && c.ClientName.Contains(search));
var total = await query.CountAsync(ct);
var items = await query
.OrderBy(c => c.ClientName)
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(ct);
return (items, total);
}
public async Task<Client?> GetByIdAsync(Guid clientId, CancellationToken ct = default)
{
return await _context.Client
.AsNoTracking()
.Include(c => c.ClientBusinesses)
.FirstOrDefaultAsync(c => c.ClientId == clientId, ct);
}
public async Task<bool> ClientExistsAsync(Guid clientId, CancellationToken ct = default)
{
return await _context.Client
.AsNoTracking()
.AnyAsync(c => c.ClientId == clientId, ct);
}
}
}
+21
View File
@@ -0,0 +1,21 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class AdminSuggestionRepository : IAdminSuggestionRepository
{
private readonly CentralDbContext _context;
public AdminSuggestionRepository(CentralDbContext context) => _context = context;
public async Task<(List<WorkoutPlan> Workouts, List<MealPlan> Meals)> GetPublishedAsync(CancellationToken ct = default) =>
(await _context.WorkoutPlans.AsNoTracking().Include(x => x.WorkoutCategory).Include(x => x.WorkoutItems).Where(x => x.FKClientId == null && x.IsTemplate && x.IsActive).OrderByDescending(x => x.CreateDate).ToListAsync(ct),
await _context.MealPlans.AsNoTracking().Include(x => x.MealCategory).Include(x => x.MealItems).Where(x => x.FKClientId == null && x.IsTemplate && x.IsActive).OrderByDescending(x => x.CreateDate).ToListAsync(ct));
public Task<WorkoutCategory?> FindWorkoutCategoryAsync(string name, CancellationToken ct = default) => _context.WorkoutCategories.FirstOrDefaultAsync(x => x.WorkoutCategoryName == name, ct);
public Task<MealCategory?> FindMealCategoryAsync(string name, CancellationToken ct = default) => _context.MealCategories.FirstOrDefaultAsync(x => x.Name == name, ct);
public Task AddWorkoutCategoryAsync(WorkoutCategory category, CancellationToken ct = default) => _context.WorkoutCategories.AddAsync(category, ct).AsTask();
public Task AddMealCategoryAsync(MealCategory category, CancellationToken ct = default) => _context.MealCategories.AddAsync(category, ct).AsTask();
public Task AddWorkoutAsync(WorkoutPlan plan, CancellationToken ct = default) => _context.WorkoutPlans.AddAsync(plan, ct).AsTask();
public Task AddMealAsync(MealPlan plan, CancellationToken ct = default) => _context.MealPlans.AddAsync(plan, ct).AsTask();
}
}
+54
View File
@@ -0,0 +1,54 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class AgentRepo : IAgentRepo
{
// Central: an agent authenticates via X-Api-Key alone, before any tenant is known.
// See CentralDbContext.Agents for the full rationale.
private readonly CentralDbContext _dbContext;
public AgentRepo(CentralDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Agent> AddAsync(Agent agent)
{
await _dbContext.Agents.AddAsync(agent);
return agent;
}
public async Task<Agent?> GetByApiKeyHashAsync(string apiKeyHash)
{
return await _dbContext.Agents
.FirstOrDefaultAsync(a => a.ApiKeyHash == apiKeyHash && a.IsActive == true);
}
public async Task<Agent?> GetByIdAsync(Guid agentId)
{
return await _dbContext.Agents
.FirstOrDefaultAsync(a => a.AgentId == agentId);
}
public async Task<List<Agent>> GetByClientIdAsync(Guid clientId)
{
return await _dbContext.Agents
.AsNoTracking()
.Where(a => a.FKClientId == clientId)
.OrderByDescending(a => a.CreatedAt)
.ToListAsync();
}
public async Task UpdateLastSeenAsync(Guid agentId, DateTime seenAt)
{
var agent = await _dbContext.Agents.FirstOrDefaultAsync(a => a.AgentId == agentId);
if (agent == null)
return;
agent.LastSeenAt = seenAt;
}
}
}
+191
View File
@@ -0,0 +1,191 @@
using Fitness_core_BE.DTOs.Attendance;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class AttendanceRepository : IAttendanceRepository
{
private readonly TenantDbContext _dbContext;
public AttendanceRepository(TenantDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Attendance?> GetLatestAttendanceAsync(Guid clientId)
{
return await _dbContext.Attendances
.AsNoTracking()
.Where(a => a.FKClientId == clientId)
.OrderByDescending(a => a.EventTime)
.ThenByDescending(a => a.AttendanceId)
.FirstOrDefaultAsync();
}
public async Task<HashSet<string>> GetAttendanceKeysAsync(Guid clientId, DateTime fromTime)
{
var keys = await _dbContext.Attendances
.AsNoTracking()
.Where(a => a.FKClientId == clientId && a.EventTime >= fromTime)
.Select(a => new
{
a.FKDeviceId,
a.FKUserId,
a.FKCustomerId,
a.DeviceUserId,
a.DeviceEmployeeNo,
a.EventTime,
a.EventType
})
.ToListAsync();
var set = new HashSet<string>();
foreach (var k in keys)
{
var userId = k.FKUserId ?? k.FKCustomerId ?? Guid.Empty;
var employeeNo = string.IsNullOrWhiteSpace(k.DeviceEmployeeNo)
? k.DeviceUserId.ToString()
: k.DeviceEmployeeNo;
set.Add(BuildKey(
k.FKDeviceId ?? Guid.Empty,
userId,
employeeNo,
k.EventTime,
k.EventType));
}
return set;
}
public async Task<HashSet<string>> GetSourceEventKeysAsync(
IReadOnlyCollection<string> candidateKeys,
CancellationToken ct = default)
{
if (candidateKeys.Count == 0)
return new HashSet<string>();
var keys = await _dbContext.Attendances
.AsNoTracking()
.Where(attendance =>
attendance.SourceEventKey != null &&
candidateKeys.Contains(attendance.SourceEventKey))
.Select(attendance => attendance.SourceEventKey!)
.ToListAsync(ct);
return keys.ToHashSet();
}
public async Task AddAttendancesAsync(IEnumerable<Attendance> attendances)
{
await _dbContext.Attendances.AddRangeAsync(attendances);
}
public async Task<List<Attendance>> GetLatestPerPersonAsync(Guid clientId)
{
return await LatestPerPersonQuery(clientId)
.AsNoTracking()
.Include(a => a.TenantCustomerLink)
.Include(a => a.AccessProfile)
.OrderByDescending(a => a.EventTime)
.ThenByDescending(a => a.CreatedAt)
.ToListAsync();
}
public async Task<List<AttendanceCountDTO>> GetAttendanceCountsAsync(Guid clientId, DateTime fromDate, DateTime toDate)
{
var records = await _dbContext.Attendances
.AsNoTracking()
.Include(a => a.TenantCustomerLink)
.Where(a => a.FKClientId == clientId && a.FKCustomerId != null
&& a.EventTime >= fromDate && a.EventTime <= toDate)
.ToListAsync();
return records
.GroupBy(a => a.FKCustomerId)
.Select(g => new AttendanceCountDTO
{
CustomerId = g.Key!.Value,
CustomerName = g.First().TenantCustomerLink?.DisplayName ?? "Unknown",
AttendanceCount = g.Count()
})
.ToList();
}
public async Task<List<TodayAttendanceDTO>> GetTodayAttendanceAsync(Guid clientId)
{
var todayStart = DateTime.UtcNow.Date;
var todayEnd = todayStart.AddDays(1);
var records = await LatestPerPersonQuery(clientId, todayStart, todayEnd)
.AsNoTracking()
.Include(a => a.TenantCustomerLink)
.Include(a => a.AccessProfile)
.OrderByDescending(a => a.EventTime)
.ThenByDescending(a => a.CreatedAt)
.ToListAsync();
return records.Select(a => new TodayAttendanceDTO
{
CustomerName = a.TenantCustomerLink?.DisplayName
?? a.AccessProfile?.DisplayName
?? "Unknown",
EventTime = a.EventTime,
EventType = a.EventType
}).ToList();
}
private IQueryable<Attendance> LatestPerPersonQuery(
Guid clientId,
DateTime? from = null,
DateTime? to = null)
{
var scoped = _dbContext.Attendances
.Where(a => a.FKClientId == clientId);
if (from.HasValue)
scoped = scoped.Where(a => a.EventTime >= from.Value);
if (to.HasValue)
scoped = scoped.Where(a => a.EventTime < to.Value);
// Raw device events remain available for reports and counts. This
// read query exposes only the newest state for each linked person.
// User/customer matching also bridges legacy rows created before
// AccessProfileId was populated.
return scoped.Where(record =>
!scoped.Any(candidate =>
(
(record.FKUserId.HasValue &&
candidate.FKUserId == record.FKUserId) ||
(record.FKCustomerId.HasValue &&
candidate.FKCustomerId == record.FKCustomerId) ||
(record.FKAccessProfileId.HasValue &&
candidate.FKAccessProfileId == record.FKAccessProfileId) ||
(!record.FKUserId.HasValue &&
!record.FKCustomerId.HasValue &&
!record.FKAccessProfileId.HasValue &&
!candidate.FKUserId.HasValue &&
!candidate.FKCustomerId.HasValue &&
!candidate.FKAccessProfileId.HasValue &&
candidate.FKDeviceId == record.FKDeviceId &&
candidate.DeviceEmployeeNo == record.DeviceEmployeeNo)
) &&
(
candidate.EventTime > record.EventTime ||
(candidate.EventTime == record.EventTime &&
candidate.CreatedAt > record.CreatedAt)
)));
}
private static string BuildKey(
Guid deviceId,
Guid userId,
string deviceEmployeeNo,
DateTime eventTime,
string eventType)
{
return $"{deviceId}|{userId}|{deviceEmployeeNo}|{eventTime:O}|{eventType}";
}
}
}
+54
View File
@@ -0,0 +1,54 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class BookingRepository : IBookingRepository
{
private readonly TenantDbContext _context;
public BookingRepository(TenantDbContext context)
{
_context = context;
}
public async Task<Booking> CreateBooking(Booking booking)
{
booking.BookingId = Guid.NewGuid();
booking.CreatedAt = DateTime.UtcNow;
_context.Bookings.Add(booking);
return booking;
}
public Task<Booking?> GetBookingById(Guid bookingId)
=> _context.Bookings
.Include(b => b.FacilitySession).ThenInclude(s => s.Facility)
.FirstOrDefaultAsync(b => b.BookingId == bookingId);
public Task<Booking?> GetActiveBookingByOtp(string otpCode)
=> _context.Bookings
.Include(b => b.FacilitySession).ThenInclude(s => s.Facility)
.FirstOrDefaultAsync(b => b.OtpCode == otpCode && b.Status == "Confirmed");
public Task<List<Booking>> GetBookingsForSession(Guid facilitySessionId)
=> _context.Bookings
.Where(b => b.FKFacilitySessionId == facilitySessionId)
.OrderBy(b => b.BookedAt)
.ToListAsync();
public Task<List<Booking>> GetBookingsForCustomer(Guid customerId)
=> _context.Bookings
.Include(b => b.FacilitySession).ThenInclude(s => s.Facility)
.Where(b => b.FKCustomerId == customerId)
.OrderByDescending(b => b.BookedAt)
.ToListAsync();
public Task<bool> CustomerHasActiveBookingForSession(Guid facilitySessionId, Guid customerId)
=> _context.Bookings.AnyAsync(b =>
b.FKFacilitySessionId == facilitySessionId &&
b.FKCustomerId == customerId &&
b.Status != "Cancelled");
}
}
+69
View File
@@ -0,0 +1,69 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class ChatRepository : IChatRepo
{
private readonly CentralDbContext _context;
public ChatRepository(CentralDbContext context)
{
_context = context;
}
public async Task<Chat?> GetChatByIdAsync(Guid chatId)
{
return await _context.Chat
.AsNoTracking()
.FirstOrDefaultAsync(c => c.Id == chatId && c.IsAcitve);
}
public async Task<Chat?> GetChatBetweenUsersAsync(Guid userAId, Guid userBId)
{
return await _context.Chat
.AsNoTracking()
.FirstOrDefaultAsync(c =>
c.IsAcitve &&
(
(c.FKSenderId == userAId && c.FKReceiverId == userBId) ||
(c.FKSenderId == userBId && c.FKReceiverId == userAId)
));
}
public async Task CreateChatAsync(Chat chat)
{
await _context.Chat.AddAsync(chat);
}
public async Task AddMessageAsync(ChatMessages message)
{
await _context.ChatMessages.AddAsync(message);
}
public async Task<List<ChatMessages>> GetMessagesAsync(
Guid chatId,
int skip,
int take)
{
return await _context.ChatMessages
.AsNoTracking()
.Where(m => m.FKChatId == chatId && m.IsAcitve)
.OrderBy(m => m.CreatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
}
public async Task<bool> IsUserInChatAsync(Guid chatId, Guid userId)
{
return await _context.Chat
.AsNoTracking()
.AnyAsync(c =>
c.Id == chatId &&
c.IsAcitve &&
(c.FKSenderId == userId || c.FKReceiverId == userId));
}
}
}
+143
View File
@@ -0,0 +1,143 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Fitness_core_BE;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class ClientInvoiceRepository : IClientInvoiceRepository
{
private readonly CentralDbContext _context;
public ClientInvoiceRepository(CentralDbContext context)
{
_context = context;
}
public async Task<ClientInvoice> CreateClientInvoiceFromSubscription(Guid subscriptionId)
{
var subscription = await _context.ClientSubscriptions
.Include(cs => cs.ClientPackage)
.Include(cs => cs.Client)
.ThenInclude(c => c.User)
.FirstOrDefaultAsync(cs => cs.SubscriptionId == subscriptionId);
if (subscription == null)
{
throw new Exception("Subscription not found");
}
var subItems = await _context.ClientSubItems
.Where(si => si.FKClientSubscriptionId == subscriptionId && si.IsActive)
.ToListAsync();
decimal totalAmount = subscription.ClientPackage.PackagePrice;
totalAmount += subItems.Sum(si => si.SubItemPrice);
var clientCard = await _context.ClientCard
.FirstOrDefaultAsync(cc => cc.FK_ClientId == subscription.Client.ClientId);
if (clientCard == null)
{
throw new Exception("Client card not found");
}
var clientInvoice = new ClientInvoice
{
ClientInvoiceId = Guid.NewGuid(),
FKClientSubscriptionId = subscriptionId,
FKUserId = subscription.Client.FKUserId,
FKClientCardId = clientCard.CardId,
Amount = totalAmount,
Status = "Pending",
ClientInvoiceDate = DateTime.UtcNow
};
_context.ClientInvoices.Add(clientInvoice);
var packageItem = new ClientInvoiceItem
{
ClientInvoiceItemId = Guid.NewGuid(),
FKClientInvoiceId = clientInvoice.ClientInvoiceId,
ItemName = subscription.ClientPackage.PackageName,
ItemDescription = subscription.ClientPackage.PackageDescription,
ItemPrice = subscription.ClientPackage.PackagePrice,
CreatedAt = DateTime.UtcNow
};
_context.ClientInvoiceItems.Add(packageItem);
foreach (var subItem in subItems)
{
var clientInvoiceItem = new ClientInvoiceItem
{
ClientInvoiceItemId = Guid.NewGuid(),
FKClientInvoiceId = clientInvoice.ClientInvoiceId,
ItemName = subItem.SubItemName,
ItemDescription = subItem.SubItemDescription,
ItemPrice = subItem.SubItemPrice,
CreatedAt = DateTime.UtcNow
};
_context.ClientInvoiceItems.Add(clientInvoiceItem);
}
return clientInvoice;
}
public async Task<ClientInvoice?> GetClientInvoiceById(Guid clientInvoiceId)
{
return await _context.ClientInvoices
.Include(i => i.ClientSubscription)
.ThenInclude(cs => cs.ClientPackage)
.Include(i => i.ClientSubscription)
.ThenInclude(cs => cs.Client)
.Include(i => i.User)
.Include(i => i.ClientCard)
.Include(i => i.Items)
.FirstOrDefaultAsync(i => i.ClientInvoiceId == clientInvoiceId);
}
public async Task<List<ClientInvoice>> GetClientInvoicesByClientId(Guid clientId)
{
return await _context.ClientInvoices
.Include(i => i.ClientSubscription)
.ThenInclude(cs => cs.ClientPackage)
.Include(i => i.User)
.Include(i => i.ClientCard)
.Include(i => i.Items)
.Where(i => i.ClientSubscription.FKClientId == clientId)
.OrderByDescending(i => i.ClientInvoiceDate)
.ToListAsync();
}
public async Task<List<ClientInvoice>> GetClientInvoicesByUserId(Guid userId)
{
return await _context.ClientInvoices
.Include(i => i.ClientSubscription)
.ThenInclude(cs => cs.ClientPackage)
.Include(i => i.ClientSubscription)
.ThenInclude(cs => cs.Client)
.Include(i => i.ClientCard)
.Include(i => i.Items)
.Where(i => i.FKUserId == userId)
.OrderByDescending(i => i.ClientInvoiceDate)
.ToListAsync();
}
public async Task<ClientInvoice> UpdateClientInvoiceStatus(Guid clientInvoiceId, string status)
{
var clientInvoice = await _context.ClientInvoices.FindAsync(clientInvoiceId);
if (clientInvoice == null)
{
throw new Exception("ClientInvoice not found");
}
clientInvoice.Status = status;
return clientInvoice;
}
}
}
+826
View File
@@ -0,0 +1,826 @@
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);
}
}
}
+330
View File
@@ -0,0 +1,330 @@
using Fitness_core_BE.DTOs.Stats;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class ClientStatRepository : IClientStatRepository
{
private readonly TenantDbContext _dbContext;
public ClientStatRepository(TenantDbContext dbContext)
{
_dbContext = dbContext;
}
// Member counts are driven by SubscriptionMembers so group secondaries
// (couple/family) are counted, not just subscription primaries.
private IQueryable<SubscriptionMember> ActiveMembersForClient(Guid clientId)
{
return _dbContext.SubscriptionMembers
.Where(m => m.Subscription.CustomerPlan.FKClientId == clientId
&& m.Subscription.IsActive
&& m.IsActive
&& m.TenantCustomerLink.IsActiveAtThisClient);
}
public async Task<int> GetTotalCustomerCountAsync(Guid clientId, CancellationToken ct = default)
{
return await ActiveMembersForClient(clientId)
.Select(m => m.FKCustomerId)
.Distinct()
.CountAsync(ct);
}
public async Task<int> GetMonthlyCustomerCountAsync(Guid clientId, CancellationToken ct = default)
{
var startOfMonth = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
return await ActiveMembersForClient(clientId)
.Where(m => m.JoinedAt >= startOfMonth)
.Select(m => m.FKCustomerId)
.Distinct()
.CountAsync(ct);
}
public async Task<int> GetTotalWorkoutPlanCountAsync(Guid clientId, CancellationToken ct = default)
{
return await _dbContext.WorkoutPlans
.Where(w => w.FKClientId == clientId && w.IsActive)
.CountAsync(ct);
}
public async Task<int> GetTotalMealPlanCountAsync(Guid clientId, CancellationToken ct = default)
{
return await _dbContext.MealPlans
.Where(m => m.FKClientId == clientId && m.IsActive)
.CountAsync(ct);
}
public async Task<int> GetLastMonthCustomerCountAsync(Guid clientId, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
var startOfLastMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-1);
var startOfThisMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc);
return await ActiveMembersForClient(clientId)
.Where(m => m.JoinedAt >= startOfLastMonth && m.JoinedAt < startOfThisMonth)
.Select(m => m.FKCustomerId)
.Distinct()
.CountAsync(ct);
}
public async Task<List<Attendance>> GetLastFiveAttendancesAsync(Guid clientId, CancellationToken ct = default)
{
return await _dbContext.Attendances
.Where(a => a.FKClientId == clientId)
.Include(a => a.TenantCustomerLink)
.OrderByDescending(a => a.EventTime)
.Take(5)
.AsNoTracking()
.ToListAsync(ct);
}
public async Task<List<PaymentTransaction>> GetLastFivePaymentsAsync(Guid clientId, CancellationToken ct = default)
{
var payments = await _dbContext.PaymentTransactions
.Where(p => p.FK_ClientId == clientId && p.IsActive == true)
.Include(p => p.CustomerInvoice)
.ThenInclude(ci => ci.CustomerSubscription)
.ThenInclude(cs => cs.TenantCustomerLink)
.OrderByDescending(p => p.PayDate)
.Take(5)
.AsNoTracking()
.ToListAsync(ct);
// PaymentTransaction.Customer isn't a real relationship post-split (Customer
// identity lives in the central DB, this table is tenant-scoped) -- backfill a
// display-only Customer shape from the cached TenantCustomerLink so this response's
// existing customer.cusName shape keeps working for API consumers.
foreach (var payment in payments)
{
var link = payment.CustomerInvoice?.CustomerSubscription?.TenantCustomerLink;
if (link != null)
{
payment.Customer = new Customer
{
CusId = link.TenantCustomerLinkId,
CusName = link.DisplayName,
PrimaryPH = link.PrimaryPhoneCached,
Email = link.EmailCached
};
}
}
return payments;
}
// Revenue counts only approved payments (owner-recorded payments are approved
// on creation; pending online attempts are excluded).
public async Task<decimal> GetLastMonthRevenueAsync(Guid clientId, CancellationToken ct = default)
{
var now = DateTime.UtcNow;
var startOfLastMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-1);
var startOfThisMonth = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc);
return await _dbContext.PaymentTransactions
.Where(p => p.FK_ClientId == clientId
&& p.IsActive == true
&& p.IsApproved == true
&& p.PayDate >= startOfLastMonth
&& p.PayDate < startOfThisMonth)
.SumAsync(p => p.PayAmount, ct);
}
public async Task<List<MonthlyRevenueStat>> GetMonthlyRevenueVariationAsync(
Guid clientId, DateTime? from = null, DateTime? to = null, CancellationToken ct = default)
{
DateTime rangeStart;
DateTime rangeEndExclusive;
bool byHour;
bool byDay;
if (from.HasValue && to.HasValue)
{
rangeStart = DateTime.SpecifyKind(from.Value.Date, DateTimeKind.Utc);
rangeEndExclusive = DateTime.SpecifyKind(to.Value.Date.AddDays(1), DateTimeKind.Utc);
var totalDays = (rangeEndExclusive - rangeStart).TotalDays;
// A single-day range (Today/Yesterday) would otherwise collapse to one bucket,
// so break it down by hour instead so the chart still shows a trend.
byHour = totalDays <= 1;
byDay = !byHour && totalDays <= 31;
}
else
{
// Default (no explicit range): trailing 10 calendar months, unchanged from before.
var now = DateTime.UtcNow;
rangeStart = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(-9);
rangeEndExclusive = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc).AddMonths(1);
byHour = false;
byDay = false;
}
DateTime BucketKey(DateTime d) =>
byHour ? new DateTime(d.Year, d.Month, d.Day, d.Hour, 0, 0, DateTimeKind.Utc)
: byDay ? d.Date
: new DateTime(d.Year, d.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var payments = await _dbContext.PaymentTransactions
.Where(p => p.FK_ClientId == clientId
&& p.IsActive == true
&& p.IsApproved == true
&& p.PayDate >= rangeStart
&& p.PayDate < rangeEndExclusive)
.Select(p => new { p.PayDate, p.PayAmount })
.AsNoTracking()
.ToListAsync(ct);
var newMembers = await ActiveMembersForClient(clientId)
.Where(m => m.JoinedAt >= rangeStart && m.JoinedAt < rangeEndExclusive)
.Select(m => new { m.JoinedAt, m.FKCustomerId })
.AsNoTracking()
.ToListAsync(ct);
var revenueByBucket = payments
.GroupBy(p => BucketKey(p.PayDate!.Value))
.ToDictionary(g => g.Key, g => g.Sum(p => p.PayAmount));
var newMembersByBucket = newMembers
.GroupBy(m => BucketKey(m.JoinedAt))
.ToDictionary(g => g.Key, g => g.Select(m => m.FKCustomerId).Distinct().Count());
var buckets = new List<DateTime>();
for (var d = rangeStart; d < rangeEndExclusive; d = byHour ? d.AddHours(1) : byDay ? d.AddDays(1) : d.AddMonths(1))
buckets.Add(d);
return buckets
.Select(bucket => new MonthlyRevenueStat
{
Year = bucket.Year,
Month = bucket.Month,
MonthLabel = byHour ? bucket.ToString("h tt") : byDay ? bucket.ToString("MMM d") : bucket.ToString("MMM yyyy"),
Revenue = revenueByBucket.TryGetValue(bucket, out var rev) ? rev : 0m,
NewMemberCount = newMembersByBucket.TryGetValue(bucket, out var cnt) ? cnt : 0
})
.ToList();
}
public async Task<int> GetExpiringSoonCountAsync(Guid clientId, DateTime from, DateTime to, CancellationToken ct = default)
{
return await ActiveMembersForClient(clientId)
.Where(m => m.Subscription.RenewalDate != null
&& m.Subscription.RenewalDate >= from
&& m.Subscription.RenewalDate <= to)
.Select(m => m.FKCustomerId)
.Distinct()
.CountAsync(ct);
}
public async Task<decimal> GetTotalRevenueAsync(Guid clientId, CancellationToken ct = default)
{
return await _dbContext.PaymentTransactions
.Where(p => p.FK_ClientId == clientId && p.IsActive == true && p.IsApproved == true)
.SumAsync(p => p.PayAmount, ct);
}
public async Task<ClientStatSummaryDto> GetClientStatSummaryAsync(
Guid clientId, DateTime? from = null, DateTime? to = null, CancellationToken ct = default)
{
DateTime periodStart;
DateTime periodEndExclusive;
DateTime comparisonStart;
if (from.HasValue && to.HasValue)
{
periodStart = DateTime.SpecifyKind(from.Value.Date, DateTimeKind.Utc);
periodEndExclusive = DateTime.SpecifyKind(to.Value.Date.AddDays(1), DateTimeKind.Utc);
// Equal-length window immediately preceding the selected period, for growth %.
comparisonStart = periodStart - (periodEndExclusive - periodStart);
}
else
{
var now = DateTime.UtcNow;
periodStart = new DateTime(now.Year, now.Month, 1, 0, 0, 0, DateTimeKind.Utc);
periodEndExclusive = periodStart.AddMonths(1);
comparisonStart = periodStart.AddMonths(-1);
}
int totalCustomers = await ActiveMembersForClient(clientId)
.Select(m => m.FKCustomerId)
.Distinct()
.CountAsync(ct);
int thisPeriodCustomers = await ActiveMembersForClient(clientId)
.Where(m => m.JoinedAt >= periodStart && m.JoinedAt < periodEndExclusive)
.Select(m => m.FKCustomerId)
.Distinct()
.CountAsync(ct);
int lastPeriodCustomers = await ActiveMembersForClient(clientId)
.Where(m => m.JoinedAt >= comparisonStart && m.JoinedAt < periodStart)
.Select(m => m.FKCustomerId)
.Distinct()
.CountAsync(ct);
decimal thisPeriodRevenue = await _dbContext.PaymentTransactions
.Where(p => p.FK_ClientId == clientId &&
p.IsActive == true &&
p.IsApproved == true &&
p.PayDate >= periodStart &&
p.PayDate < periodEndExclusive)
.SumAsync(p => p.PayAmount, ct);
decimal lastPeriodRevenue = await _dbContext.PaymentTransactions
.Where(p => p.FK_ClientId == clientId &&
p.IsActive == true &&
p.IsApproved == true &&
p.PayDate >= comparisonStart &&
p.PayDate < periodStart)
.SumAsync(p => p.PayAmount, ct);
decimal totalRevenue = await _dbContext.PaymentTransactions
.Where(p => p.FK_ClientId == clientId &&
p.IsActive == true &&
p.IsApproved == true)
.SumAsync(p => p.PayAmount, ct);
decimal customerGrowthPct = lastPeriodCustomers == 0
? (thisPeriodCustomers > 0 ? 100m : 0m)
: Math.Round((decimal)(thisPeriodCustomers - lastPeriodCustomers)
/ lastPeriodCustomers * 100, 2);
decimal revenueGrowthPct = lastPeriodRevenue == 0
? (thisPeriodRevenue > 0 ? 100m : 0m)
: Math.Round((thisPeriodRevenue - lastPeriodRevenue)
/ lastPeriodRevenue * 100, 2);
// OverdueCount is filled in by ClientStatService (needs IOverdueLockRepository).
var expiryWindowStart = DateTime.UtcNow;
int expiringSoon = await GetExpiringSoonCountAsync(clientId, expiryWindowStart, expiryWindowStart.AddDays(7), ct);
// "Today" always means today regardless of the from/to period filter above --
// matches the AttendanceRepository convention (DateTime.UtcNow.Date), not the
// selected reporting window.
var todayStart = DateTime.UtcNow.Date;
var todayEndExclusive = todayStart.AddDays(1);
var todayPayments = await _dbContext.PaymentTransactions
.Where(p => p.FK_ClientId == clientId &&
p.IsActive == true &&
p.IsApproved == true &&
p.PayDate >= todayStart &&
p.PayDate < todayEndExclusive)
.ToListAsync(ct);
return new ClientStatSummaryDto
{
TotalCustomerCount = totalCustomers,
ThisMonthCustomerCount = thisPeriodCustomers,
LastMonthCustomerCount = lastPeriodCustomers,
CustomerGrowthPercentage = customerGrowthPct,
ThisMonthRevenue = thisPeriodRevenue,
LastMonthRevenue = lastPeriodRevenue,
RevenueGrowthPercentage = revenueGrowthPct,
TotalRevenue = totalRevenue,
ExpiringWithin7DaysCount = expiringSoon,
TodayPaymentsCount = todayPayments.Count,
TodayPaymentsTotal = todayPayments.Sum(p => p.PayAmount)
};
}
}
}
+569
View File
@@ -0,0 +1,569 @@
using Fitness_core_BE.Domain;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Fitness_core_BE;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class CustomerInvoiceRepository(TenantDbContext context) : ICustomerInvoiceRepository
{
// Shared with BuildAndAddInvoice (writes it), RepointAndRecalculateInvoiceAsync (preserves
// it by name) and GetCarriedForwardAmountsAsync (reads it) -- one constant so the three
// sites can't drift apart.
private const string PreviousBalanceItemName = "Previous Balance";
private readonly TenantDbContext _context = context;
public async Task<CustomerInvoice> CreateCustomerInvoiceFromSubscription(
Guid subscriptionId, bool chargePayment = true, decimal carriedOverBalance = 0m, bool includeOnboardingFee = true)
{
// Idempotency: invoices are now created automatically at subscription time,
// so a second call (e.g. via the legacy function-handler route) must not duplicate.
// Not used by the daily renewal sweep (CreateRenewalInvoiceAsync) -- under the
// renewal-approval model the same subscription legitimately gets a new invoice every
// cycle, which this guard would otherwise block. Credit/Applied don't count as "the"
// invoice either -- a Credit-only subscription (e.g. right after a downgrade) still
// needs a real invoice built the next time one is due, which is what actually consumes
// that open credit via ConsumeOpenCreditAsync inside BuildAndAddInvoice.
var existingInvoice = await _context.CustomerInvoices
.Where(i => i.FKCustomerSubscriptionId == subscriptionId
&& i.Status != "Cancelled" && i.Status != "RolledOver"
&& i.Status != "Credit" && i.Status != "Applied")
.OrderByDescending(i => i.CustomerInvoiceDate)
.FirstOrDefaultAsync();
if (existingInvoice != null)
{
return existingInvoice;
}
var subscription = await LoadSubscriptionForInvoiceAsync(subscriptionId);
var subItems = await LoadActiveSubItemsAsync(subscriptionId);
// Only consume credit when there's actually something to apply it to -- a
// chargePayment=false invoice is already Rs. 0, so burning the customer's open credit
// on it would just destroy that value instead of applying it to anything.
var openCredit = chargePayment
? await ConsumeOpenCreditAsync(subscription.TenantCustomerLink.CentralUserId, subscription.CustomerPlan.FKClientId)
: 0m;
return BuildAndAddInvoice(
subscription, subItems, chargePayment, carriedOverBalance, openCredit, includeOnboardingFee, isActive: true);
}
// Guard-free sibling of CreateCustomerInvoiceFromSubscription for the daily renewal
// sweep: unlike a normal subscription (one invoice, created once), the same
// CustomerSubscription now persists across many renewal cycles and legitimately gets a
// new invoice each time, so the "an invoice already exists" idempotency guard above does
// not apply here. Duplicate-renewal protection instead lives in the renewal candidate
// query itself (SubscriptionRenewalRunner excludes subscriptions with an
// already-pending, unapproved invoice). Renewals never re-bill the one-time onboarding
// fee, and the new invoice starts IsActive=false pending staff approval.
public async Task<CustomerInvoice> CreateRenewalInvoiceAsync(
Guid subscriptionId, decimal carriedOverBalance, CancellationToken ct = default)
{
var subscription = await LoadSubscriptionForInvoiceAsync(subscriptionId, ct);
var subItems = await LoadActiveSubItemsAsync(subscriptionId, ct);
var openCredit = await ConsumeOpenCreditAsync(
subscription.TenantCustomerLink.CentralUserId, subscription.CustomerPlan.FKClientId, ct);
return BuildAndAddInvoice(
subscription, subItems, chargePayment: true, carriedOverBalance, openCredit,
includeOnboardingFee: false, isActive: false);
}
private async Task<Fitness_core_BE.Models.CustomerSubscription> LoadSubscriptionForInvoiceAsync(
Guid subscriptionId, CancellationToken ct = default)
{
var subscription = await _context.CustomerSubscriptions
.Include(cs => cs.CustomerPlan)
.Include(cs => cs.TenantCustomerLink)
.FirstOrDefaultAsync(cs => cs.SubscriptionId == subscriptionId, ct);
if (subscription == null)
{
throw new Exception("Subscription not found");
}
return subscription;
}
private Task<List<CustomerSubItem>> LoadActiveSubItemsAsync(Guid subscriptionId, CancellationToken ct = default)
{
return _context.CustomerSubItems
.Where(si => si.FKCustomerSubscriptionId == subscriptionId && si.IsActive)
.ToListAsync(ct);
}
// Shared itemization/discount/carry-forward logic behind both invoice-creation entry
// points above. Adds the invoice and its line items to the context but does not save --
// callers control the transaction boundary.
private CustomerInvoice BuildAndAddInvoice(
Fitness_core_BE.Models.CustomerSubscription subscription,
List<CustomerSubItem> subItems,
bool chargePayment,
decimal carriedOverBalance,
decimal openCredit,
bool includeOnboardingFee,
bool isActive)
{
decimal onboardingFee = includeOnboardingFee && subscription.CustomerPlan.HasOnboardingFee
? subscription.CustomerPlan.OnboardingFeeAmount
: 0m;
decimal subtotal = subscription.CustomerPlan.PlanPrice + onboardingFee;
subtotal += subItems.Sum(si => si.SubItemPrice);
decimal discountAmount = DiscountCalculator.ComputeDiscountAmount(
subtotal, subscription.DiscountType, subscription.DiscountValue);
decimal totalAmount = subtotal - discountAmount;
// Already-earned debt from a prior period isn't discountable goods -- add it after
// the discount, not into the subtotal it's computed from.
if (carriedOverBalance > 0)
totalAmount += carriedOverBalance;
// Owner chose not to charge (e.g. comped/waived membership) — keep the itemized
// breakdown for record-keeping but the invoice owes nothing.
if (!chargePayment)
totalAmount = 0m;
// Any open credit (e.g. from a prior mid-cycle downgrade) applies automatically to
// the next invoice, whatever kind it is -- floored at 0 rather than going negative;
// this is a regular invoice, not a credit note, so unused credit beyond this bill
// simply stays open for the one after it (ConsumeOpenCreditAsync already marked the
// whole thing "Applied" up front, so a same-transaction leftover is not re-split here).
decimal creditApplied = Math.Min(openCredit, Math.Max(0m, totalAmount));
if (creditApplied > 0)
totalAmount -= creditApplied;
var customerInvoice = new CustomerInvoice
{
CustomerInvoiceId = Guid.NewGuid(),
FKCustomerSubscriptionId = subscription.SubscriptionId,
FKClientId = subscription.CustomerPlan.FKClientId,
FKUserId = subscription.TenantCustomerLink.CentralUserId,
Amount = totalAmount,
// A Rs. 0 invoice (chargePayment=false, a 100% discount, or credit covering the
// whole bill) has nothing left to collect -- leaving it "Pending" would show up
// as outstanding debt forever with no way to mark it paid.
Status = totalAmount == 0m ? "Paid" : "Pending",
IsActive = isActive,
CustomerInvoiceDate = DateTime.UtcNow
};
_context.CustomerInvoices.Add(customerInvoice);
AddInvoiceLineItems(customerInvoice.CustomerInvoiceId, subscription, subItems, onboardingFee, discountAmount);
if (carriedOverBalance > 0)
{
_context.CustomerInvoiceItems.Add(new CustomerInvoiceItem
{
CustomerInvoiceItemId = Guid.NewGuid(),
FKCustomerInvoiceId = customerInvoice.CustomerInvoiceId,
ItemName = PreviousBalanceItemName,
ItemDescription = "Outstanding balance carried over from the previous subscription period",
ItemPrice = carriedOverBalance,
CreatedAt = DateTime.UtcNow
});
}
if (creditApplied > 0)
{
_context.CustomerInvoiceItems.Add(new CustomerInvoiceItem
{
CustomerInvoiceItemId = Guid.NewGuid(),
FKCustomerInvoiceId = customerInvoice.CustomerInvoiceId,
ItemName = "Credit Applied",
ItemDescription = "Credit balance from a prior plan change, applied to this invoice",
ItemPrice = -creditApplied,
CreatedAt = DateTime.UtcNow
});
}
return customerInvoice;
}
// Finds every still-open Credit invoice for this customer at this gym and consumes all
// of it in one shot (marks each "Applied"), returning the total as a positive amount for
// the caller to subtract from whatever invoice is being built right now. Credit invoices
// are created by CreatePlanChangeInvoiceAsync when a downgrade leaves the customer having
// overpaid -- this is the other half of that: applying it the next time they're billed.
private async Task<decimal> ConsumeOpenCreditAsync(Guid userId, Guid clientId, CancellationToken ct = default)
{
var creditInvoices = await _context.CustomerInvoices
.Where(i => i.FKUserId == userId && i.FKClientId == clientId && i.Status == "Credit")
.ToListAsync(ct);
if (creditInvoices.Count == 0)
return 0m;
decimal totalCredit = 0m;
foreach (var invoice in creditInvoices)
{
totalCredit += -invoice.Amount; // Amount is stored negative on a Credit invoice
invoice.Status = "Applied";
}
return totalCredit;
}
// Plan/onboarding-fee/sub-item/discount line items -- shared by BuildAndAddInvoice and
// RepointAndRecalculateInvoiceAsync (the "Previous Balance" carry-forward item is handled
// separately by each caller, since RepointAndRecalculateInvoiceAsync must leave an
// existing one untouched rather than recreate it).
private void AddInvoiceLineItems(
Guid customerInvoiceId,
Fitness_core_BE.Models.CustomerSubscription subscription,
List<CustomerSubItem> subItems,
decimal onboardingFee,
decimal discountAmount)
{
_context.CustomerInvoiceItems.Add(new CustomerInvoiceItem
{
CustomerInvoiceItemId = Guid.NewGuid(),
FKCustomerInvoiceId = customerInvoiceId,
ItemName = subscription.CustomerPlan.PlanName,
ItemDescription = subscription.CustomerPlan.PlanDescription,
ItemPrice = subscription.CustomerPlan.PlanPrice,
CreatedAt = DateTime.UtcNow
});
if (onboardingFee > 0)
{
_context.CustomerInvoiceItems.Add(new CustomerInvoiceItem
{
CustomerInvoiceItemId = Guid.NewGuid(),
FKCustomerInvoiceId = customerInvoiceId,
ItemName = "Onboarding Fee",
ItemDescription = "One-time gym onboarding fee",
ItemPrice = onboardingFee,
CreatedAt = DateTime.UtcNow
});
}
foreach (var subItem in subItems)
{
_context.CustomerInvoiceItems.Add(new CustomerInvoiceItem
{
CustomerInvoiceItemId = Guid.NewGuid(),
FKCustomerInvoiceId = customerInvoiceId,
ItemName = subItem.SubItemName,
ItemDescription = subItem.SubItemDescription,
ItemPrice = subItem.SubItemPrice,
CreatedAt = DateTime.UtcNow
});
}
if (discountAmount > 0)
{
_context.CustomerInvoiceItems.Add(new CustomerInvoiceItem
{
CustomerInvoiceItemId = Guid.NewGuid(),
FKCustomerInvoiceId = customerInvoiceId,
ItemName = "Discount",
ItemDescription = subscription.DiscountType == "Percentage"
? $"{subscription.DiscountValue}% discount"
: $"Flat discount of Rs. {subscription.DiscountValue}",
ItemPrice = -discountAmount,
CreatedAt = DateTime.UtcNow
});
}
}
public Task<bool> HasPendingApprovalInvoiceAsync(Guid subscriptionId)
{
return _context.CustomerInvoices.AnyAsync(i =>
i.FKCustomerSubscriptionId == subscriptionId && i.IsActive == false && i.Status != "Cancelled");
}
// Renewal-approval workflow: the unapproved invoices awaiting staff review.
public async Task<List<CustomerInvoice>> GetUnapprovedInvoicesByClientIdAsync(Guid clientId)
{
return await _context.CustomerInvoices
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.CustomerPlan)
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.TenantCustomerLink)
.Where(i => i.FKClientId == clientId && i.IsActive == false && i.Status != "Cancelled")
.OrderByDescending(i => i.CustomerInvoiceDate)
.ToListAsync();
}
// Regular invoices tab: one row per customer (their latest approved, currently-relevant
// invoice) instead of every invoice they've ever had. "RolledOver" is excluded alongside
// "Cancelled": once an invoice's balance has been rolled into a newer one (see
// SubscriptionRenewalRunner), it's superseded -- surfacing it here as "the latest" would
// double-count that balance against the new invoice's own carried-forward line item.
public async Task<List<CustomerInvoice>> GetLatestApprovedInvoicePerCustomerAsync(Guid clientId)
{
// Cancelled is the only status excluded up front -- RolledOver and not-yet-approved
// (IsActive=false) invoices are still real candidates below when they're the most
// recent thing on the customer's active subscription (mid-renewal: the old cycle's
// invoice is RolledOver and the new one is awaiting approval, so *neither* would
// pass the old "IsActive && not RolledOver" filter, leaving nothing on that
// subscription to show and wrongly falling back to a long-superseded invoice).
var all = await _context.CustomerInvoices
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.CustomerPlan)
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.TenantCustomerLink)
.Where(i => i.FKClientId == clientId && i.Status != "Cancelled")
.ToListAsync();
return all
.GroupBy(i => i.CustomerSubscription.FKCustomerId)
.Select(g =>
{
// A customer has at most one active subscription -- whatever their most
// recent invoice on it is (even RolledOver or still-unapproved) is more
// representative of where they actually stand than a fully-settled invoice
// from a subscription that's been inactive for months.
var onActiveSubscription = g
.Where(i => i.CustomerSubscription.IsActive)
.OrderByDescending(i => i.CustomerInvoiceDate)
.FirstOrDefault();
if (onActiveSubscription != null)
return onActiveSubscription;
// No active subscription (e.g. removed from the gym, or between plan
// changes) -- fall back to their last genuinely-collectible invoice.
return g.Where(i => i.IsActive && i.Status != "RolledOver")
.OrderByDescending(i => i.CustomerInvoiceDate)
.FirstOrDefault();
})
.Where(i => i != null)
.Select(i => i!)
.OrderByDescending(i => i.CustomerInvoiceDate)
.ToList();
}
// Change-Plan approval action: repoints a pending renewal invoice at the freshly-created
// replacement subscription and recalculates the "new round" portion from the new plan's
// price -- any carried-forward "Previous Balance" line item is left exactly as-is.
public async Task RepointAndRecalculateInvoiceAsync(
Guid customerInvoiceId, Guid newSubscriptionId, CancellationToken ct = default)
{
var invoice = await _context.CustomerInvoices
.FirstOrDefaultAsync(i => i.CustomerInvoiceId == customerInvoiceId, ct);
if (invoice == null)
{
throw new Exception("Invoice not found");
}
var previousBalanceItem = await _context.CustomerInvoiceItems
.FirstOrDefaultAsync(item =>
item.FKCustomerInvoiceId == customerInvoiceId && item.ItemName == PreviousBalanceItemName, ct);
decimal carriedOverBalance = previousBalanceItem?.ItemPrice ?? 0m;
var staleItems = await _context.CustomerInvoiceItems
.Where(item => item.FKCustomerInvoiceId == customerInvoiceId && item.ItemName != PreviousBalanceItemName)
.ToListAsync(ct);
_context.CustomerInvoiceItems.RemoveRange(staleItems);
var newSubscription = await LoadSubscriptionForInvoiceAsync(newSubscriptionId, ct);
var subItems = await LoadActiveSubItemsAsync(newSubscriptionId, ct);
// Approval-flow invoices never re-bill the one-time onboarding fee.
decimal subtotal = newSubscription.CustomerPlan.PlanPrice + subItems.Sum(si => si.SubItemPrice);
decimal discountAmount = DiscountCalculator.ComputeDiscountAmount(
subtotal, newSubscription.DiscountType, newSubscription.DiscountValue);
decimal newRoundAmount = Math.Max(0m, subtotal - discountAmount);
invoice.FKCustomerSubscriptionId = newSubscriptionId;
invoice.Amount = newRoundAmount + carriedOverBalance;
invoice.IsActive = true;
AddInvoiceLineItems(customerInvoiceId, newSubscription, subItems, onboardingFee: 0m, discountAmount);
}
public async Task<Dictionary<Guid, decimal>> GetCarriedForwardAmountsAsync(List<Guid> invoiceIds)
{
if (invoiceIds.Count == 0)
return new Dictionary<Guid, decimal>();
return await _context.CustomerInvoiceItems
.Where(item => invoiceIds.Contains(item.FKCustomerInvoiceId) && item.ItemName == PreviousBalanceItemName)
.GroupBy(item => item.FKCustomerInvoiceId)
.Select(g => new { InvoiceId = g.Key, Total = g.Sum(item => item.ItemPrice) })
.ToDictionaryAsync(x => x.InvoiceId, x => x.Total);
}
public async Task<CustomerInvoice?> GetInvoiceBySubscriptionIdAsync(Guid subscriptionId)
{
// IsActive filter: once a renewal invoice exists awaiting approval, this must keep
// resolving to the real, still-collectible prior invoice (manual payments post
// against that one) rather than the pending-approval invoice. Defaults true for
// every other invoice, so this is a no-op for subscriptions with no pending renewal.
// RolledOver is excluded too: once its balance has been carried into a newer invoice,
// it is no longer the invoice to collect against -- without this, a payment recorded
// during the approval-pending window would land on the superseded invoice while the
// new invoice's "Previous Balance" line (which already counts that same debt) never
// reflects the payment. Credit/Applied are excluded for the same reason as RolledOver
// -- a Credit invoice has a negative Amount and nothing to collect (it's owed to the
// customer, not by them), so it must never be "the invoice" RecordSubscriptionPayment
// tries to charge against.
return await _context.CustomerInvoices
.Where(i => i.FKCustomerSubscriptionId == subscriptionId
&& i.Status != "Cancelled" && i.Status != "RolledOver"
&& i.Status != "Credit" && i.Status != "Applied" && i.IsActive)
.OrderByDescending(i => i.CustomerInvoiceDate)
.FirstOrDefaultAsync();
}
public async Task<CustomerInvoice?> GetCustomerInvoiceById(Guid customerInvoiceId)
{
return await _context.CustomerInvoices
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.CustomerPlan)
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.TenantCustomerLink)
.FirstOrDefaultAsync(i => i.CustomerInvoiceId == customerInvoiceId);
}
public async Task<List<CustomerInvoice>> GetCustomerInvoicesByCustomerId(Guid customerId)
{
return await _context.CustomerInvoices
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.CustomerPlan)
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.TenantCustomerLink)
.Where(i => i.CustomerSubscription.FKCustomerId == customerId)
// Newest cycle first, oldest last -- ordered by the subscription's own
// RenewalDate/ExpiryDate (its true place in the renewal chain), not raw
// CustomerInvoiceDate: a backdated OnboardDate anchors that date to the
// backdated cycle, so the very first invoice can carry a date that's numerically
// *later* than several genuinely more recent renewals, putting it at the top of
// a CustomerInvoiceDate-sorted list instead of the bottom.
.OrderByDescending(i => i.CustomerSubscription.RenewalDate
?? i.CustomerSubscription.ExpiryDate
?? i.CustomerInvoiceDate)
.ToListAsync();
}
public async Task<List<CustomerInvoice>> GetCustomerInvoicesByClientId(Guid clientId)
{
// Cancelled invoices are superseded/orphaned (e.g. left behind when a subscription
// was reassigned) and must not appear on the payments screen or count toward its
// Total Billed / Outstanding totals -- same exclusion already used everywhere else
// this repo reads invoices (GetCustomerInvoicesBySubscription/GetCustomerInvoiceById).
// RolledOver invoices are excluded the same way: their balance has already been
// carried into a newer invoice, so counting both would double the outstanding total.
return await _context.CustomerInvoices
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.CustomerPlan)
.Include(i => i.CustomerSubscription)
.ThenInclude(cs => cs.TenantCustomerLink)
.Where(i => i.FKClientId == clientId && i.Status != "Cancelled" && i.Status != "RolledOver")
.OrderByDescending(i => i.CustomerInvoiceDate)
.ToListAsync();
}
public async Task<CustomerInvoice> UpdateCustomerInvoiceStatus(Guid customerInvoiceId, string status)
{
var customerInvoice = await _context.CustomerInvoices.FindAsync(customerInvoiceId);
if (customerInvoice == null)
{
throw new Exception("CustomerInvoice not found");
}
customerInvoice.Status = status;
return customerInvoice;
}
public async Task<CustomerInvoice> CreateSupplementalInvoiceAsync(Guid subscriptionId, string itemName, string? itemDescription, decimal amount)
{
var subscription = await _context.CustomerSubscriptions
.Include(cs => cs.CustomerPlan)
.Include(cs => cs.TenantCustomerLink)
.FirstOrDefaultAsync(cs => cs.SubscriptionId == subscriptionId);
if (subscription == null)
{
throw new Exception("Subscription not found");
}
var invoice = new CustomerInvoice
{
CustomerInvoiceId = Guid.NewGuid(),
FKCustomerSubscriptionId = subscriptionId,
FKClientId = subscription.CustomerPlan.FKClientId,
FKUserId = subscription.TenantCustomerLink.CentralUserId,
Amount = amount,
Status = amount == 0m ? "Paid" : "Pending",
CustomerInvoiceDate = DateTime.UtcNow
};
_context.CustomerInvoices.Add(invoice);
_context.CustomerInvoiceItems.Add(new CustomerInvoiceItem
{
CustomerInvoiceItemId = Guid.NewGuid(),
FKCustomerInvoiceId = invoice.CustomerInvoiceId,
ItemName = itemName,
ItemDescription = itemDescription,
ItemPrice = amount,
CreatedAt = DateTime.UtcNow
});
return invoice;
}
// Plan-change settlement invoice: `amount` is the signed diff between the new plan's
// price and whatever the customer already paid toward the old one. Nets out any
// already-open credit first (e.g. a prior downgrade), same as every other invoice path.
public async Task<CustomerInvoice> CreatePlanChangeInvoiceAsync(
Guid subscriptionId, string itemName, decimal amount, CancellationToken ct = default)
{
var subscription = await LoadSubscriptionForInvoiceAsync(subscriptionId, ct);
var openCredit = await ConsumeOpenCreditAsync(
subscription.TenantCustomerLink.CentralUserId, subscription.CustomerPlan.FKClientId, ct);
var finalAmount = amount - openCredit;
// Negative = the customer now has a credit (this settlement alone, or combined with
// extra prior credit, exceeds what they owe) -- ConsumeOpenCreditAsync already marked
// the old credit rows "Applied", so this new row becomes the one open credit invoice
// going forward. Zero reuses the same "nothing left to collect" rule as every other
// invoice path.
var status = finalAmount < 0 ? "Credit" : finalAmount == 0m ? "Paid" : "Pending";
var invoice = new CustomerInvoice
{
CustomerInvoiceId = Guid.NewGuid(),
FKCustomerSubscriptionId = subscriptionId,
FKClientId = subscription.CustomerPlan.FKClientId,
FKUserId = subscription.TenantCustomerLink.CentralUserId,
Amount = finalAmount,
Status = status,
CustomerInvoiceDate = DateTime.UtcNow
};
_context.CustomerInvoices.Add(invoice);
_context.CustomerInvoiceItems.Add(new CustomerInvoiceItem
{
CustomerInvoiceItemId = Guid.NewGuid(),
FKCustomerInvoiceId = invoice.CustomerInvoiceId,
ItemName = itemName,
ItemDescription = status == "Credit"
? "Credit from switching to a lower-priced plan mid-cycle -- applied automatically to your next invoice."
: "Balance due from switching to a higher-priced plan mid-cycle.",
ItemPrice = finalAmount,
CreatedAt = DateTime.UtcNow
});
return invoice;
}
public async Task<decimal> GetApprovedPaymentTotalAsync(Guid invoiceId, CancellationToken ct = default)
{
return await _context.PaymentTransactions
.AsNoTracking()
.Where(payment =>
payment.FK_CustomerInvoiceId == invoiceId &&
payment.IsApproved == true &&
payment.IsActive == true)
.Select(payment => (decimal?)payment.PayAmount)
.SumAsync(ct) ?? 0m;
}
}
}
+48
View File
@@ -0,0 +1,48 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class CustomerNotificationRepository : ICustomerNotificationRepository
{
private readonly CentralDbContext _context;
public CustomerNotificationRepository(CentralDbContext context) => _context = context;
public async Task CreateAsync(CustomerNotification notification, CancellationToken ct = default) =>
await _context.CustomerNotifications.AddAsync(notification, ct);
public async Task CreateManyAsync(IEnumerable<CustomerNotification> notifications, CancellationToken ct = default) =>
await _context.CustomerNotifications.AddRangeAsync(notifications, ct);
public async Task<List<CustomerNotification>> GetByCustomerIdAsync(Guid customerId, CancellationToken ct = default) =>
await _context.CustomerNotifications
.AsNoTracking()
.Where(n => n.FKCustomerId == customerId)
.OrderByDescending(n => n.CreatedAt)
.ToListAsync(ct);
public async Task<CustomerNotification?> GetByIdAsync(Guid customerNotificationId, Guid customerId, CancellationToken ct = default) =>
await _context.CustomerNotifications
.FirstOrDefaultAsync(n => n.CustomerNotificationId == customerNotificationId && n.FKCustomerId == customerId, ct);
public async Task<string?> GetCustomerPushTokenAsync(Guid customerId, CancellationToken ct = default) =>
await _context.Customer
.AsNoTracking()
.Where(c => c.CusId == customerId)
.Select(c => c.PushTokenId)
.FirstOrDefaultAsync(ct);
public async Task<List<string>> GetCustomerPushTokensAsync(IEnumerable<Guid> customerIds, CancellationToken ct = default)
{
var ids = customerIds.Distinct().ToList();
if (ids.Count == 0) return new List<string>();
return await _context.Customer
.AsNoTracking()
.Where(c => ids.Contains(c.CusId) && c.PushTokenId != null)
.Select(c => c.PushTokenId!)
.ToListAsync(ct);
}
}
}
+148
View File
@@ -0,0 +1,148 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class CustomerPlanRepository : ICustomerPlanRepository
{
// Client is central-bucket -- CustomerPlan.Client can't be a same-context Include once
// central/tenant are physically separate (see TenantDbContext.OnModelCreating). Loaded
// separately here and attached in memory, same convention as StaffRepository.User.
private readonly TenantDbContext _context;
private readonly CentralDbContext _central;
public CustomerPlanRepository(TenantDbContext context, CentralDbContext central)
{
_context = context;
_central = central;
}
private async Task AttachClientsAsync(IEnumerable<CustomerPlan> plans)
{
var clientIds = plans.Select(p => p.FKClientId).Distinct().ToList();
if (clientIds.Count == 0) return;
var clients = await _central.Client
.AsNoTracking()
.Where(c => clientIds.Contains(c.ClientId))
.ToDictionaryAsync(c => c.ClientId);
foreach (var plan in plans)
{
if (clients.TryGetValue(plan.FKClientId, out var client))
plan.Client = client;
}
}
public async Task<List<CustomerPlan>> GetAllCustomerPlans(bool includeInactive = false, Guid? clientId = null)
{
var query = _context.CustomerPlans.AsQueryable();
if (clientId.HasValue)
{
query = query.Where(cp => cp.FKClientId == clientId.Value);
}
if (!includeInactive)
{
query = query.Where(cp => cp.IsActive);
}
var plans = await query
.OrderBy(cp => cp.PlanName)
.ToListAsync();
await AttachClientsAsync(plans);
return plans;
}
public async Task<CustomerPlan?> GetCustomerPlanById(Guid customerPlanId)
{
var plan = await _context.CustomerPlans
.FirstOrDefaultAsync(cp => cp.CustomerPlanId == customerPlanId);
if (plan != null)
await AttachClientsAsync(new[] { plan });
return plan;
}
public async Task<CustomerPlan> CreateCustomerPlan(CustomerPlan customerPlan)
{
customerPlan.CustomerPlanId = Guid.NewGuid();
customerPlan.CreatedAt = DateTime.UtcNow;
customerPlan.IsActive = true;
_context.CustomerPlans.Add(customerPlan);
return customerPlan;
}
public async Task<CustomerPlan> UpdateCustomerPlan(CustomerPlan customerPlan)
{
var existingPlan = await _context.CustomerPlans.FindAsync(customerPlan.CustomerPlanId);
if (existingPlan == null)
{
throw new Exception("Customer Plan not found");
}
// The service merges provided keys onto the loaded entity and validates
// the combined configuration, so this is a direct full assignment.
existingPlan.PlanName = customerPlan.PlanName;
existingPlan.PlanDescription = customerPlan.PlanDescription;
existingPlan.PlanPrice = customerPlan.PlanPrice;
existingPlan.DurationInDays = customerPlan.DurationInDays;
existingPlan.PlanScope = customerPlan.PlanScope;
existingPlan.MemberLimit = customerPlan.MemberLimit;
existingPlan.DurationMode = customerPlan.DurationMode;
existingPlan.ValidFrom = customerPlan.ValidFrom;
existingPlan.ValidTo = customerPlan.ValidTo;
existingPlan.AllowSplitPayments = customerPlan.AllowSplitPayments;
existingPlan.InstallmentCount = customerPlan.InstallmentCount;
existingPlan.SessionsPerCycle = customerPlan.SessionsPerCycle;
existingPlan.SessionRolloverPolicy = customerPlan.SessionRolloverPolicy;
existingPlan.RolloverGraceDays = customerPlan.RolloverGraceDays;
existingPlan.BillingTiming = customerPlan.BillingTiming;
existingPlan.IsActive = customerPlan.IsActive;
return existingPlan;
}
public async Task<CustomerPlan?> SetCustomerPlanActive(Guid customerPlanId, Guid clientId, bool isActive)
{
var plan = await _context.CustomerPlans
.FirstOrDefaultAsync(cp => cp.CustomerPlanId == customerPlanId && cp.FKClientId == clientId);
if (plan == null)
return null;
plan.IsActive = isActive;
await AttachClientsAsync(new[] { plan });
return plan;
}
public async Task<bool> DeleteCustomerPlan(Guid customerPlanId, bool softDelete = true)
{
var customerPlan = await _context.CustomerPlans.FindAsync(customerPlanId);
if (customerPlan == null)
{
return false;
}
if (softDelete)
{
// Soft delete: just mark as inactive
customerPlan.IsActive = false;
}
else
{
// Hard delete: remove from database
_context.CustomerPlans.Remove(customerPlan);
}
return true;
}
}
}
File diff suppressed because it is too large Load Diff
+240
View File
@@ -0,0 +1,240 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class CustomerSubscriptionRepository : ICustomerSubscriptionRepository
{
private readonly TenantDbContext _context;
private readonly CentralDbContext _central;
public CustomerSubscriptionRepository(TenantDbContext context, CentralDbContext central)
{
_context = context;
_central = central;
}
public async Task AddCustomerSubscriptionAsync(
CustomerSubscription subscription,
CustomerSubItem initialSubItem,
SubscriptionMember primaryMember,
IReadOnlyCollection<SubscriptionInstallment> installments,
CancellationToken ct = default)
{
await _context.CustomerSubscriptions.AddAsync(subscription, ct);
await _context.CustomerSubItems.AddAsync(initialSubItem, ct);
await _context.SubscriptionMembers.AddAsync(primaryMember, ct);
await _context.SubscriptionInstallments.AddRangeAsync(installments, ct);
}
public async Task<CustomerPlan?> GetPlanByIdAsync(
Guid customerPlanId,
CancellationToken ct = default)
{
return await _context.CustomerPlans.FindAsync(
new object[] { customerPlanId }, ct);
}
public Task<bool> IsActiveCustomerAsync(
Guid customerId,
CancellationToken ct = default)
{
return _central.Customer.AnyAsync(
customer => customer.CusId == customerId && customer.IsActive, ct);
}
public Task<List<CustomerSubItem>> GetSubItemsAsync(
Guid subscriptionId,
bool activeOnly,
bool newestFirst,
CancellationToken ct = default)
{
var query = _context.CustomerSubItems
.AsNoTracking()
.Where(item => item.FKCustomerSubscriptionId == subscriptionId);
if (activeOnly)
query = query.Where(item => item.IsActive);
query = newestFirst
? query.OrderByDescending(item => item.CreatedAt)
: query.OrderBy(item => item.CreatedAt);
return query.ToListAsync(ct);
}
public async Task AddSubItemAsync(
CustomerSubItem subItem,
CancellationToken ct = default)
{
await _context.CustomerSubItems.AddAsync(subItem, ct);
}
public Task<CustomerInvoice?> GetLatestInvoiceAsync(
Guid subscriptionId,
CancellationToken ct = default)
{
// IsActive filter: once an unapproved renewal invoice exists for this subscription,
// "latest" must still resolve to the real, still-collectible prior invoice -- not the
// pending-approval one. Defaults true for every other invoice (no-op elsewhere).
// RolledOver is excluded too: it's superseded by whatever invoice carried its balance
// forward, so it's never "the current invoice" for display/payment purposes.
return _context.CustomerInvoices
.AsNoTracking()
.Where(invoice =>
invoice.FKCustomerSubscriptionId == subscriptionId &&
invoice.Status != "Cancelled" &&
invoice.Status != "RolledOver" &&
invoice.IsActive)
.OrderByDescending(invoice => invoice.CustomerInvoiceDate)
.FirstOrDefaultAsync(ct);
}
public async Task<decimal> GetApprovedPaymentTotalAsync(
Guid invoiceId,
CancellationToken ct = default)
{
return await _context.PaymentTransactions
.AsNoTracking()
.Where(payment =>
payment.FK_CustomerInvoiceId == invoiceId &&
payment.IsApproved == true &&
payment.IsActive == true)
.Select(payment => (decimal?)payment.PayAmount)
.SumAsync(ct) ?? 0m;
}
public Task<CustomerCards?> GetCardByIdAsync(
Guid cardId,
CancellationToken ct = default)
{
return _context.CustomerCards
.AsNoTracking()
.FirstOrDefaultAsync(card => card.JobId == cardId, ct);
}
public async Task<CustomerSubscription?> GetCustomerSubscriptionById(Guid subscriptionId)
{
return await _context.CustomerSubscriptions
.Include(s => s.TenantCustomerLink)
.Include(s => s.CustomerPlan)
.Include(s => s.SubscriptionMembers)
.ThenInclude(m => m.TenantCustomerLink)
.Include(s => s.SubscriptionInstallments)
.FirstOrDefaultAsync(s => s.SubscriptionId == subscriptionId);
}
public async Task<List<CustomerSubscription>> GetCustomerSubscriptionsByCustomerId(Guid customerId)
{
return await _context.CustomerSubscriptions
.Include(s => s.CustomerPlan)
.Where(s => s.FKCustomerId == customerId)
.OrderByDescending(s => s.CreatedAt)
.ToListAsync();
}
public async Task<CustomerSubscription?> GetActiveSubscriptionByCustomerId(Guid customerId)
{
return await _context.CustomerSubscriptions
.Include(s => s.TenantCustomerLink)
.Include(s => s.CustomerPlan)
.Where(s => s.FKCustomerId == customerId && s.IsActive)
.OrderByDescending(s => s.CreatedAt)
.FirstOrDefaultAsync();
}
// SubscriptionMembers always has a row for the primary too (CustomerSubscriptionFactory
// adds it alongside the CustomerSubscription itself), so this one query covers both
// "primary somewhere" and "secondary/group member somewhere" in a single check.
public Task<bool> HasAnyActiveSubscriptionAsync(Guid customerId, CancellationToken ct = default)
{
return _context.SubscriptionMembers.AnyAsync(m =>
m.FKCustomerId == customerId && m.IsActive && m.Subscription.IsActive, ct);
}
// ---------- Group membership ----------
public async Task<SubscriptionMember> AddSubscriptionMemberAsync(SubscriptionMember member)
{
member.SubscriptionMemberId = Guid.NewGuid();
member.JoinedAt = DateTime.UtcNow;
member.IsActive = true;
_context.SubscriptionMembers.Add(member);
return member;
}
public async Task<int> GetActiveMemberCountAsync(Guid subscriptionId)
{
// Unassigned members still hold their row (so they stay listed and can be put
// back), but they aren't using the plan -- so they don't occupy a MemberLimit slot.
return await _context.SubscriptionMembers
.CountAsync(m => m.FKSubscriptionId == subscriptionId && m.IsActive && m.IsPlanAssigned);
}
public async Task<SubscriptionMember?> GetSubscriptionMemberAsync(Guid subscriptionMemberId)
{
return await _context.SubscriptionMembers
.Include(m => m.Subscription)
.ThenInclude(s => s.CustomerPlan)
.FirstOrDefaultAsync(m => m.SubscriptionMemberId == subscriptionMemberId);
}
public async Task<SubscriptionMember?> GetActiveSubscriptionMemberByCustomerAsync(Guid subscriptionId, Guid customerId)
{
return await _context.SubscriptionMembers
.FirstOrDefaultAsync(m => m.FKSubscriptionId == subscriptionId && m.FKCustomerId == customerId && m.IsActive);
}
/// <summary>Subscriptions where the customer is an active member (primary OR group secondary).</summary>
public async Task<List<CustomerSubscription>> GetSubscriptionsForCustomerAsync(Guid customerId)
{
return await _context.SubscriptionMembers
.AsNoTracking()
.Where(m => m.FKCustomerId == customerId && m.IsActive && m.Subscription.IsActive)
.Include(m => m.Subscription).ThenInclude(s => s.CustomerPlan)
.Include(m => m.Subscription).ThenInclude(s => s.SubscriptionMembers).ThenInclude(sm => sm.TenantCustomerLink)
.Include(m => m.Subscription).ThenInclude(s => s.SubscriptionInstallments)
.Select(m => m.Subscription)
.OrderByDescending(s => s.OnboardDate)
.ToListAsync();
}
public async Task<List<SubscriptionInstallment>> GetInstallmentsAsync(Guid subscriptionId)
{
return await _context.SubscriptionInstallments
.Where(i => i.FKSubscriptionId == subscriptionId && i.IsActive)
.OrderBy(i => i.InstallmentNo)
.ToListAsync();
}
public async Task AdvanceRenewalDateAsync(Guid subscriptionId, DateTime newRenewalDate, CancellationToken ct = default)
{
var subscription = await _context.CustomerSubscriptions
.FirstOrDefaultAsync(s => s.SubscriptionId == subscriptionId, ct);
if (subscription == null)
return;
subscription.RenewalDate = newRenewalDate;
}
public async Task DeactivatePriorInstallmentsAsync(Guid subscriptionId, CancellationToken ct = default)
{
// Hard delete, not soft-deactivate: TenantDbContext has a unique index on
// (FKSubscriptionId, InstallmentNo), so a soft-deactivated row would still occupy
// its InstallmentNo and collide with the new cycle's schedule restarting at 1 on the
// same subscription. The payment record of truth is PaymentTransactions (linked to
// the invoice, not the installment), so this loses no financial history.
var installments = await _context.SubscriptionInstallments
.Where(i => i.FKSubscriptionId == subscriptionId)
.ToListAsync(ct);
_context.SubscriptionInstallments.RemoveRange(installments);
}
public async Task AddInstallmentsAsync(IReadOnlyCollection<SubscriptionInstallment> installments, CancellationToken ct = default)
{
await _context.SubscriptionInstallments.AddRangeAsync(installments, ct);
}
}
}
+133
View File
@@ -0,0 +1,133 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class DeviceCommandRepo : IDeviceCommandRepo
{
private readonly TenantDbContext _dbContext;
public DeviceCommandRepo(TenantDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<DeviceCommand> AddAsync(DeviceCommand command)
{
await _dbContext.DeviceCommands.AddAsync(command);
return command;
}
public async Task<List<DeviceCommand>> GetPendingByClientAsync(Guid clientId)
{
return await _dbContext.DeviceCommands
.Where(c => c.FKClientId == clientId && c.Status == "Pending")
.OrderBy(c => c.CreatedAt)
.ToListAsync();
}
public async Task<DeviceCommand?> GetByIdAsync(Guid commandId)
{
return await _dbContext.DeviceCommands
.FirstOrDefaultAsync(c => c.Id == commandId);
}
public Task<bool> IsQueuedAsync(
Guid bindingId,
string commandType,
int desiredVersion,
CancellationToken ct = default)
{
return _dbContext.DeviceCommands.AnyAsync(command =>
command.FKAccessDeviceBindingId == bindingId &&
command.CommandType == commandType &&
command.DesiredVersion == desiredVersion &&
(command.Status == "Pending" || command.Status == "InProgress"), ct);
}
public Task<List<DeviceCommand>> GetPendingStateCommandsAsync(
Guid bindingId,
CancellationToken ct = default)
{
return _dbContext.DeviceCommands
.Where(command =>
command.FKAccessDeviceBindingId == bindingId &&
command.CommandType == "SetAccessState" &&
command.Status == "Pending")
.ToListAsync(ct);
}
public Task<List<DeviceCommand>> GetPendingFacePhotoCommandsAsync(
Guid bindingId,
CancellationToken ct = default)
{
return _dbContext.DeviceCommands
.Where(command =>
command.FKAccessDeviceBindingId == bindingId &&
command.CommandType == "UpsertFacePhoto" &&
command.Status == "Pending")
.ToListAsync(ct);
}
public Task<List<DeviceCommand>> GetExhaustedLeasesAsync(
Guid clientId,
DateTime now,
CancellationToken ct = default)
{
return _dbContext.DeviceCommands
.Include(command => command.AccessDeviceBinding)
.Where(command =>
command.FKClientId == clientId &&
command.Status == "InProgress" &&
command.LeaseExpiresAt <= now &&
command.AttemptCount >= command.MaxAttempts)
.ToListAsync(ct);
}
public Task<List<DeviceCommand>> GetLeaseableCommandsAsync(
Guid clientId,
Guid agentId,
DateTime now,
int take,
CancellationToken ct = default)
{
return _dbContext.DeviceCommands
.Include(command => command.AccessDevice)
.Where(command =>
command.FKClientId == clientId &&
command.AccessDevice != null &&
command.AccessDevice.IsActive &&
command.AccessDevice.FKAgentId == agentId &&
((command.Status == "Pending" &&
(command.NextAttemptAt == null ||
command.NextAttemptAt <= now)) ||
(command.Status == "InProgress" &&
command.LeaseExpiresAt <= now &&
command.AttemptCount < command.MaxAttempts)))
.OrderBy(command => command.CreatedAt)
.Take(take)
.ToListAsync(ct);
}
public Task<DeviceCommand?> GetForAgentResultAsync(
Guid commandId,
CancellationToken ct = default)
{
return _dbContext.DeviceCommands
.Include(command => command.AccessDevice)
.Include(command => command.AccessDeviceBinding!)
.ThenInclude(binding => binding.AccessProfile)
.FirstOrDefaultAsync(command => command.Id == commandId, ct);
}
public Task UpdateStatusAsync(DeviceCommand command, string status, string? resultJson = null, DateTime? processedAt = null)
{
command.Status = status;
command.ResultJson = resultJson;
command.ProcessedAt = processedAt;
_dbContext.DeviceCommands.Update(command);
return Task.CompletedTask;
}
}
}
+66
View File
@@ -0,0 +1,66 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class DocumentRepository : IDocumentRepository
{
private readonly CentralDbContext _context;
public DocumentRepository(CentralDbContext context)
{
_context = context;
}
public async Task<UserDocument> UploadDocumentAsync(UserDocument userDocument)
{
await _context.UserDocuments.AddAsync(userDocument);
return userDocument;
}
public async Task<string> GetLastSequnce()
{
var lastDoc = await _context.UserDocuments
.OrderByDescending(d => d.Created_At)
.FirstOrDefaultAsync();
if (lastDoc == null)
return "00001";
// Extract sequence number from Doc_Name or generate new one
var docs = await _context.UserDocuments.CountAsync();
return (docs + 1).ToString("D5");
}
public async Task<List<UserDocument>> GetDocumentsByUserId(Guid userId)
{
return await _context.UserDocuments
.Include(d => d.DocumentType)
.Where(d => d.UserId == userId && d.IsActive)
.OrderByDescending(d => d.Created_At)
.ToListAsync();
}
public async Task<UserDocument?> GetDocumentById(int documentId)
{
return await _context.UserDocuments
.Include(d => d.DocumentType)
.FirstOrDefaultAsync(d => d.DocumentId == documentId && d.IsActive);
}
public async Task<bool> RemoveDocument(int documentId)
{
var document = await _context.UserDocuments.FindAsync(documentId);
if (document == null)
{
return false;
}
document.IsActive = false;
return true;
}
}
}
+102
View File
@@ -0,0 +1,102 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class FacilityRepository : IFacilityRepository
{
private readonly TenantDbContext _context;
public FacilityRepository(TenantDbContext context)
{
_context = context;
}
public async Task<List<Facility>> GetAllFacilities(Guid clientId, bool includeInactive = false)
{
var query = _context.Facilities.Where(f => f.FKClientId == clientId);
if (!includeInactive)
query = query.Where(f => f.IsActive);
return await query.OrderBy(f => f.Name).ToListAsync();
}
public Task<Facility?> GetFacilityById(Guid facilityId)
=> _context.Facilities.FirstOrDefaultAsync(f => f.FacilityId == facilityId);
public async Task<Facility> CreateFacility(Facility facility)
{
facility.FacilityId = Guid.NewGuid();
facility.CreatedAt = DateTime.UtcNow;
facility.IsActive = true;
_context.Facilities.Add(facility);
return facility;
}
public async Task<Facility> UpdateFacility(Facility facility)
{
var existing = await _context.Facilities.FindAsync(facility.FacilityId)
?? throw new KeyNotFoundException("Facility not found");
existing.Name = facility.Name;
existing.FacilityType = facility.FacilityType;
existing.Description = facility.Description;
existing.DefaultCapacity = facility.DefaultCapacity;
existing.IsActive = facility.IsActive;
return existing;
}
public async Task<Facility?> SetFacilityActive(Guid facilityId, Guid clientId, bool isActive)
{
var facility = await _context.Facilities
.FirstOrDefaultAsync(f => f.FacilityId == facilityId && f.FKClientId == clientId);
if (facility == null) return null;
facility.IsActive = isActive;
return facility;
}
public async Task<FacilitySession> CreateFacilitySession(FacilitySession session)
{
session.FacilitySessionId = Guid.NewGuid();
session.CreatedAt = DateTime.UtcNow;
session.IsCancelled = false;
_context.FacilitySessions.Add(session);
return session;
}
public Task<FacilitySession?> GetFacilitySessionById(Guid facilitySessionId)
=> _context.FacilitySessions
.Include(s => s.Facility)
.FirstOrDefaultAsync(s => s.FacilitySessionId == facilitySessionId);
public async Task<List<FacilitySession>> GetSessions(Guid clientId, Guid? facilityId, DateTime fromUtc, DateTime toUtc)
{
var query = _context.FacilitySessions
.Include(s => s.Facility)
.Where(s => s.FKClientId == clientId && s.StartTime >= fromUtc && s.StartTime <= toUtc && !s.IsCancelled);
if (facilityId.HasValue)
query = query.Where(s => s.FKFacilityId == facilityId.Value);
return await query.OrderBy(s => s.StartTime).ToListAsync();
}
public Task<int> GetActiveBookingCount(Guid facilitySessionId)
=> _context.Bookings.CountAsync(b => b.FKFacilitySessionId == facilitySessionId && b.Status != "Cancelled");
public async Task<FacilitySession?> CancelFacilitySession(Guid facilitySessionId, Guid clientId)
{
var session = await _context.FacilitySessions
.FirstOrDefaultAsync(s => s.FacilitySessionId == facilitySessionId && s.FKClientId == clientId);
if (session == null) return null;
session.IsCancelled = true;
return session;
}
}
}
+220
View File
@@ -0,0 +1,220 @@
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
using System.Linq.Expressions;
namespace Fitness_core_BE.Repos
{
public class FeedRepository : IFeedRepository
{
// FeedPost/FeedLike are central-bucket (poster identity spans gyms); Staff and
// CustomerSubscriptions used to scope a feed to one client live in the tenant DB.
private readonly CentralDbContext _central;
private readonly TenantDbContext _dbContext;
public FeedRepository(CentralDbContext central, TenantDbContext dbContext)
{
_central = central;
_dbContext = dbContext;
}
public async Task<FeedPost> GetFeedPostByIdAsync(Guid creatorId, Guid postId)
{
FeedPost feedPost = await _central.FeedPosts
.Where(p => p.FKUserId == creatorId && p.FeedPostId == postId)
.FirstOrDefaultAsync();
if (feedPost == null)
throw new NotFoundException("Post : " + postId + " not found");
return feedPost;
}
public async Task<List<FeedPost>> GetFeedListAsync(int skip, int take)
{
return await _central.FeedPosts
.Include(fp => fp.User)
.Where(fp => fp.IsActive)
.OrderByDescending(fp => fp.CreatedAt)
.Skip(skip)
.Take(take)
.ToListAsync();
}
public async Task<bool> IsLikedAsync(Guid postId, Guid userId)
{
return await _central.FeedLikes
.AnyAsync(x => x.FKFeedPostId == postId && x.FKUserId == userId);
}
public async Task AddLikeAsync(Guid postId, Guid userId)
{
_central.FeedLikes.Add(new FeedLike
{
FKFeedPostId = postId,
FKUserId = userId
});
}
public async Task RemoveLikeAsync(Guid postId, Guid userId)
{
var like = await _central.FeedLikes
.FirstOrDefaultAsync(x => x.FKFeedPostId == postId && x.FKUserId == userId);
if (like != null)
{
_central.FeedLikes.Remove(like);
}
}
public async Task<int> GetLikeCountAsync(Guid postId)
{
return await _central.FeedLikes
.CountAsync(x => x.FKFeedPostId == postId);
}
public async Task<object> CreateFeedAsync(FeedPost feedPost, CancellationToken ct = default)
{
await _central.FeedPosts.AddAsync(feedPost, ct);
return true;
}
public async Task<List<FeedPost>> GetFeedByClientAsync(Guid clientId)
{
// Client (central) and Staff (tenant) can't be joined in one query anymore --
// resolve the set of poster user ids (owner + staff) first, then filter posts.
var posterUserIds = new List<Guid>();
var clientOwnerUserId = await _central.Client
.Where(client => client.ClientId == clientId)
.Select(client => (Guid?)client.FKUserId)
.FirstOrDefaultAsync();
if (clientOwnerUserId != null)
posterUserIds.Add(clientOwnerUserId.Value);
var staffUserIds = await _dbContext.Staff
.Where(staff => staff.FKClientId == clientId)
.Select(staff => staff.FKUserId)
.ToListAsync();
posterUserIds.AddRange(staffUserIds);
return await _central.FeedPosts
.Include(fp => fp.User)
.Where(fp => fp.IsActive && posterUserIds.Contains(fp.FKUserId))
.OrderByDescending(fp => fp.CreatedAt)
.ToListAsync();
}
public async Task<(Guid? ClientId, List<FeedPost> Posts)> GetFeedForCustomerAsync(
Guid customerUserId,
int skip,
int take,
CancellationToken ct = default)
{
var cusId = await _central.Customer
.Where(customer => customer.FKUserId == customerUserId)
.Select(customer => (Guid?)customer.CusId)
.FirstOrDefaultAsync(ct);
var clientId = Guid.Empty;
if (cusId != null)
{
clientId = await _dbContext.CustomerSubscriptions
.Where(subscription =>
subscription.FKCustomerId == cusId.Value &&
subscription.IsActive)
.Select(subscription => subscription.CustomerPlan.FKClientId)
.FirstOrDefaultAsync(ct);
}
// No active gym: there's no single gym feed to scope to, so fall back to the
// platform-wide feed (every active post) instead of showing nothing.
if (clientId == Guid.Empty)
{
var globalPosts = await _central.FeedPosts
.AsNoTracking()
.Include(post => post.User)
.Where(post => post.IsActive)
.OrderByDescending(post => post.CreatedAt)
.Skip(skip)
.Take(take)
.ToListAsync(ct);
return (null, globalPosts);
}
// Poster set for this customer's gym = owner + staff (mirrors GetFeedByClientAsync,
// the client-owner's own view) -- otherwise a staff member's post would show up on
// the gym's own dashboard but never reach that gym's customers.
var posterUserIds = new List<Guid>();
var clientOwnerUserId = await _central.Client
.Where(client => client.ClientId == clientId)
.Select(client => (Guid?)client.FKUserId)
.FirstOrDefaultAsync(ct);
if (clientOwnerUserId != null)
posterUserIds.Add(clientOwnerUserId.Value);
var staffUserIds = await _dbContext.Staff
.Where(staff => staff.FKClientId == clientId)
.Select(staff => staff.FKUserId)
.ToListAsync(ct);
posterUserIds.AddRange(staffUserIds);
// Show the gym's own posts alongside this customer's own posts, so a customer
// who creates a post can see it here too, not just their gym's feed.
var posts = await _central.FeedPosts
.AsNoTracking()
.Include(post => post.User)
.Where(post => post.IsActive && (posterUserIds.Contains(post.FKUserId) || post.FKUserId == customerUserId))
.OrderByDescending(post => post.CreatedAt)
.Skip(skip)
.Take(take)
.ToListAsync(ct);
return (clientId, posts);
}
public async Task<List<Guid>> GetActiveCustomerIdsByPosterUserIdAsync(Guid posterUserId, CancellationToken ct = default)
{
var clientId = await _central.Client
.Where(client => client.FKUserId == posterUserId)
.Select(client => (Guid?)client.ClientId)
.FirstOrDefaultAsync(ct);
if (clientId == null || clientId == Guid.Empty)
return new List<Guid>();
return await _dbContext.CustomerSubscriptions
.Where(subscription => subscription.IsActive && subscription.CustomerPlan.FKClientId == clientId)
.Select(subscription => subscription.FKCustomerId)
.Distinct()
.ToListAsync(ct);
}
public async Task<object> DeletePostPostIdAsync(Guid creatorId, Guid postId)
{
FeedPost post = await _central.FeedPosts
.Where(p => p.FeedPostId == postId && p.FKUserId == creatorId)
.FirstOrDefaultAsync();
if (post == null)
throw new NotFoundException("Post : " + postId + "not found");
post.IsActive = false;
try
{
_central.Update(post);
return new { process = "success" };
}
catch (Exception ex)
{
throw new Exception(ex.Message, ex);
}
}
}
}
+88
View File
@@ -0,0 +1,88 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class GymRoleRepository : IGymRoleRepository
{
private readonly TenantDbContext _context;
public GymRoleRepository(TenantDbContext context)
{
_context = context;
}
public async Task<List<GymRole>> GetRolesByClientId(Guid clientId)
{
return await _context.GymRoles
.Include(r => r.Permissions)
.Where(r => r.FKClientId == clientId)
.OrderBy(r => r.Name)
.ToListAsync();
}
public async Task<GymRole?> GetRoleById(Guid gymRoleId)
{
return await _context.GymRoles
.Include(r => r.Permissions)
.FirstOrDefaultAsync(r => r.GymRoleId == gymRoleId);
}
public async Task<GymRole> CreateRole(GymRole role)
{
role.GymRoleId = Guid.NewGuid();
role.CreatedAt = DateTime.UtcNow;
role.IsActive = true;
_context.GymRoles.Add(role);
return role;
}
public async Task<GymRole> UpdateRole(GymRole role)
{
return role;
}
public async Task DeleteRole(GymRole role)
{
_context.GymRoles.Remove(role);
}
public async Task ReplacePermissions(Guid gymRoleId, IEnumerable<string> navKeys)
{
var existing = _context.GymRolePermissions.Where(p => p.FKGymRoleId == gymRoleId);
_context.GymRolePermissions.RemoveRange(existing);
foreach (var key in navKeys.Distinct())
{
_context.GymRolePermissions.Add(new GymRolePermission { FKGymRoleId = gymRoleId, NavItemKey = key });
}
}
public async Task<List<string>> GetPermissionKeys(Guid gymRoleId)
{
return await _context.GymRolePermissions
.AsNoTracking()
.Where(p => p.FKGymRoleId == gymRoleId)
.Select(p => p.NavItemKey)
.ToListAsync();
}
public async Task<bool> AssignStaffRole(Guid staffId, Guid clientId, Guid? gymRoleId)
{
var staff = await _context.Staff.FirstOrDefaultAsync(s => s.StaffId == staffId && s.FKClientId == clientId);
if (staff == null) return false;
if (gymRoleId.HasValue)
{
var roleBelongsToClient = await _context.GymRoles
.AnyAsync(r => r.GymRoleId == gymRoleId.Value && r.FKClientId == clientId);
if (!roleBelongsToClient) return false;
}
staff.FKGymRoleId = gymRoleId;
return true;
}
}
}
+442
View File
@@ -0,0 +1,442 @@
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);
}
}
+25
View File
@@ -0,0 +1,25 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class NavItemRepository : INavItemRepository
{
private readonly CentralDbContext _context;
public NavItemRepository(CentralDbContext context)
{
_context = context;
}
public async Task<List<NavItem>> GetActiveNavItemsOrdered()
{
return await _context.NavItems
.AsNoTracking()
.Where(n => n.IsActive)
.OrderBy(n => n.SortOrder)
.ToListAsync();
}
}
}
+113
View File
@@ -0,0 +1,113 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class OverdueLockRepository : IOverdueLockRepository
{
private readonly TenantDbContext _context;
private readonly CentralDbContext _central;
public OverdueLockRepository(TenantDbContext context, CentralDbContext central)
{
_context = context;
_central = central;
}
// A null policy now means "use the platform default" (see Client.DefaultLockAfterDaysOverdue)
// rather than "auto-lock is off" -- only an explicit 0 disables it for a client.
public Task<List<Client>> GetClientsWithOverdueLockPolicyAsync(
CancellationToken ct = default)
{
return _central.Client
.AsNoTracking()
.Where(client =>
client.LockAfterDaysOverdue == null ||
client.LockAfterDaysOverdue > 0)
.ToListAsync(ct);
}
public async Task<List<Guid>> GetOverdueCustomerIdsAsync(
Guid clientId,
DateTime thresholdDate,
CancellationToken ct = default)
{
// IsActive filter: an unapproved renewal invoice (daily sweep, awaiting staff
// approve/change-plan/reject) must never count toward auto-locking a member -- they
// have no way to pay an invoice that isn't even approved for billing yet. Once it's
// approved (IsActive=true) it's a normal invoice and ages into "overdue" like any
// other; RolledOver invoices are also excluded since their debt now lives on the
// invoice that superseded them, not on this one.
var overdueSubscriptionIds = await _context.CustomerInvoices
.AsNoTracking()
.Where(invoice =>
invoice.FKClientId == clientId &&
invoice.Status != "Paid" &&
invoice.Status != "Cancelled" &&
invoice.Status != "RolledOver" &&
invoice.IsActive &&
invoice.CustomerInvoiceDate <= thresholdDate)
.Select(invoice => invoice.FKCustomerSubscriptionId)
.Distinct()
.ToListAsync(ct);
if (overdueSubscriptionIds.Count == 0)
return new List<Guid>();
// One invoice covers the whole subscription, so an unpaid one locks every active
// member on it -- not just the primary. On a group/couple plan the secondaries
// never hold an invoice of their own, so selecting the subscription's owner alone
// would leave them with working access while the plan they share is in arrears
// (and would then have them auto-unlocked again by the Except() in OverdueLockJob).
return await _context.SubscriptionMembers
.AsNoTracking()
.Where(member =>
member.IsActive &&
overdueSubscriptionIds.Contains(member.FKSubscriptionId))
.Select(member => member.FKCustomerId)
.Distinct()
.ToListAsync(ct);
}
public Task<Customer?> GetCustomerByIdAsync(
Guid customerId,
CancellationToken ct = default)
{
return _central.Customer
.AsNoTracking()
.FirstOrDefaultAsync(customer => customer.CusId == customerId, ct);
}
public Task<bool> IsCustomerAccessDisabledAsync(
Guid clientId,
Guid userId,
CancellationToken ct = default)
{
return _context.AccessProfiles.AnyAsync(profile =>
profile.FKClientId == clientId &&
profile.FKUserId == userId &&
profile.DesiredAccessState == "Disabled", ct);
}
public Task<List<Guid>> GetCustomersLockedForOverdueAsync(
Guid clientId,
CancellationToken ct = default)
{
return _context.SubscriptionMembers
.AsNoTracking()
.Where(member =>
member.IsActive &&
member.Subscription.IsActive &&
member.Subscription.CustomerPlan.FKClientId == clientId &&
_context.AccessProfiles.Any(profile =>
profile.FKClientId == clientId &&
profile.FKUserId == member.TenantCustomerLink.CentralUserId &&
profile.DesiredAccessState == "Disabled" &&
profile.LockReason == "Overdue"))
.Select(member => member.FKCustomerId)
.Distinct()
.ToListAsync(ct);
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class PairingCodeRepo : IPairingCodeRepo
{
// Central: created by platform Admin (no TenantId claim) and redeemed by an anonymous
// agent enroll call (no tenant context at all yet). See CentralDbContext.PairingCodes.
private readonly CentralDbContext _dbContext;
public PairingCodeRepo(CentralDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<PairingCode> AddAsync(PairingCode pairingCode)
{
await _dbContext.PairingCodes.AddAsync(pairingCode);
return pairingCode;
}
// Active = not used and not expired.
public async Task<PairingCode?> GetActiveByCodeAsync(string code)
{
var now = DateTime.UtcNow;
return await _dbContext.PairingCodes
.FirstOrDefaultAsync(p => p.Code == code && !p.IsUsed && p.ExpiresAt > now);
}
public Task MarkUsedAsync(PairingCode pairingCode)
{
pairingCode.IsUsed = true;
_dbContext.PairingCodes.Update(pairingCode);
return Task.CompletedTask;
}
}
}
+84
View File
@@ -0,0 +1,84 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class PaymentRepository : IPaymentRepository
{
// Row-split entity: member payments (FK_CustomerInvoiceId set) live in the tenant DB,
// platform-billing payments (FK_ClientInvoiceId set) live in the central DB.
private readonly TenantDbContext _context;
private readonly CentralDbContext _central;
public PaymentRepository(TenantDbContext context, CentralDbContext central)
{
_context = context;
_central = central;
}
public async Task<PaymentTransaction> CreatePaymentAttemptAsync(PaymentTransaction attempt)
{
if (attempt.FK_ClientInvoiceId.HasValue)
await _central.PaymentTransactions.AddAsync(attempt);
else
await _context.PaymentTransactions.AddAsync(attempt);
return attempt;
}
public async Task<PaymentTransaction?> GetPaymentByIdAsync(Guid attemptId)
{
var tenant = await _context.PaymentTransactions
.AsNoTracking()
.Include(p => p.CustomerCard)
.Include(p => p.CustomerInvoice)
.FirstOrDefaultAsync(p => p.AttemptId == attemptId && p.IsActive == true);
if (tenant != null)
return tenant;
return await _central.PaymentTransactions
.AsNoTracking()
.Include(p => p.ClientInvoice)
.FirstOrDefaultAsync(p => p.AttemptId == attemptId && p.IsActive == true);
}
public async Task<List<PaymentTransaction>> GetPaymentsByCustomerInvoiceIdAsync(Guid customerInvoiceId)
{
return await _context.PaymentTransactions
.AsNoTracking()
.Include(p => p.CustomerCard)
.Where(p => p.FK_CustomerInvoiceId == customerInvoiceId && p.IsActive == true)
.OrderByDescending(p => p.PayDate)
.ToListAsync();
}
public async Task<List<PaymentTransaction>> GetPaymentsByClientInvoiceIdAsync(Guid clientInvoiceId)
{
return await _central.PaymentTransactions
.AsNoTracking()
.Where(p => p.FK_ClientInvoiceId == clientInvoiceId && p.IsActive == true)
.OrderByDescending(p => p.PayDate)
.ToListAsync();
}
public async Task<PaymentTransaction> UpdatePaymentAttemptAsync(PaymentTransaction attempt)
{
if (attempt.FK_ClientInvoiceId.HasValue)
_central.PaymentTransactions.Update(attempt);
else
_context.PaymentTransactions.Update(attempt);
return attempt;
}
public async Task<decimal> GetApprovedPaidAmountAsync(Guid customerInvoiceId)
{
return await _context.PaymentTransactions
.AsNoTracking()
.Where(p => p.FK_CustomerInvoiceId == customerInvoiceId
&& p.IsApproved == true
&& p.IsActive == true)
.Select(p => (decimal?)p.PayAmount)
.SumAsync() ?? 0m;
}
}
}
+84
View File
@@ -0,0 +1,84 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Fitness_core_BE.Models.Enums;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class PaymentsEnablementRepository : IPaymentsEnablementRepository
{
private readonly CentralDbContext _context;
public PaymentsEnablementRepository(CentralDbContext context)
{
_context = context;
}
public async Task<ClientWallet?> GetWalletByClientIdAsync(Guid clientId, CancellationToken ct = default)
{
return await _context.ClientWallets
.FirstOrDefaultAsync(w => w.FKClientId == clientId, ct);
}
public async Task<List<WalletLedgerEntry>> GetLedgerAsync(Guid walletId, CancellationToken ct = default)
{
return await _context.WalletLedgerEntries
.AsNoTracking()
.Where(e => e.FKWalletId == walletId)
.OrderByDescending(e => e.CreatedAt)
.ToListAsync(ct);
}
public async Task<ClientPayoutKyc?> GetKycByClientIdAsync(Guid clientId, CancellationToken ct = default)
{
return await _context.ClientPayoutKycs
.AsNoTracking()
.Include(k => k.Client)
.Where(k => k.FKClientId == clientId)
.OrderByDescending(k => k.SubmittedAt)
.FirstOrDefaultAsync(ct);
}
public async Task<ClientPayoutKyc?> GetKycForUpdateAsync(
Guid clientId,
CancellationToken ct = default)
{
return await _context.ClientPayoutKycs
.Include(k => k.Client)
.Where(k => k.FKClientId == clientId)
.OrderByDescending(k => k.SubmittedAt)
.FirstOrDefaultAsync(ct);
}
public async Task CreateKycAsync(ClientPayoutKyc kyc, CancellationToken ct = default)
{
await _context.ClientPayoutKycs.AddAsync(kyc, ct);
}
public async Task<(List<ClientPayoutKyc> Items, int Total)> ListKycAsync(string? status, CancellationToken ct = default)
{
var query = _context.ClientPayoutKycs
.AsNoTracking()
.Include(k => k.Client)
.AsQueryable();
if (!string.IsNullOrWhiteSpace(status) && Enum.TryParse<KycStatus>(status, true, out var parsedStatus))
query = query.Where(k => k.KycStatus == parsedStatus);
var total = await query.CountAsync(ct);
var items = await query.OrderByDescending(k => k.SubmittedAt).ToListAsync(ct);
return (items, total);
}
public async Task CreateWalletAsync(ClientWallet wallet, CancellationToken ct = default)
{
await _context.ClientWallets.AddAsync(wallet, ct);
}
public async Task CreateClientCardAsync(ClientCard card, CancellationToken ct = default)
{
await _context.ClientCard.AddAsync(card, ct);
}
}
}
+143
View File
@@ -0,0 +1,143 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class PlaceRepo : IPlaceRepo
{
private readonly TenantDbContext _context;
public PlaceRepo(TenantDbContext context)
{
_context = context;
}
public async Task<List<Place>> GetAllPlaces(Guid clientId, bool includeInactive = false)
{
var query = _context.Places.Where(p => p.FKClientId == clientId);
if (!includeInactive)
query = query.Where(p => p.IsActive);
return await query.OrderBy(p => p.Name).ToListAsync();
}
public Task<Place?> GetPlaceById(Guid placeId)
=> _context.Places.FirstOrDefaultAsync(p => p.PlaceId == placeId);
public async Task<Place> CreatePlace(Place place)
{
place.PlaceId = Guid.NewGuid();
place.CreatedAt = DateTime.UtcNow;
place.IsActive = true;
_context.Places.Add(place);
return place;
}
public async Task<Place> UpdatePlace(Place place)
{
var existing = await _context.Places.FindAsync(place.PlaceId)
?? throw new KeyNotFoundException("Place not found");
existing.Name = place.Name;
existing.Description = place.Description;
existing.IsActive = place.IsActive;
return existing;
}
public async Task<Place?> SetPlaceActive(Guid placeId, Guid clientId, bool isActive)
{
var place = await _context.Places
.FirstOrDefaultAsync(p => p.PlaceId == placeId && p.FKClientId == clientId);
if (place == null) return null;
place.IsActive = isActive;
return place;
}
public Task<AccessDevice?> GetDeviceByIdForClientAsync(Guid deviceId, Guid clientId)
=> _context.AccessDevices
.FirstOrDefaultAsync(d => d.DeviceId == deviceId && d.FKClientId == clientId);
public async Task<Dictionary<Guid, string>> GetPlaceNamesByIdsAsync(List<Guid> placeIds)
{
return await _context.Places
.Where(p => placeIds.Contains(p.PlaceId))
.ToDictionaryAsync(p => p.PlaceId, p => p.Name);
}
public async Task<Dictionary<Guid, AccessDevice>> GetDevicesByPlaceIdsAsync(List<Guid> placeIds)
{
var devices = await _context.AccessDevices
.Where(d => d.FKPlaceId != null && placeIds.Contains(d.FKPlaceId.Value))
.ToListAsync();
return devices.ToDictionary(d => d.FKPlaceId!.Value, d => d);
}
public Task<bool> IsDeviceAssignedToAnotherPlaceAsync(Guid deviceId, Guid placeId)
=> _context.AccessDevices
.AnyAsync(d => d.DeviceId == deviceId && d.FKPlaceId != null && d.FKPlaceId != placeId);
public async Task AssignDeviceAsync(Guid placeId, Guid deviceId)
{
// A device can only sit in one place at a time -- clear anywhere else it's
// currently assigned before pointing it at this place.
var previouslyAssigned = await _context.AccessDevices
.Where(d => d.FKPlaceId == placeId && d.DeviceId != deviceId)
.ToListAsync();
foreach (var device in previouslyAssigned)
device.FKPlaceId = null;
var target = await _context.AccessDevices.FindAsync(deviceId)
?? throw new KeyNotFoundException("Device not found");
target.FKPlaceId = placeId;
}
public async Task UnassignDeviceAsync(Guid placeId)
{
var devices = await _context.AccessDevices
.Where(d => d.FKPlaceId == placeId)
.ToListAsync();
foreach (var device in devices)
device.FKPlaceId = null;
}
public async Task SetPlanPlacesAsync(Guid customerPlanId, List<Guid> placeIds)
{
var existing = await _context.CustomerPlanPlaces
.Where(pp => pp.FKCustomerPlanId == customerPlanId)
.ToListAsync();
_context.CustomerPlanPlaces.RemoveRange(existing);
foreach (var placeId in placeIds.Distinct())
{
_context.CustomerPlanPlaces.Add(new CustomerPlanPlace
{
CustomerPlanPlaceId = Guid.NewGuid(),
FKCustomerPlanId = customerPlanId,
FKPlaceId = placeId
});
}
}
public Task<List<Guid>> GetPlaceIdsForPlanAsync(Guid customerPlanId)
=> _context.CustomerPlanPlaces
.Where(pp => pp.FKCustomerPlanId == customerPlanId)
.Select(pp => pp.FKPlaceId)
.ToListAsync();
public async Task<Dictionary<Guid, List<Guid>>> GetPlaceIdsByPlanIdsAsync(List<Guid> customerPlanIds)
{
var rows = await _context.CustomerPlanPlaces
.Where(pp => customerPlanIds.Contains(pp.FKCustomerPlanId))
.ToListAsync();
return rows
.GroupBy(pp => pp.FKCustomerPlanId)
.ToDictionary(g => g.Key, g => g.Select(pp => pp.FKPlaceId).ToList());
}
}
}
+264
View File
@@ -0,0 +1,264 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class PlatformBillingRepository : IPlatformBillingRepository
{
// Platform billing is central-bucket (HexaFitness's own revenue relationship with
// each gym); AccessDevices (used only to check device-billing eligibility) is tenant.
private readonly CentralDbContext _context;
private readonly TenantDbContext _tenant;
public PlatformBillingRepository(CentralDbContext context, TenantDbContext tenant)
{
_context = context;
_tenant = tenant;
}
public Task<List<ClientPackage>> GetPricingPlansAsync(CancellationToken ct = default)
{
return _context.ClientPackages
.AsNoTracking()
.OrderByDescending(package => package.IsDefault)
.ThenBy(package => package.PackageName)
.ToListAsync(ct);
}
public async Task<ClientPackage?> GetPackageByIdAsync(
Guid packageId,
CancellationToken ct = default)
{
return await _context.ClientPackages.FindAsync(new object[] { packageId }, ct);
}
public async Task AddPackageAsync(
ClientPackage package,
CancellationToken ct = default)
{
await _context.ClientPackages.AddAsync(package, ct);
}
public Task<List<ClientPackage>> GetOtherDefaultPackagesAsync(
Guid exceptPackageId,
CancellationToken ct = default)
{
return _context.ClientPackages
.Where(package =>
package.IsDefault &&
package.ClientPackageId != exceptPackageId)
.ToListAsync(ct);
}
public Task<ClientPackage?> GetDefaultPackageAsync(CancellationToken ct = default)
{
return _context.ClientPackages
.FirstOrDefaultAsync(package => package.IsDefault, ct);
}
public async Task<ClientPackage?> GetActivePackageForClientAsync(
Guid clientId,
CancellationToken ct = default)
{
return await _context.ClientSubscriptions
.AsNoTracking()
.Where(subscription =>
subscription.FKClientId == clientId &&
subscription.IsActive)
.OrderByDescending(subscription => subscription.OnboardDate)
.Select(subscription => subscription.ClientPackage)
.FirstOrDefaultAsync(ct);
}
public Task<Client?> GetClientByIdAsync(
Guid clientId,
CancellationToken ct = default)
{
return _context.Client
.FirstOrDefaultAsync(client => client.ClientId == clientId, ct);
}
public Task<List<ClientSubscription>> GetActiveClientSubscriptionsAsync(
Guid clientId,
CancellationToken ct = default)
{
return _context.ClientSubscriptions
.Where(subscription =>
subscription.FKClientId == clientId &&
subscription.IsActive)
.ToListAsync(ct);
}
public Task<ClientSubscription?> GetActiveClientSubscriptionAsync(
Guid clientId,
CancellationToken ct = default)
{
return _context.ClientSubscriptions
.Where(subscription =>
subscription.FKClientId == clientId &&
subscription.IsActive)
.OrderByDescending(subscription => subscription.OnboardDate)
.FirstOrDefaultAsync(ct);
}
public async Task AddClientSubscriptionAsync(
ClientSubscription subscription,
CancellationToken ct = default)
{
await _context.ClientSubscriptions.AddAsync(subscription, ct);
}
public Task<List<ClientDeviceBilling>> GetActiveDeviceBillingsAsync(
Guid clientId,
CancellationToken ct = default)
{
return _context.ClientDeviceBillings
.Where(billing =>
billing.FKClientId == clientId &&
billing.IsActive)
.ToListAsync(ct);
}
public Task<ClientDeviceBilling?> GetLatestActiveDeviceBillingAsync(
Guid clientId,
CancellationToken ct = default)
{
return _context.ClientDeviceBillings
.Where(billing =>
billing.FKClientId == clientId &&
billing.IsActive)
.OrderByDescending(billing => billing.AssignedAt)
.FirstOrDefaultAsync(ct);
}
public async Task AddDeviceBillingAsync(
ClientDeviceBilling billing,
CancellationToken ct = default)
{
await _context.ClientDeviceBillings.AddAsync(billing, ct);
}
public Task<bool> HasActiveDeviceAsync(
Guid clientId,
CancellationToken ct = default)
{
return _tenant.AccessDevices.AnyAsync(device =>
device.FKClientId == clientId &&
device.IsActive, ct);
}
public Task<List<Guid>> GetMonthlyBillingCandidatesAsync(
DateTime startOfMonth,
CancellationToken ct = default)
{
return _context.Client
.AsNoTracking()
.Where(client =>
_context.ClientCard.Any(card =>
card.FK_ClientId == client.ClientId) &&
!_context.ClientInvoices.Any(invoice =>
invoice.ClientSubscription.FKClientId == client.ClientId &&
invoice.ClientInvoiceDate >= startOfMonth))
.Select(client => client.ClientId)
.ToListAsync(ct);
}
public Task<List<ClientInvoice>> GetInvoicesByStatusAsync(
string status,
bool newestFirst,
CancellationToken ct = default)
{
var query = _context.ClientInvoices
.AsNoTracking()
.Include(invoice => invoice.ClientSubscription)
.ThenInclude(subscription => subscription.Client)
.ThenInclude(client => client.ClientBusinesses)
.Where(invoice =>
status == "Outstanding"
? invoice.Status == "Pending" || invoice.Status == "Partial"
: invoice.Status == status);
query = newestFirst
? query.OrderByDescending(invoice => invoice.ClientInvoiceDate)
: query.OrderBy(invoice => invoice.ClientInvoiceDate);
return query.ToListAsync(ct);
}
public Task<ClientInvoice?> GetInvoiceWithClientAsync(
Guid invoiceId,
bool includeBusinesses,
CancellationToken ct = default)
{
var query = _context.ClientInvoices
.Include(invoice => invoice.ClientSubscription)
.ThenInclude(subscription => subscription.Client)
.AsQueryable();
if (includeBusinesses)
{
query = query.Include(invoice =>
invoice.ClientSubscription.Client.ClientBusinesses);
}
return query.FirstOrDefaultAsync(
invoice => invoice.ClientInvoiceId == invoiceId, ct);
}
public Task<ClientCard?> GetClientCardAsync(
Guid clientId,
CancellationToken ct = default)
{
// The card billing charges: the active card marked primary. CardToken != null
// excludes legacy rows created by ActivationService.ChargeAndActivateAsync before
// real card onboarding existed — those were never confirmed by Payment-Service and
// can't actually be charged, so they must never outrank (or stand in for) a real card.
return _context.ClientCard
.Where(card => card.FK_ClientId == clientId && card.IsActive && card.CardToken != null)
.OrderByDescending(card => card.IsPrimary)
.FirstOrDefaultAsync(ct);
}
public Task<List<ClientCard>> GetClientCardsAsync(
Guid clientId,
CancellationToken ct = default)
{
return _context.ClientCard
.Where(card => card.FK_ClientId == clientId && card.IsActive && card.CardToken != null)
.OrderByDescending(card => card.IsPrimary)
.ToListAsync(ct);
}
public Task<ClientCard?> GetClientCardByTokenAsync(
Guid clientId,
string cardToken,
CancellationToken ct = default)
{
return _context.ClientCard
.FirstOrDefaultAsync(card => card.FK_ClientId == clientId && card.CardToken == cardToken, ct);
}
public async Task AddInvoiceAsync(
ClientInvoice invoice,
IReadOnlyCollection<ClientInvoiceItem> items,
CancellationToken ct = default)
{
await _context.ClientInvoices.AddAsync(invoice, ct);
await _context.ClientInvoiceItems.AddRangeAsync(items, ct);
}
public Task<DevicePricingConfig?> GetDevicePricingConfigAsync(
CancellationToken ct = default)
{
return _context.DevicePricingConfigs.FirstOrDefaultAsync(ct);
}
public async Task AddDevicePricingConfigAsync(
DevicePricingConfig config,
CancellationToken ct = default)
{
await _context.DevicePricingConfigs.AddAsync(config, ct);
}
}
}
+124
View File
@@ -0,0 +1,124 @@
using Fitness_core_BE.DTOs.Ledger;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class ProfitLossRepository : IProfitLossRepository
{
// Every method here is already scoped to a single caller-owned clientId (resolved
// from the ambient tenant), so no cross-tenant fan-out is needed. PaymentTransactions
// (member-payment rows), StaffPayments and LedgerEntries are tenant-bucket;
// ClientInvoices (platform billing) is central.
private readonly TenantDbContext _context;
private readonly CentralDbContext _central;
public ProfitLossRepository(TenantDbContext context, CentralDbContext central)
{
_context = context;
_central = central;
}
public async Task<List<LedgerEntryDto>> GetDerivedEntriesAsync(
Guid clientId,
DateTime fromUtc,
DateTime toUtc,
CancellationToken ct = default)
{
var memberPayments = await _context.PaymentTransactions
.AsNoTracking()
.Where(payment =>
payment.FK_ClientId == clientId &&
payment.FK_CustomerInvoiceId != null &&
payment.IsApproved == true &&
payment.IsActive == true &&
payment.PayDate != null &&
payment.PayDate >= fromUtc &&
payment.PayDate < toUtc)
.Select(payment => new LedgerEntryDto
{
EntryType = "Income",
Category = "Member Payments",
Description = payment.PayNote,
Amount = payment.PayAmount,
EntryDate = payment.PayDate!.Value,
IsManual = false
})
.ToListAsync(ct);
var platformInvoices = await _central.ClientInvoices
.AsNoTracking()
.Where(invoice =>
invoice.ClientSubscription.FKClientId == clientId &&
invoice.PaidAmount > 0 &&
invoice.ClientInvoiceDate >= fromUtc &&
invoice.ClientInvoiceDate < toUtc)
.Select(invoice => new LedgerEntryDto
{
EntryType = "Expense",
Category = "Platform Fees",
Description = "HexaFitness platform invoice",
Amount = invoice.PaidAmount,
EntryDate = invoice.ClientInvoiceDate,
IsManual = false
})
.ToListAsync(ct);
var staffPayments = await _context.StaffPayments
.AsNoTracking()
.Where(payment =>
payment.Staff.FKClientId == clientId &&
payment.PaidAt >= fromUtc &&
payment.PaidAt < toUtc)
.Select(payment => new LedgerEntryDto
{
EntryType = "Expense",
Category = "Staff Payroll",
Description = payment.Note,
Amount = payment.Amount,
EntryDate = payment.PaidAt,
IsManual = false
})
.ToListAsync(ct);
memberPayments.AddRange(platformInvoices);
memberPayments.AddRange(staffPayments);
return memberPayments;
}
public Task<List<LedgerEntry>> GetManualEntriesAsync(
Guid clientId,
DateTime fromUtc,
DateTime toUtc,
CancellationToken ct = default)
{
return _context.LedgerEntries
.AsNoTracking()
.Where(entry =>
entry.FKClientId == clientId &&
entry.EntryDate >= fromUtc &&
entry.EntryDate < toUtc)
.ToListAsync(ct);
}
public Task<LedgerEntry?> GetManualEntryByIdAsync(
Guid id,
CancellationToken ct = default)
{
return _context.LedgerEntries.FirstOrDefaultAsync(entry => entry.Id == id, ct);
}
public async Task AddManualEntryAsync(
LedgerEntry entry,
CancellationToken ct = default)
{
await _context.LedgerEntries.AddAsync(entry, ct);
}
public void RemoveManualEntry(LedgerEntry entry)
{
_context.LedgerEntries.Remove(entry);
}
}
}
+205
View File
@@ -0,0 +1,205 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class StaffRepository : IStaffRepository
{
private readonly TenantDbContext _context;
private readonly CentralDbContext _central;
public StaffRepository(TenantDbContext context, CentralDbContext central)
{
_context = context;
_central = central;
}
// Staff.User/Staff.Client are central-bucket entities (not a real relationship once
// physically split) -- these load the central User rows separately and attach them
// in memory onto the already-fetched Staff objects, so callers reading staff.User.*
// still see the right data without needing to change.
private async Task AttachUsersAsync(IEnumerable<Staff> staffList)
{
var userIds = staffList.Select(s => s.FKUserId).Distinct().ToList();
if (userIds.Count == 0) return;
var users = await _central.Users
.AsNoTracking()
.Where(u => userIds.Contains(u.UserId))
.ToDictionaryAsync(u => u.UserId);
foreach (var staff in staffList)
{
if (users.TryGetValue(staff.FKUserId, out var user))
staff.User = user;
}
}
public async Task<List<Staff>> GetAllStaff(bool includeInactive = false)
{
var query = _context.Staff
.Include(s => s.GymRole)
.AsQueryable();
if (!includeInactive)
{
query = query.Where(s => s.IsActive);
}
var staffList = await query.ToListAsync();
await AttachUsersAsync(staffList);
return staffList.OrderBy(s => s.User?.UserName).ToList();
}
public async Task<Staff?> GetStaffById(Guid staffId)
{
var staff = await _context.Staff
.Include(s => s.GymRole)
.FirstOrDefaultAsync(s => s.StaffId == staffId);
if (staff != null)
await AttachUsersAsync(new[] { staff });
return staff;
}
public async Task<List<Staff>> GetStaffByClientId(Guid clientId, bool includeInactive = false)
{
var query = _context.Staff
.Include(s => s.GymRole)
.Where(s => s.FKClientId == clientId);
if (!includeInactive)
{
query = query.Where(s => s.IsActive);
}
var staffList = await query.ToListAsync();
await AttachUsersAsync(staffList);
return staffList.OrderBy(s => s.User?.UserName).ToList();
}
public async Task<Staff> CreateStaff(Staff staff, User user)
{
// Create User first (central)
user.UserId = Guid.NewGuid();
user.CreateDate = DateTime.UtcNow;
user.IsActive = true;
_central.Users.Add(user);
// Create Staff (tenant)
staff.StaffId = Guid.NewGuid();
staff.FKUserId = user.UserId;
staff.CreatedAt = DateTime.UtcNow;
staff.HireDate = staff.HireDate == default ? DateTime.UtcNow : staff.HireDate;
staff.IsActive = true;
// Verify Client exists (central)
var clientExists = await _central.Client.AnyAsync(c => c.ClientId == staff.FKClientId);
if (!clientExists)
{
throw new Exception("Client not found");
}
_context.Staff.Add(staff);
return staff;
}
public async Task<Staff> UpdateStaff(Staff staff)
{
var existingStaff = await _context.Staff
.FirstOrDefaultAsync(s => s.StaffId == staff.StaffId);
if (existingStaff == null)
{
throw new Exception("Staff not found");
}
// Update User fields if provided -- User lives in the central DB.
if (staff.User != null && (!string.IsNullOrEmpty(staff.User.UserName)
|| !string.IsNullOrEmpty(staff.User.Email)
|| !string.IsNullOrEmpty(staff.User.PhoneNumber)))
{
var centralUser = await _central.Users.FirstOrDefaultAsync(u => u.UserId == existingStaff.FKUserId);
if (centralUser != null)
{
if (!string.IsNullOrEmpty(staff.User.UserName))
centralUser.UserName = staff.User.UserName;
if (!string.IsNullOrEmpty(staff.User.Email))
centralUser.Email = staff.User.Email;
if (!string.IsNullOrEmpty(staff.User.PhoneNumber))
centralUser.PhoneNumber = staff.User.PhoneNumber;
existingStaff.User = centralUser;
}
}
// Update Staff fields if provided
if (!string.IsNullOrEmpty(staff.StaffPosition))
{
existingStaff.StaffPosition = staff.StaffPosition;
}
if (!string.IsNullOrEmpty(staff.Department))
{
existingStaff.Department = staff.Department;
}
// Update IsActive explicitly
existingStaff.IsActive = staff.IsActive;
return existingStaff;
}
public async Task<bool> DeleteStaff(Guid staffId, bool softDelete = true)
{
var staff = await _context.Staff.FindAsync(staffId);
if (staff == null)
{
return false;
}
if (softDelete)
{
// Soft delete: just mark as inactive
staff.IsActive = false;
}
else
{
// Hard delete: remove from database
_context.Staff.Remove(staff);
}
return true;
}
public async Task<StaffPayRate?> GetActivePayRateAsync(Guid staffId)
{
return await _context.StaffPayRates
.Where(r => r.FKStaffId == staffId && r.IsActive)
.OrderByDescending(r => r.EffectiveFrom)
.FirstOrDefaultAsync();
}
public async Task<StaffPayRate> AddPayRateAsync(StaffPayRate rate)
{
await _context.StaffPayRates.AddAsync(rate);
return rate;
}
public async Task<List<StaffPayment>> GetPaymentsAsync(Guid staffId)
{
return await _context.StaffPayments
.AsNoTracking()
.Where(p => p.FKStaffId == staffId)
.OrderByDescending(p => p.PaidAt)
.ToListAsync();
}
public async Task<StaffPayment> AddPaymentAsync(StaffPayment payment)
{
_context.StaffPayments.Add(payment);
return payment;
}
}
}
+57
View File
@@ -0,0 +1,57 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class TermsRepository : ITermsRepository
{
private readonly TenantDbContext _context;
public TermsRepository(TenantDbContext context)
{
_context = context;
}
public async Task<ClientTerms?> GetActiveByClientIdAsync(Guid clientId)
{
return await _context.ClientTerms
.AsNoTracking()
.Where(t => t.FKClientId == clientId && t.IsActive)
.OrderByDescending(t => t.Version)
.FirstOrDefaultAsync();
}
public Task<List<ClientTerms>> GetActiveForUpdateAsync(Guid clientId)
{
return _context.ClientTerms
.Where(t => t.FKClientId == clientId && t.IsActive)
.ToListAsync();
}
public async Task<int> GetLatestVersionAsync(Guid clientId)
{
return (await _context.ClientTerms
.Where(t => t.FKClientId == clientId)
.Select(t => (int?)t.Version)
.MaxAsync()) ?? 0;
}
public async Task AddTermsAsync(ClientTerms terms)
{
await _context.ClientTerms.AddAsync(terms);
}
public async Task AddAcceptanceAsync(CustomerTermsAcceptance acceptance)
{
await _context.CustomerTermsAcceptances.AddAsync(acceptance);
}
public async Task<CustomerTermsAcceptance?> GetAcceptanceAsync(Guid cusId, Guid clientId, int version)
{
return await _context.CustomerTermsAcceptances
.AsNoTracking()
.FirstOrDefaultAsync(a => a.FKCusId == cusId && a.FKClientId == clientId && a.Version == version);
}
}
}
+202
View File
@@ -0,0 +1,202 @@
using Fitness_core_BE.Models;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.DTOs.Ticket;
using Microsoft.EntityFrameworkCore;
using Fitness_core_BE.Exceptions;
namespace Fitness_core_BE.Repos
{
public class TicketRepository : ITicketRepository
{
private readonly CentralDbContext _dbContext;
public TicketRepository(CentralDbContext dbContext)
{
_dbContext = dbContext;
}
public async Task<Guid> GetTicketCatId (string type)
{
Guid TicketCatId = await _dbContext.TicketCategories
.Where(tc => tc.TicketCategoryName == type && tc.IsActive)
.Select(tc => tc.TicketCategoryId)
.FirstOrDefaultAsync();
return TicketCatId;
}
public async Task<List<TicketDto>> GetTicketListAsync(CancellationToken ct = default)
{
return await _dbContext.Tickets
.AsNoTracking()
.Include(t => t.User)
.Include(t => t.Admin)
.Include(t => t.TicketCategory)
.Where(t => t.IsActive)
.OrderByDescending(t => t.CreateDate)
.Select(t => new TicketDto
{
TicketId = t.TicketId,
MainReason = t.MainReason,
CreateDate = t.CreateDate,
UserId = t.FKUserId,
UserName = t.User != null ? t.User.UserName : "",
UserEmail = t.User != null ? t.User.Email : "",
AdminId = t.FKAssignerId,
AdminName = t.Admin != null ? t.Admin.AdminName : null,
TicketCategoryId = t.FKTicketCategoryId,
TicketCategoryName = t.TicketCategory != null ? t.TicketCategory.TicketCategoryName : null,
PriorityLevel = t.TicketCategory != null ? t.TicketCategory.PriorityLevel : (Fitness_core_BE.Models.Enums.PriorityLevel?)null,
UserRating = t.UserRating,
TicketStatus = t.TicketStatus
})
.ToListAsync(ct);
}
public async Task<List<TicketDto>> GetTicketByUserIdAsync(Guid userId, CancellationToken ct = default)
{
return await _dbContext.Tickets
.AsNoTracking()
.Include(t => t.User)
.Include(t => t.Admin)
.Include(t => t.TicketCategory)
.Where(t => t.FKUserId == userId && t.IsActive)
.OrderByDescending(t => t.CreateDate)
.Select(t => new TicketDto
{
TicketId = t.TicketId,
MainReason = t.MainReason,
CreateDate = t.CreateDate,
UserId = t.FKUserId,
UserName = t.User != null ? t.User.UserName : "",
UserEmail = t.User != null ? t.User.Email : "",
AdminId = t.FKAssignerId,
AdminName = t.Admin != null ? t.Admin.AdminName : null,
TicketCategoryId = t.FKTicketCategoryId,
TicketCategoryName = t.TicketCategory != null ? t.TicketCategory.TicketCategoryName : null,
PriorityLevel = t.TicketCategory != null ? t.TicketCategory.PriorityLevel : (Fitness_core_BE.Models.Enums.PriorityLevel?)null,
UserRating = t.UserRating,
TicketStatus = t.TicketStatus
})
.ToListAsync(ct);
}
public async Task<TicketDetailDto?> GetByTicketIdAsync(Guid id, CancellationToken ct = default)
{
var ticket = await _dbContext.Tickets
.AsNoTracking()
.Include(t => t.User)
.Include(t => t.Admin)
.Include(t => t.TicketCategory)
.Include(t => t.Messages)
.FirstOrDefaultAsync(t => t.TicketId == id && t.IsActive, ct);
if (ticket == null)
return null;
return new TicketDetailDto
{
TicketId = ticket.TicketId,
MainReason = ticket.MainReason,
CreateDate = ticket.CreateDate,
UserId = ticket.FKUserId,
UserName = ticket.User?.UserName ?? "",
UserEmail = ticket.User?.Email ?? "",
UserPhoneNumber = ticket.User?.PhoneNumber ?? "",
AdminId = ticket.FKAssignerId,
AdminName = ticket.Admin?.AdminName,
AdminPhoneNumber = ticket.Admin?.PhoneNumber,
TicketCategoryId = ticket.FKTicketCategoryId,
TicketCategoryName = ticket.TicketCategory?.TicketCategoryName,
PriorityLevel = ticket.TicketCategory?.PriorityLevel,
TicketStatus = ticket.TicketStatus,
UserRating = ticket.UserRating,
UserFeedback = ticket.UserFeedback,
FeedbackAt = ticket.FeedbackAt,
Messages = ticket.Messages?.Select(m => new TicketMessageDto
{
MessageId = m.MessageId,
IsSentByAdmin = m.IsSentByAdmin,
Message = m.Message,
FKDocumentId = m.FKDocumentId,
AttachmentURL = m.AttachmentURL,
CreateDate = m.CreateDate
}).OrderBy(m => m.CreateDate).ToList() ?? new List<TicketMessageDto>()
};
}
public Task<Ticket> CreateTicketAsync(Ticket ticket, CancellationToken ct = default)
{
_dbContext.Tickets.Add(ticket);
return Task.FromResult(ticket);
}
public async Task<Ticket> UpdateTicketAsync(TicketDetailDto ticketDto, CancellationToken ct = default)
{
Ticket ticket = await _dbContext.Tickets.FirstOrDefaultAsync(t => t.TicketId == ticketDto.TicketId, ct) ?? throw new NotFoundException($"Ticket with ID {ticketDto.TicketId} not found");
ticket.MainReason = ticketDto.MainReason;
ticket.FKTicketCategoryId = ticketDto.TicketCategoryId;
_dbContext.Tickets.Update(ticket);
return ticket;
}
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
{
var entity = await _dbContext.Tickets.FirstOrDefaultAsync(t => t.TicketId == id, ct);
if (entity == null)
return false;
entity.IsActive = false;
return true;
}
// Ticket category operations
public async Task<List<TicketCategory>> GetTicketCategoryListAsync(CancellationToken ct = default)
{
return await _dbContext.TicketCategories
.AsNoTracking()
.Where(tc => tc.IsActive)
.OrderBy(tc => tc.TicketCategoryName)
.ToListAsync(ct);
}
public async Task<TicketCategory?> GetTicketCategoryByIdAsync(Guid id, CancellationToken ct = default)
{
return await _dbContext.TicketCategories
.AsNoTracking()
.FirstOrDefaultAsync(tc => tc.TicketCategoryId == id && tc.IsActive, ct);
}
// Ticket message operations
public async Task<TicketMessage> SendMessageAsync(TicketMessage ticketMessage, CancellationToken ct = default)
{
_dbContext.TicketMessages.Add(ticketMessage);
return ticketMessage;
}
public async Task<Ticket> SubmitTicketFeedbackAsync(Guid ticketId, int rating, string? feedback, CancellationToken ct = default)
{
var entity = await _dbContext.Tickets.FirstOrDefaultAsync(t => t.TicketId == ticketId, ct) ?? throw new NotFoundException($"Ticket with ID {ticketId} not found");
entity.UserRating = rating;
if (feedback != null)
entity.UserFeedback = feedback;
entity.FeedbackAt = DateTime.Now;
_dbContext.Tickets.Update(entity);
return entity;
}
public async Task UpdateTicketStatusAsync(Guid ticketId, Fitness_core_BE.Models.Enums.TicketStatusEnum statusCode)
{
var ticket = await _dbContext.Tickets.FirstOrDefaultAsync(t => t.TicketId == ticketId);
if (ticket == null)
throw new NotFoundException($"Ticket with ID {ticketId} not found");
ticket.TicketStatus = statusCode;
}
}
}
+50
View File
@@ -0,0 +1,50 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class TimeBankCardRepository : ITimeBankCardRepository
{
private readonly TenantDbContext _context;
public TimeBankCardRepository(TenantDbContext context)
{
_context = context;
}
public async Task<TimeBankCard> CreateTimeBankCard(TimeBankCard card)
{
card.TimeBankCardId = Guid.NewGuid();
card.CreatedAt = DateTime.UtcNow;
card.IsActive = true;
_context.TimeBankCards.Add(card);
return card;
}
public Task<TimeBankCard?> GetById(Guid timeBankCardId)
=> _context.TimeBankCards.FirstOrDefaultAsync(c => c.TimeBankCardId == timeBankCardId);
public Task<TimeBankCard?> GetByRfidTagId(string rfidTagId)
=> _context.TimeBankCards
.Include(c => c.ActiveSessionFacility)
.FirstOrDefaultAsync(c => c.RfidTagId == rfidTagId && c.IsActive);
public Task<List<TimeBankCard>> GetByCustomerId(Guid customerId)
=> _context.TimeBankCards
.Include(c => c.ActiveSessionFacility)
.Where(c => c.FKCustomerId == customerId)
.OrderByDescending(c => c.PurchasedAt)
.ToListAsync();
public async Task<TimeBankCardUsageLog> AddUsageLog(TimeBankCardUsageLog log)
{
log.TimeBankCardUsageLogId = Guid.NewGuid();
log.CreatedAt = DateTime.UtcNow;
_context.TimeBankCardUsageLogs.Add(log);
return log;
}
}
}
+459
View File
@@ -0,0 +1,459 @@
using Fitness_core_BE.Models;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.DTOs.Workout;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Repos
{
public class WorkoutRepository : IWorkoutRepository
{
// Client-authored (non-template) workout plans + all CustomerWorkout assignment rows.
private readonly TenantDbContext _dbContext;
// Admin-authored templates (WorkoutCategories, and WorkoutPlans/Items/Exercises where IsTemplate=true).
private readonly CentralDbContext _central;
public WorkoutRepository(TenantDbContext dbContext, CentralDbContext central)
{
_dbContext = dbContext;
_central = central;
}
// Workout Category operations -- global lookup, admin-owned, always central.
public async Task<List<WorkoutCategoryDto>> GetWorkoutCategoryListAsync(CancellationToken ct = default)
{
return await _central.WorkoutCategories
.AsNoTracking()
.Where(wc => wc.IsActive)
.OrderBy(wc => wc.WorkoutCategoryName)
.Select(wc => new WorkoutCategoryDto
{
WorkoutCategoryId = wc.WorkoutCategoryId,
Name = wc.WorkoutCategoryName,
Description = wc.Description,
IsActive = wc.IsActive,
CreateDate = wc.CreateDate
})
.ToListAsync(ct);
}
// Categories are central; the plan row is tenant-side with no enforceable cross-DB
// FK, so the id has to be checked here rather than left to the database.
public Task<bool> WorkoutCategoryExistsAsync(Guid workoutCategoryId, CancellationToken ct = default)
{
return _central.WorkoutCategories
.AsNoTracking()
.AnyAsync(wc => wc.WorkoutCategoryId == workoutCategoryId && wc.IsActive, ct);
}
public async Task<WorkoutCategory> CreateWorkoutCategoryAsync(WorkoutCategory workoutCategory, CancellationToken ct = default)
{
await _central.WorkoutCategories.AddAsync(workoutCategory, ct);
return workoutCategory;
}
// Workout Plan operations
public async Task<List<WorkoutPlanDto>> GetWorkoutPlanListAsync(string userType, Guid clientId, CancellationToken ct = default)
{
// A customer browsing sees admin-authored templates (central); the coach/owner
// dashboard only manages this gym's own custom plans (tenant) -- disjoint sets
// post-split, never merged in one query.
if (userType == "Customer")
{
return await _central.WorkoutPlans
.AsNoTracking()
.Include(wp => wp.WorkoutCategory)
.Where(wp => wp.IsActive && wp.IsTemplate)
.OrderByDescending(wp => wp.CreateDate)
.Select(wp => new WorkoutPlanDto
{
WorkoutPlanId = wp.WorkoutPlanId,
Name = wp.WorkoutPlanName,
Description = wp.Description,
IsTemplate = wp.IsTemplate,
WorkoutCategoryId = wp.FKWorkoutCategoryId,
WorkoutCategoryName = wp.WorkoutCategory != null ? wp.WorkoutCategory.WorkoutCategoryName : "",
ClientId = wp.FKClientId,
ClientName = null,
IsActive = wp.IsActive,
CreateDate = wp.CreateDate,
AssignedCustomers = new List<CustomerWorkoutDto>()
})
.ToListAsync(ct);
}
// Categories live centrally, so the tenant query can't join them -- resolve the
// names in a second round-trip and stitch them on below.
var categoryNames = await _central.WorkoutCategories
.AsNoTracking()
.ToDictionaryAsync(wc => wc.WorkoutCategoryId, wc => wc.WorkoutCategoryName, ct);
var tenantPlans = await _dbContext.WorkoutPlans
.AsNoTracking()
.Include(wp => wp.CustomerWorkouts.Where(cw => cw.IsActive))
.ThenInclude(cw => cw.TenantCustomerLink)
.Where(wp => wp.IsActive && !wp.IsTemplate && wp.FKClientId == clientId)
.OrderByDescending(wp => wp.CreateDate)
.Select(wp => new WorkoutPlanDto
{
WorkoutPlanId = wp.WorkoutPlanId,
Name = wp.WorkoutPlanName,
Description = wp.Description,
IsTemplate = wp.IsTemplate,
WorkoutCategoryId = wp.FKWorkoutCategoryId,
WorkoutCategoryName = "",
ClientId = wp.FKClientId,
ClientName = null,
IsActive = wp.IsActive,
CreateDate = wp.CreateDate,
AssignedCustomers = wp.CustomerWorkouts
.Where(cw => cw.IsActive)
.Select(cw => new CustomerWorkoutDto
{
CustomerWorkoutId = cw.CustomerWorkoutId,
CustomerId = cw.FKCustomerId,
CustomerName = cw.TenantCustomerLink != null ? cw.TenantCustomerLink.DisplayName : null,
WorkoutPlanId = cw.FKWorkoutPlanId,
IsActive = cw.IsActive,
IsFollowing = cw.IsFollowing,
AssignedDate = cw.AssignedDate,
CompletedDate = cw.CompletedDate,
Notes = cw.Notes
}).ToList()
})
.ToListAsync(ct);
foreach (var plan in tenantPlans)
{
plan.WorkoutCategoryName =
categoryNames.TryGetValue(plan.WorkoutCategoryId, out var name) ? name : "";
}
return tenantPlans;
}
public async Task<WorkoutPlanDetailDto?> GetWorkoutPlanByIdAsync(Guid id, CancellationToken ct = default)
{
// The id could belong to either bucket (an admin template or a client-owned
// plan) -- try central first, fall back to tenant.
var centralPlan = await _central.WorkoutPlans
.AsNoTracking()
.Include(wp => wp.WorkoutCategory)
.Include(wp => wp.WorkoutItems)
.ThenInclude(wi => wi.WorkoutExercises)
.FirstOrDefaultAsync(wp => wp.WorkoutPlanId == id && wp.IsActive, ct);
if (centralPlan != null)
return MapDetail(centralPlan, Array.Empty<CustomerWorkout>());
var tenantPlan = await _dbContext.WorkoutPlans
.AsNoTracking()
.Include(wp => wp.CustomerWorkouts.Where(cw => cw.IsActive))
.ThenInclude(cw => cw.TenantCustomerLink)
.Include(wp => wp.WorkoutItems)
.ThenInclude(wi => wi.WorkoutExercises)
.FirstOrDefaultAsync(wp => wp.WorkoutPlanId == id && wp.IsActive, ct);
if (tenantPlan == null)
return null;
var detail = MapDetail(tenantPlan, tenantPlan.CustomerWorkouts.Where(cw => cw.IsActive).ToList());
// Category is central-only, so it can't be Include()d from the tenant context.
detail.WorkoutCategoryName = await _central.WorkoutCategories
.AsNoTracking()
.Where(wc => wc.WorkoutCategoryId == tenantPlan.FKWorkoutCategoryId)
.Select(wc => wc.WorkoutCategoryName)
.FirstOrDefaultAsync(ct) ?? "";
return detail;
}
private static WorkoutPlanDetailDto MapDetail(WorkoutPlan workoutPlan, IReadOnlyCollection<CustomerWorkout> assigned)
{
return new WorkoutPlanDetailDto
{
WorkoutPlanId = workoutPlan.WorkoutPlanId,
Name = workoutPlan.WorkoutPlanName,
Description = workoutPlan.Description,
IsTemplate = workoutPlan.IsTemplate,
WorkoutCategoryId = workoutPlan.FKWorkoutCategoryId,
WorkoutCategoryName = workoutPlan.WorkoutCategory != null ? workoutPlan.WorkoutCategory.WorkoutCategoryName : "",
ClientId = workoutPlan.FKClientId,
ClientName = null,
IsActive = workoutPlan.IsActive,
CreateDate = workoutPlan.CreateDate,
WorkoutItems = workoutPlan.WorkoutItems
.Where(wi => wi.IsActive)
.Select(MapWorkoutItem)
.ToList(),
AssignedCustomers = assigned
.Select(cw => new CustomerWorkoutDto
{
CustomerWorkoutId = cw.CustomerWorkoutId,
CustomerId = cw.FKCustomerId,
CustomerName = cw.TenantCustomerLink?.DisplayName,
WorkoutPlanId = cw.FKWorkoutPlanId,
IsActive = cw.IsActive,
IsFollowing = cw.IsFollowing,
AssignedDate = cw.AssignedDate,
CompletedDate = cw.CompletedDate,
Notes = cw.Notes
}).ToList()
};
}
// Shared WorkoutItem -> WorkoutItemDto mapping (including structured exercises) for the
// client/coach-facing read paths, which operate on already-materialized entity graphs
// rather than server-translated queries.
private static WorkoutItemDto MapWorkoutItem(WorkoutItem wi)
{
var exerciseDtos = wi.WorkoutExercises
.Where(we => we.IsActive)
.OrderBy(we => we.SortOrder)
.Select(we => new WorkoutExerciseDto
{
WorkoutExerciseId = we.WorkoutExerciseId,
Name = we.Name,
MuscleGroup = we.MuscleGroup,
Sets = we.Sets,
Reps = we.Reps,
Description = we.Description,
BreathIn = we.BreathIn,
BreathOut = we.BreathOut,
VideoUrl = we.VideoUrl,
SortOrder = we.SortOrder,
IsCompleted = false
})
.ToList();
return new WorkoutItemDto
{
WorkoutItemId = wi.WorkoutItemId,
ExerciseDay = wi.ExerciseDay,
Description = wi.Description,
Exercises = wi.Exercises,
HowToDo = wi.HowToDo,
DoURL = wi.DoURL,
Duration = wi.Duration,
WormUp = wi.WormUp,
Cooldown = wi.Cooldown,
WorkoutPlanId = wi.FKWorkoutPlanId,
IsActive = wi.IsActive,
CreateDate = wi.CreateDate,
UpdatedAt = wi.UpdatedAt,
WorkoutExercises = exerciseDtos,
TotalExercises = exerciseDtos.Count,
CompletedExercises = 0,
IsCompleted = false
};
}
public async Task<List<WorkoutPlanDetailDto>> GetWorkoutPlansByCustomerIdAsync(Guid customerId, CancellationToken ct = default)
{
// CustomerWorkout (tenant) records which plans this customer follows at this gym;
// each followed plan is resolved from whichever bucket it actually lives in.
var follows = await _dbContext.CustomerWorkouts
.AsNoTracking()
.Where(cw => cw.FKCustomerId == customerId && cw.IsActive)
.OrderByDescending(cw => cw.AssignedDate)
.Select(cw => cw.FKWorkoutPlanId)
.Distinct()
.ToListAsync(ct);
var result = new List<WorkoutPlanDetailDto>();
foreach (var workoutPlanId in follows)
{
var detail = await GetWorkoutPlanByIdAsync(workoutPlanId, ct);
if (detail != null && detail.IsActive)
result.Add(detail);
}
return result;
}
public async Task<WorkoutPlan?> GetWorkoutPlanEntityByIdAsync(Guid id, CancellationToken ct = default)
{
return await _dbContext.WorkoutPlans
.FirstOrDefaultAsync(wp => wp.WorkoutPlanId == id && wp.IsActive, ct);
}
public async Task<WorkoutPlan> CreateWorkoutPlanAsync(WorkoutPlan workoutPlan, CancellationToken ct = default)
{
await _dbContext.WorkoutPlans.AddAsync(workoutPlan, ct);
return workoutPlan;
}
public async Task<WorkoutPlan> UpdateWorkoutPlanAsync(WorkoutPlan workoutPlan, CancellationToken ct = default)
{
_dbContext.WorkoutPlans.Update(workoutPlan);
return workoutPlan;
}
public async Task<bool> DeleteWorkoutPlanAsync(Guid id, CancellationToken ct = default)
{
var workoutPlan = await _dbContext.WorkoutPlans.FindAsync(new object[] { id }, ct);
if (workoutPlan == null)
{
return false;
}
workoutPlan.IsActive = false;
_dbContext.WorkoutPlans.Update(workoutPlan);
return true;
}
public async Task<WorkoutItem?> GetWorkoutItemByIdAsync(Guid id, CancellationToken ct = default)
{
return await _dbContext.WorkoutItems
.FirstOrDefaultAsync(wi => wi.WorkoutItemId == id && wi.IsActive, ct);
}
// Workout Item operations
public async Task<List<WorkoutItemDto>> GetWorkoutItemsByWorkoutPlanIdAsync(Guid workoutPlanId, CancellationToken ct = default)
{
var items = await _dbContext.WorkoutItems
.AsNoTracking()
.Include(wi => wi.WorkoutExercises)
.Where(wi => wi.FKWorkoutPlanId == workoutPlanId && wi.IsActive)
.OrderBy(wi => wi.CreateDate)
.ToListAsync(ct);
return items.Select(MapWorkoutItem).ToList();
}
public async Task<WorkoutItem> CreateWorkoutItemAsync(WorkoutItem workoutItem, CancellationToken ct = default)
{
await _dbContext.WorkoutItems.AddAsync(workoutItem, ct);
return workoutItem;
}
public async Task<WorkoutItem> UpdateWorkoutItemAsync(WorkoutItem workoutItem, CancellationToken ct = default)
{
_dbContext.WorkoutItems.Update(workoutItem);
return workoutItem;
}
public async Task DeleteWorkoutItemAsync(Guid id, CancellationToken ct = default)
{
var workoutItem = await _dbContext.WorkoutItems.FindAsync(new object[] { id }, ct);
if (workoutItem != null)
{
// Soft delete by setting IsActive to false
workoutItem.IsActive = false;
_dbContext.WorkoutItems.Update(workoutItem);
}
}
// Customer Workout Assignment operations
// Assigning a plan puts the customer on it the same way self-follow does: only one
// plan can be "followed" at a time, and an assigned plan must show up under the
// customer's Followed tab immediately.
public async Task<CustomerWorkout> AssignWorkoutToCustomerAsync(CustomerWorkout customerWorkout, CancellationToken ct = default)
{
var currentFollow = await _dbContext.CustomerWorkouts
.FirstOrDefaultAsync(cw => cw.FKCustomerId == customerWorkout.FKCustomerId && cw.IsFollowing, ct);
if (currentFollow != null && currentFollow.FKWorkoutPlanId != customerWorkout.FKWorkoutPlanId)
{
currentFollow.IsFollowing = false;
_dbContext.CustomerWorkouts.Update(currentFollow);
}
var existing = await _dbContext.CustomerWorkouts
.FirstOrDefaultAsync(cw => cw.FKCustomerId == customerWorkout.FKCustomerId && cw.FKWorkoutPlanId == customerWorkout.FKWorkoutPlanId, ct);
if (existing != null)
{
existing.IsFollowing = true;
existing.IsActive = true;
existing.AssignedDate = customerWorkout.AssignedDate;
existing.Notes = customerWorkout.Notes;
_dbContext.CustomerWorkouts.Update(existing);
return existing;
}
customerWorkout.IsFollowing = true;
await _dbContext.CustomerWorkouts.AddAsync(customerWorkout, ct);
return customerWorkout;
}
public async Task<bool> UnassignWorkoutFromCustomerAsync(Guid customerWorkoutId, CancellationToken ct = default)
{
var customerWorkout = await _dbContext.CustomerWorkouts.FindAsync(new object[] { customerWorkoutId }, ct);
if (customerWorkout == null)
{
return false;
}
// Soft delete by setting IsActive to false
customerWorkout.IsActive = false;
_dbContext.CustomerWorkouts.Update(customerWorkout);
return true;
}
// A followed plan id may name a central template or a tenant plan, so names are
// resolved from both buckets rather than through a (now un-enforced) navigation.
private async Task<Dictionary<Guid, string>> ResolvePlanNamesAsync(
IReadOnlyCollection<Guid> planIds, CancellationToken ct)
{
if (planIds.Count == 0)
return new Dictionary<Guid, string>();
var names = await _dbContext.WorkoutPlans
.AsNoTracking()
.Where(wp => planIds.Contains(wp.WorkoutPlanId))
.ToDictionaryAsync(wp => wp.WorkoutPlanId, wp => wp.WorkoutPlanName, ct);
var missing = planIds.Where(id => !names.ContainsKey(id)).ToList();
if (missing.Count > 0)
{
var centralNames = await _central.WorkoutPlans
.AsNoTracking()
.Where(wp => missing.Contains(wp.WorkoutPlanId))
.ToDictionaryAsync(wp => wp.WorkoutPlanId, wp => wp.WorkoutPlanName, ct);
foreach (var pair in centralNames)
names[pair.Key] = pair.Value;
}
return names;
}
private async Task<List<CustomerWorkoutDto>> BuildCustomerWorkoutDtosAsync(
IQueryable<CustomerWorkout> query, CancellationToken ct)
{
var rows = await query
.AsNoTracking()
.Include(cw => cw.TenantCustomerLink)
.OrderByDescending(cw => cw.AssignedDate)
.Select(cw => new CustomerWorkoutDto
{
CustomerWorkoutId = cw.CustomerWorkoutId,
CustomerId = cw.FKCustomerId,
CustomerName = cw.TenantCustomerLink != null ? cw.TenantCustomerLink.DisplayName : null,
WorkoutPlanId = cw.FKWorkoutPlanId,
WorkoutPlanName = null,
IsActive = cw.IsActive,
IsFollowing = cw.IsFollowing,
AssignedDate = cw.AssignedDate,
CompletedDate = cw.CompletedDate,
Notes = cw.Notes
})
.ToListAsync(ct);
var names = await ResolvePlanNamesAsync(
rows.Select(r => r.WorkoutPlanId).Distinct().ToList(), ct);
foreach (var row in rows)
row.WorkoutPlanName = names.TryGetValue(row.WorkoutPlanId, out var name) ? name : null;
return rows;
}
public Task<List<CustomerWorkoutDto>> GetCustomerWorkoutsByPlanIdAsync(Guid workoutPlanId, CancellationToken ct = default)
=> BuildCustomerWorkoutDtosAsync(
_dbContext.CustomerWorkouts.Where(cw => cw.FKWorkoutPlanId == workoutPlanId && cw.IsActive), ct);
public Task<List<CustomerWorkoutDto>> GetCustomerWorkoutsByCustomerIdAsync(Guid customerId, CancellationToken ct = default)
=> BuildCustomerWorkoutDtosAsync(
_dbContext.CustomerWorkouts.Where(cw => cw.FKCustomerId == customerId && cw.IsActive), ct);
}
}