first commit
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user