first commit

This commit is contained in:
Dhananjaya99
2026-09-28 11:44:26 +05:30
parent 721743ae6b
commit bffc9b1686
424 changed files with 46474 additions and 0 deletions
+803
View File
@@ -0,0 +1,803 @@
using Fitness_core_BE.DTOs;
using Fitness_core_BE.DTOs.AccessDevice;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using System.Globalization;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
namespace Fitness_core_BE.Services
{
/// <summary>
/// Owns the cloud-side desired state for physical access. User identity is scoped by
/// client in AccessProfile; terminal-specific state lives in AccessDeviceBinding.
/// Fingerprint template bytes never leave the terminal.
/// </summary>
public class AccessDeviceService : IAccessDeviceService
{
private readonly IUnitOfWork _UoW;
private readonly IAccessDeviceRepo _accessDeviceRepo;
private readonly IAttendanceRepository _attendanceRepo;
private readonly IDeviceCommandRepo _deviceCommandRepo;
private readonly IPairingCodeRepo _pairingCodeRepo;
public AccessDeviceService(
IUnitOfWork unitOfWork,
IAccessDeviceRepo accessDeviceRepo,
IAttendanceRepository attendanceRepo,
IDeviceCommandRepo deviceCommandRepo,
IPairingCodeRepo pairingCodeRepo)
{
_UoW = unitOfWork;
_accessDeviceRepo = accessDeviceRepo;
_attendanceRepo = attendanceRepo;
_deviceCommandRepo = deviceCommandRepo;
_pairingCodeRepo = pairingCodeRepo;
}
public async Task<object> AddDevice(object payload)
{
await _UoW.BeginAsync();
var data = DeserializePayload.Deserialize(payload);
try
{
var device = new AccessDevice
{
DeviceId = Guid.NewGuid(),
DeviceName = DataExtractor.GetRequiredString(data, "DeviceName"),
FKClientId = DataExtractor.GetRequiredGuid(data, "FKClientId")
};
await _accessDeviceRepo.AddDeviceAsync(device);
await _UoW.CommitAsync();
return new { Message = "Device added", device.DeviceId };
}
catch
{
await _UoW.RollbackAsync();
throw;
}
}
public async Task<object> RemoveDevice(object payload)
{
await _UoW.BeginAsync();
var data = DeserializePayload.Deserialize(payload);
try
{
var clientId = DataExtractor.GetRequiredGuid(data, "clientId");
var result = await _accessDeviceRepo.RemoveDeviceAsync(clientId);
await _UoW.CommitAsync();
return new { Message = $"Device {result.DeviceId} disabled", result.DeviceId };
}
catch
{
await _UoW.RollbackAsync();
throw;
}
}
public async Task<object> GeneratePairingCode(object payload)
{
var data = DeserializePayload.Deserialize(payload);
var clientId = DataExtractor.GetRequiredGuid(data, "clientId");
var pairingCode = new PairingCode
{
Id = Guid.NewGuid(),
Code = GenerateShortCode(),
FKClientId = clientId,
ExpiresAt = DateTime.UtcNow.AddMinutes(15),
IsUsed = false,
CreatedAt = DateTime.UtcNow
};
await _UoW.BeginAsync();
try
{
await _pairingCodeRepo.AddAsync(pairingCode);
await _UoW.CommitAsync();
return new { pairingCode.Code, pairingCode.ExpiresAt };
}
catch
{
await _UoW.RollbackAsync();
throw;
}
}
/// <summary>
/// Compatibility entry point for old admin callers. New flows create an
/// AccessProfile automatically when the customer/staff record is created.
/// </summary>
public async Task<object> AddUser(CustomerAccess requested)
{
var device = await _accessDeviceRepo.GetDeviceByDId(requested.FKDeviceId);
var customer = await _accessDeviceRepo
.GetCustomerByIdAsync(requested.FKCustomerId)
?? throw new NotFoundException("Customer not found.");
await _UoW.BeginAsync();
try
{
var profile = await EnsureAccessProfileAsync(
device.FKClientId, customer.FKUserId, "Customer",
requested.Name ?? customer.CusName ?? string.Empty, queueProvisioning: false);
var binding = await EnsureBindingAsync(profile, device);
await QueueProfileCommandAsync(profile, binding, "UpsertPerson");
await _UoW.CommitAsync();
return new
{
accessProfileId = profile.AccessProfileId,
accessNumber = binding.DeviceEmployeeNo,
Message = "Access user queued for the on-prem agent."
};
}
catch
{
await _UoW.RollbackAsync();
throw;
}
}
public async Task<AccessProfile> EnsureAccessProfileAsync(
Guid clientId,
Guid userId,
string personType,
string displayName,
bool queueProvisioning = true)
{
var profile = await _accessDeviceRepo.GetAccessProfileAsync(clientId, userId);
if (profile == null)
{
profile = new AccessProfile
{
FKClientId = clientId,
FKUserId = userId,
PersonType = personType,
DisplayName = displayName,
DesiredAccessState = "Enabled",
DesiredVersion = 1,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
await _accessDeviceRepo.AddAccessProfileAsync(profile);
// AccessProfileId is the stable numeric terminal employee number.
await _UoW.SaveChangesAsync();
}
else
{
profile.PersonType = personType;
profile.DisplayName = displayName;
profile.UpdatedAt = DateTime.UtcNow;
}
if (queueProvisioning)
await QueueProvisioningForActiveDevicesAsync(profile);
return profile;
}
public async Task ProvisionProfilesToDeviceAsync(AccessDevice device)
{
var profiles = await _accessDeviceRepo
.GetAccessProfilesByClientAsync(device.FKClientId);
foreach (var profile in profiles)
{
var binding = await EnsureBindingAsync(profile, device);
if (binding.ProvisionStatus != "Synced")
await QueueProfileCommandAsync(profile, binding, "UpsertPerson");
}
}
public async Task<object> SetUserAccessAsync(Guid clientId, Guid userId, bool enabled, string? reason)
{
await _UoW.BeginAsync();
try
{
var profile = await _accessDeviceRepo
.GetAccessProfileAsync(clientId, userId)
?? throw new NotFoundException("No access profile exists for this user at this gym.");
profile.DesiredAccessState = enabled ? "Enabled" : "Disabled";
profile.DesiredVersion++;
profile.LockReason = enabled ? null : reason;
profile.LockedAt = enabled ? null : DateTime.UtcNow;
profile.UpdatedAt = DateTime.UtcNow;
// Include terminals added after the person was originally onboarded.
await QueueProvisioningForActiveDevicesAsync(profile);
foreach (var binding in profile.DeviceBindings)
{
if (!await IsDeviceCommandableAsync(binding.FKDeviceId))
continue;
binding.ProvisionStatus = "Pending";
binding.LastError = null;
binding.UpdatedAt = DateTime.UtcNow;
await SupersedePendingStateCommandsAsync(binding.AccessDeviceBindingId);
await QueueProfileCommandAsync(profile, binding, "SetAccessState");
}
await _UoW.CommitAsync();
return (await GetAccessStatusAsync(clientId, userId))!;
}
catch
{
await _UoW.RollbackAsync();
throw;
}
}
public async Task<object> SetCustomerAccessAsync(
Guid clientId, Guid customerId, bool enabled, string? reason)
{
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId)
?? throw new NotFoundException("Customer not found.");
var belongsToGym = await _accessDeviceRepo
.IsActiveCustomerMemberAsync(clientId, customerId);
if (!belongsToGym)
throw new ForbiddenException("This customer does not belong to your gym.");
var status = await SetUserAccessAsync(clientId, customer.FKUserId, enabled, reason);
// A couple/group plan is one shared entitlement, so the whole party follows the
// member that was acted on -- locking only the primary would leave the partner
// badging in on a plan that is locked, and unlocking only the primary would leave
// the partner stranded after the reason for the lock is gone.
var coMemberIds = await _accessDeviceRepo
.GetCoMembersOnSharedSubscriptionsAsync(clientId, customerId);
foreach (var coMemberId in coMemberIds)
{
var coMember = await _accessDeviceRepo.GetCustomerByIdAsync(coMemberId);
if (coMember == null)
continue;
// Best-effort: a co-member without an access profile yet (never onboarded to
// a device) must not fail the lock that was actually requested.
try
{
await SetUserAccessAsync(clientId, coMember.FKUserId, enabled, reason);
}
catch (NotFoundException)
{
}
}
return status;
}
public async Task<object> SetCustomerFacePhotoAsync(
Guid clientId, Guid customerId, string base64Jpeg)
{
// Validate before opening a transaction: a rejected photo should cost nothing.
var photo = FacePhotoValidator.ValidateBase64(base64Jpeg);
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId)
?? throw new NotFoundException("Customer not found.");
var belongsToGym = await _accessDeviceRepo
.IsActiveCustomerMemberAsync(clientId, customerId);
if (!belongsToGym)
throw new ForbiddenException("This customer does not belong to your gym.");
await _UoW.BeginAsync();
try
{
var profile = await _accessDeviceRepo
.GetAccessProfileAsync(clientId, customer.FKUserId)
?? throw new NotFoundException(
"No access profile exists for this member at this gym.");
profile.FacePhotoStatus = "Pending";
profile.FacePhotoError = null;
profile.FacePhotoUpdatedAt = DateTime.UtcNow;
profile.DesiredVersion++;
profile.UpdatedAt = DateTime.UtcNow;
// Include terminals added after the member was originally onboarded.
await QueueProvisioningForActiveDevicesAsync(profile);
var queued = 0;
var payload = Convert.ToBase64String(photo.Bytes);
foreach (var binding in profile.DeviceBindings)
{
if (!await IsDeviceCommandableAsync(binding.FKDeviceId))
continue;
await SupersedePendingFacePhotoCommandsAsync(binding.AccessDeviceBindingId);
await QueueFacePhotoCommandAsync(profile, binding, payload);
queued++;
}
if (queued == 0)
{
// Nothing can carry the photo to a terminal, so don't leave the member
// sitting on "Pending" forever with no command in flight.
profile.FacePhotoStatus = "Failed";
profile.FacePhotoError =
"No reachable access terminal is currently paired with this gym.";
}
await _UoW.CommitAsync();
return new
{
accessProfileId = profile.AccessProfileId,
facePhotoStatus = profile.FacePhotoStatus,
queuedToDevices = queued,
width = photo.Width,
height = photo.Height,
sizeBytes = photo.Bytes.Length
};
}
catch
{
await _UoW.RollbackAsync();
throw;
}
}
// Kept separate from QueueProfileCommandAsync: the face payload is large and is
// deliberately purged after the terminal confirms, which the person/state commands
// must never be.
private async Task QueueFacePhotoCommandAsync(
AccessProfile profile, AccessDeviceBinding binding, string base64Jpeg)
{
var payloadJson = JsonSerializer.Serialize(new
{
bindingId = binding.AccessDeviceBindingId,
accessProfileId = profile.AccessProfileId,
employeeNo = binding.DeviceEmployeeNo,
name = profile.DisplayName,
faceJpegBase64 = base64Jpeg,
desiredVersion = profile.DesiredVersion
});
await _deviceCommandRepo.AddAsync(new DeviceCommand
{
Id = Guid.NewGuid(),
FKClientId = profile.FKClientId,
FKDeviceId = binding.FKDeviceId,
FKAccessDeviceBindingId = binding.AccessDeviceBindingId,
CommandType = "UpsertFacePhoto",
PayloadJson = payloadJson,
DesiredVersion = profile.DesiredVersion,
Status = "Pending",
MaxAttempts = 3,
NextAttemptAt = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow
});
}
// A newer photo replaces an older one that has not reached the device yet, so the
// stale payload is dropped rather than left queued (and holding an image).
private async Task SupersedePendingFacePhotoCommandsAsync(Guid bindingId)
{
var stale = await _deviceCommandRepo
.GetPendingFacePhotoCommandsAsync(bindingId);
foreach (var command in stale)
{
command.Status = "Superseded";
command.PayloadJson = string.Empty;
command.ProcessedAt = DateTime.UtcNow;
}
}
public async Task<object> SetStaffAccessAsync(
Guid clientId, Guid staffId, bool enabled, string? reason)
{
var staff = await _accessDeviceRepo.GetStaffByIdAsync(clientId, staffId)
?? throw new NotFoundException("Staff member not found in this gym.");
return await SetUserAccessAsync(clientId, staff.FKUserId, enabled, reason);
}
public async Task<bool> WhiteListMember(Guid customerId)
{
var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId);
await SetUserAccessAsync(clientId, userId, enabled: true, reason: null);
return true;
}
public async Task<bool> BlackListMember(Guid customerId)
{
var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId);
await SetUserAccessAsync(clientId, userId, enabled: false, reason: "Manual");
return true;
}
public async Task<bool> LockCustomer(Guid customerId, string reason)
{
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId);
if (customer == null)
return false;
var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId);
await SetUserAccessAsync(clientId, userId, enabled: false, reason);
customer.IsLocked = true;
customer.LockReason = reason;
customer.LockedAt = DateTime.UtcNow;
await _UoW.SaveChangesAsync();
return true;
}
public async Task<bool> UnlockCustomer(Guid customerId)
{
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId);
if (customer == null)
return false;
var (clientId, userId) = await ResolveCustomerAccessOwnerAsync(customerId);
await SetUserAccessAsync(clientId, userId, enabled: true, reason: null);
customer.IsLocked = false;
customer.LockReason = null;
customer.LockedAt = null;
await _UoW.SaveChangesAsync();
return true;
}
public Task<Customer> UpdateDeviceProfile(Customer customer) =>
throw new NotImplementedException();
public async Task<int> SaveAttendanceRecords(Guid clientId, List<AttendanceDTO> attendanceList)
{
if (attendanceList.Count == 0)
return 0;
// The source key is stable across JSON DateTime kind conversion and
// database round-trips. It also gives the database a final idempotency
// guard when an agent retries after an ambiguous HTTP response.
var keyedEvents = attendanceList
.OrderBy(a => a.EventTime)
.Select(dto => new
{
Dto = dto,
SourceEventKey = BuildSourceEventKey(
clientId, dto.DeviceId, dto.DeviceEmployeeNo, dto.EventTime, dto.EventType)
})
.ToList();
var candidateKeys = keyedEvents
.Select(e => e.SourceEventKey)
.Distinct()
.ToList();
var existingKeys = await _attendanceRepo.GetSourceEventKeysAsync(candidateKeys);
var newRecords = new List<Attendance>();
var seenKeys = new HashSet<string>(existingKeys);
foreach (var keyedEvent in keyedEvents)
{
if (!seenKeys.Add(keyedEvent.SourceEventKey))
continue;
var dto = keyedEvent.Dto;
newRecords.Add(new Attendance
{
AttendanceId = Guid.NewGuid(),
FKClientId = clientId,
FKDeviceId = dto.DeviceId,
FKCustomerId = dto.CustomerId,
FKUserId = dto.UserId,
FKAccessProfileId = dto.AccessProfileId,
DeviceUserId = dto.DeviceUserId,
DeviceEmployeeNo = dto.DeviceEmployeeNo,
SourceEventKey = keyedEvent.SourceEventKey,
EventTime = dto.EventTime,
EventType = dto.EventType
});
}
if (newRecords.Count == 0)
return 0;
await _UoW.BeginAsync();
try
{
await _attendanceRepo.AddAttendancesAsync(newRecords);
await _UoW.CommitAsync();
return newRecords.Count;
}
catch
{
await _UoW.RollbackAsync();
// The agent advances its per-device watermark only after an HTTP success.
// Surface persistence failures so it retries instead of losing events.
throw;
}
}
public Task<List<UnlinkedDeviceEnrollment>> GetUnlinkedFingerprintsAsync(Guid clientId) =>
_accessDeviceRepo.GetUnlinkedEnrollmentsByClientAsync(clientId);
public async Task<AccessProfile> LinkFingerprintAsync(
Guid clientId, Guid customerId, Guid unlinkedEnrollmentId)
{
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId)
?? throw new NotFoundException("Customer not found.");
var belongsToGym = await _accessDeviceRepo
.IsActiveCustomerMemberAsync(clientId, customerId);
if (!belongsToGym)
throw new ForbiddenException("This customer does not belong to your gym.");
return await LinkDevicePersonAsync(
clientId, customer.FKUserId, "Customer",
customer.CusName ?? string.Empty, unlinkedEnrollmentId);
}
public async Task<AccessProfile> LinkDevicePersonAsync(
Guid clientId,
Guid userId,
string personType,
string displayName,
Guid unlinkedEnrollmentId)
{
var enrollment = await _accessDeviceRepo
.GetUnlinkedEnrollmentByIdAsync(unlinkedEnrollmentId)
?? throw new NotFoundException("Unlinked device person not found.");
if (enrollment.LinkedAt != null)
throw new BadRequestException("This device person has already been linked.");
if (enrollment.AccessDevice.FKClientId != clientId)
throw new ForbiddenException("This device person does not belong to your gym.");
var employeeNo = NormalizeEmployeeNo(enrollment);
var occupied = await _accessDeviceRepo.IsDeviceEmployeeNoOccupiedAsync(
enrollment.FKDeviceId, employeeNo, userId);
if (occupied)
throw new BadRequestException($"Device user {employeeNo} is already linked to another person.");
await _UoW.BeginAsync();
try
{
var profile = await EnsureAccessProfileAsync(
clientId, userId, personType, displayName, queueProvisioning: false);
var binding = await _accessDeviceRepo.GetBindingAsync(
profile.AccessProfileId, enrollment.FKDeviceId);
if (binding == null)
{
binding = new AccessDeviceBinding
{
AccessDeviceBindingId = Guid.NewGuid(),
FKAccessProfileId = profile.AccessProfileId,
FKDeviceId = enrollment.FKDeviceId,
CreatedAt = DateTime.UtcNow
};
await _accessDeviceRepo.AddBindingAsync(binding);
profile.DeviceBindings.Add(binding);
}
binding.DeviceEmployeeNo = employeeNo;
binding.DeviceReportedName = enrollment.DeviceReportedName;
binding.FingerprintCount = enrollment.FingerprintCount;
binding.ProvisionStatus = "Synced";
binding.ActualAccessState = IsBlocked(enrollment.DeviceUserType) ? "Disabled" : "Enabled";
binding.LastSyncedAt = DateTime.UtcNow;
binding.LastError = null;
binding.UpdatedAt = DateTime.UtcNow;
var linkedAt = DateTime.UtcNow;
var duplicateDiscoveries =
await _accessDeviceRepo.GetDuplicateUnlinkedEnrollmentsAsync(
enrollment.FKDeviceId,
employeeNo,
enrollment.DeviceUserId);
foreach (var discovered in duplicateDiscoveries)
{
discovered.LinkedAt = linkedAt;
discovered.FKAccessProfileId = profile.AccessProfileId;
}
// Reconcile all other active terminals for the same gym.
await QueueProvisioningForActiveDevicesAsync(profile);
await _UoW.CommitAsync();
return profile;
}
catch
{
await _UoW.RollbackAsync();
throw;
}
}
public async Task<AccessProfile> LinkStaffFingerprintAsync(
Guid clientId, Guid staffId, Guid unlinkedEnrollmentId)
{
var staff = await _accessDeviceRepo.GetStaffByIdAsync(clientId, staffId)
?? throw new NotFoundException("Staff member not found in this gym.");
return await LinkDevicePersonAsync(
clientId, staff.FKUserId, "Staff",
staff.User?.UserName ?? string.Empty, unlinkedEnrollmentId);
}
public async Task<AccessStatusDto?> GetAccessStatusAsync(Guid clientId, Guid userId)
{
var profile = await _accessDeviceRepo
.GetAccessStatusProfileAsync(clientId, userId);
if (profile == null)
return null;
var bindings = profile.DeviceBindings
.Where(b => b.AccessDevice.IsActive && b.AccessDevice.FKAgentId != null)
.ToList();
var provisionStatus = bindings.Count == 0
? "NoDevice"
: bindings.Any(b => b.ProvisionStatus == "Failed")
? "Failed"
: bindings.All(b => b.ProvisionStatus == "Synced") ? "Synced" : "Pending";
var desired = profile.DesiredAccessState;
var actual = bindings.Count == 0
? "Unknown"
: bindings.All(b => b.ActualAccessState == desired) ? desired
: bindings.All(b => b.ActualAccessState == "Unknown") ? "Unknown" : "Mixed";
return new AccessStatusDto
{
AccessProfileId = profile.AccessProfileId,
AccessNumber = bindings.FirstOrDefault()?.DeviceEmployeeNo
?? $"HF{profile.AccessProfileId}",
PersonType = profile.PersonType,
DesiredAccessState = desired,
ProvisionStatus = provisionStatus,
ActualAccessState = actual,
FingerprintCount = bindings.Count == 0 ? 0 : bindings.Max(b => b.FingerprintCount),
LastError = bindings.FirstOrDefault(b => !string.IsNullOrWhiteSpace(b.LastError))?.LastError,
LastSyncedAt = bindings.Max(b => b.LastSyncedAt),
DeviceCount = bindings.Count,
SyncedDeviceCount = bindings.Count(b => b.ProvisionStatus == "Synced")
};
}
private async Task QueueProvisioningForActiveDevicesAsync(AccessProfile profile)
{
var devices = (await _accessDeviceRepo
.GetDevicesByClientAsync(profile.FKClientId))
.Where(device => device.FKAgentId != null)
.ToList();
foreach (var device in devices)
{
var binding = profile.DeviceBindings.FirstOrDefault(b => b.FKDeviceId == device.DeviceId)
?? await EnsureBindingAsync(profile, device);
if (binding.ProvisionStatus != "Synced")
await QueueProfileCommandAsync(profile, binding, "UpsertPerson");
}
}
private async Task<AccessDeviceBinding> EnsureBindingAsync(AccessProfile profile, AccessDevice device)
{
var binding = profile.DeviceBindings.FirstOrDefault(b => b.FKDeviceId == device.DeviceId)
?? await _accessDeviceRepo.GetBindingAsync(
profile.AccessProfileId, device.DeviceId);
if (binding != null)
return binding;
binding = new AccessDeviceBinding
{
AccessDeviceBindingId = Guid.NewGuid(),
FKAccessProfileId = profile.AccessProfileId,
FKDeviceId = device.DeviceId,
// Namespace platform-managed terminal users away from locally-created
// numeric ids, which prevents overwriting an unlinked person by collision.
DeviceEmployeeNo = $"HF{profile.AccessProfileId}",
ProvisionStatus = "Pending",
ActualAccessState = "Unknown",
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
await _accessDeviceRepo.AddBindingAsync(binding);
profile.DeviceBindings.Add(binding);
return binding;
}
private async Task QueueProfileCommandAsync(
AccessProfile profile, AccessDeviceBinding binding, string commandType)
{
var alreadyQueued = await _deviceCommandRepo.IsQueuedAsync(
binding.AccessDeviceBindingId,
commandType,
profile.DesiredVersion);
if (alreadyQueued)
return;
var payloadJson = JsonSerializer.Serialize(new
{
bindingId = binding.AccessDeviceBindingId,
accessProfileId = profile.AccessProfileId,
employeeNo = binding.DeviceEmployeeNo,
name = profile.DisplayName,
enabled = profile.DesiredAccessState == "Enabled",
desiredVersion = profile.DesiredVersion
});
await _deviceCommandRepo.AddAsync(new DeviceCommand
{
Id = Guid.NewGuid(),
FKClientId = profile.FKClientId,
FKDeviceId = binding.FKDeviceId,
FKAccessDeviceBindingId = binding.AccessDeviceBindingId,
CommandType = commandType,
PayloadJson = payloadJson,
DesiredVersion = profile.DesiredVersion,
Status = "Pending",
MaxAttempts = 5,
NextAttemptAt = DateTime.UtcNow,
CreatedAt = DateTime.UtcNow
});
}
private async Task SupersedePendingStateCommandsAsync(Guid bindingId)
{
var stale = await _deviceCommandRepo
.GetPendingStateCommandsAsync(bindingId);
foreach (var command in stale)
{
command.Status = "Superseded";
command.ProcessedAt = DateTime.UtcNow;
}
}
private Task<bool> IsDeviceCommandableAsync(Guid deviceId) =>
_accessDeviceRepo.IsDeviceCommandableAsync(deviceId);
private async Task<(Guid clientId, Guid userId)> ResolveCustomerAccessOwnerAsync(Guid customerId)
{
var customer = await _accessDeviceRepo.GetCustomerByIdAsync(customerId)
?? throw new NotFoundException("Customer not found.");
var clientId = await _accessDeviceRepo
.GetLatestActiveClientIdForCustomerAsync(customerId);
if (!clientId.HasValue)
throw new NotFoundException("Customer has no active gym membership.");
return (clientId.Value, customer.FKUserId);
}
private static string NormalizeEmployeeNo(UnlinkedDeviceEnrollment enrollment) =>
!string.IsNullOrWhiteSpace(enrollment.DeviceEmployeeNo)
? enrollment.DeviceEmployeeNo
: enrollment.DeviceUserId.ToString();
private static bool IsBlocked(string? userType) =>
string.Equals(userType, "blackList", StringComparison.OrdinalIgnoreCase);
private static string BuildSourceEventKey(
Guid clientId,
Guid deviceId,
string deviceEmployeeNo,
DateTime eventTime,
string eventType)
{
// MySQL DATETIME does not preserve DateTime.Kind. Formatting only the
// stored wall-clock fields keeps this key identical after reading it back.
var normalizedTime = eventTime.ToString(
"yyyy-MM-dd'T'HH:mm:ss.ffffff", CultureInfo.InvariantCulture);
var source = string.Join(
"|",
clientId.ToString("D"),
deviceId.ToString("D"),
deviceEmployeeNo.Trim(),
normalizedTime,
eventType.Trim());
return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(source)))
.ToLowerInvariant();
}
private static string GenerateShortCode(int length = 8)
{
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
var bytes = RandomNumberGenerator.GetBytes(length);
var chars = new char[length];
for (var i = 0; i < length; i++)
chars[i] = alphabet[bytes[i] % alphabet.Length];
return new string(chars);
}
}
}
+98
View File
@@ -0,0 +1,98 @@
using Fitness_core_BE.Models;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services.ActivityLog
{
public class ActivityLogService : IActivityLogService
{
private readonly IActivityLogRepository _activityLogRepository;
private readonly UserRoleHelper _userRoleHelper;
private readonly IUnitOfWork _uow;
public ActivityLogService(
IActivityLogRepository activityLogRepository,
UserRoleHelper userRoleHelper,
IUnitOfWork uow)
{
_activityLogRepository = activityLogRepository;
_userRoleHelper = userRoleHelper;
_uow = uow;
}
// To do: add services used, IP addresses, category as Error/ Action
public async Task<object> LogActivity(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
string description = data["description"].GetString()!;
// Get userId from HttpContext ( for testing purposes - hardcoded)
Guid userId = Guid.Parse("08de9f89-4681-4a2f-8f37-6c1ce830ee6f");
// Guid userId = Guid.Parse(httpContext.Items["UserId"]?.ToString()!);
Guid? adminId = await _userRoleHelper.GetAdminId(userId);
bool isAdmin = adminId.HasValue;
// Get user details (you might need to add a method to get user by Id)
var userName = await _activityLogRepository.GetUserNameAsync(userId);
var log = new Fitness_core_BE.Models.ActivityLog
{
FKUserId = userId,
UserName = userName,
IsAdmin = isAdmin,
Description = description,
Timestamp = DateTime.UtcNow
};
await _activityLogRepository.AddActivityLogAsync(log);
await _uow.CommitAsync();
return log;
}
catch (Exception ex)
{
await _uow.RollbackAsync();
Console.WriteLine($"Activity log failed: {ex.Message}");
throw;
}
}
public async Task<object> GetActivityLogs(object payload, HttpContext httpContext)
{
try
{
var data = DeserializePayload.Deserialize(payload);
string searchTerm = data.ContainsKey("searchTerm") && !string.IsNullOrEmpty(data["searchTerm"].GetString())
? data["searchTerm"].GetString()
: null;
DateTime? startDate = data.ContainsKey("startDate") && !string.IsNullOrEmpty(data["startDate"].GetString())
? DateTime.Parse(data["startDate"].GetString()!)
: null;
DateTime? endDate = data.ContainsKey("endDate") && !string.IsNullOrEmpty(data["endDate"].GetString())
? DateTime.Parse(data["endDate"].GetString()!)
: null;
var logs = await _activityLogRepository.GetActivityLogsAsync(
searchTerm,
startDate,
endDate);
return logs;
}
catch
{
throw;
}
}
}
}
+194
View File
@@ -0,0 +1,194 @@
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Fitness_core_BE.Models.IOs;
using System.Security.Cryptography;
namespace Fitness_core_BE.Services
{
public class AdminClientDirectoryService : IAdminClientDirectoryService
{
private readonly IAdminClientDirectoryRepository _repository;
private readonly IAgentRepo _agentRepo;
private readonly IAccessDeviceRepo _accessDeviceRepo;
private readonly IPairingCodeRepo _pairingCodeRepo;
private readonly IUnitOfWork _UoW;
private readonly IAuthService _authService;
// Three missed 30-second health heartbeats marks an agent/device offline.
// Legacy agents without explicit health reports retain the old grace window.
private static readonly TimeSpan HealthOnlineWindow = TimeSpan.FromSeconds(90);
private static readonly TimeSpan LegacyOnlineWindow = TimeSpan.FromMinutes(5);
public AdminClientDirectoryService(
IAdminClientDirectoryRepository repository,
IAgentRepo agentRepo,
IAccessDeviceRepo accessDeviceRepo,
IPairingCodeRepo pairingCodeRepo,
IUnitOfWork unitOfWork,
IAuthService authService)
{
_repository = repository;
_agentRepo = agentRepo;
_accessDeviceRepo = accessDeviceRepo;
_pairingCodeRepo = pairingCodeRepo;
_UoW = unitOfWork;
_authService = authService;
}
public async Task<PagedResult<ClientSummary>> ListAsync(ClientDirectoryQuery query)
{
var (items, total) = await _repository.ListAsync(query.Search, query.Page, query.PageSize);
var summaries = items.Select(c => new ClientSummary(
c.ClientId,
c.ClientName,
c.ClientBusinesses.FirstOrDefault()?.City,
c.PaymentTrack?.ToString(),
c.ClientBusinesses.Any(b => b.IsActive),
c.ClientBusinesses.OrderBy(b => b.createdAt).FirstOrDefault()?.createdAt ?? DateTime.MinValue
)).ToList();
return new PagedResult<ClientSummary>(summaries, total, query.Page, query.PageSize);
}
public async Task<object> GetByIdAsync(Guid id)
{
var client = await _repository.GetByIdAsync(id)
?? throw new NotFoundException("Client not found.");
return new
{
client.ClientId,
client.ClientName,
client.ClientPhone,
client.ClientEmail,
PaymentTrack = client.PaymentTrack?.ToString(),
Businesses = client.ClientBusinesses.Select(b => new
{
b.Bid,
b.BusinessName,
b.Address,
b.City,
b.IsActive
})
};
}
public async Task ResendWelcomeEmailAsync(Guid clientId, HttpContext httpContext)
{
var client = await _repository.GetByIdAsync(clientId)
?? throw new NotFoundException("Client not found.");
if (string.IsNullOrWhiteSpace(client.ClientEmail))
throw new BadRequestException("This client has no email on file to send the link to.");
var authResponse = await _authService.ForgotPassword(
new { identifier = client.ClientEmail, useResetLink = true, welcome = true },
httpContext);
if (authResponse.StatusCode != 200 || !authResponse.Success)
throw new BadRequestException(authResponse.Message ?? "Couldn't resend the welcome email.");
}
public async Task<PairingCodeResult> GeneratePairingCodeAsync(Guid clientId)
{
if (!await _repository.ClientExistsAsync(clientId))
throw new NotFoundException("Client not found.");
var pairingCode = new PairingCode
{
Id = Guid.NewGuid(),
Code = GenerateShortCode(),
FKClientId = clientId,
ExpiresAt = DateTime.UtcNow.AddMinutes(15),
IsUsed = false,
CreatedAt = DateTime.UtcNow
};
await _UoW.BeginAsync();
try
{
await _pairingCodeRepo.AddAsync(pairingCode);
await _UoW.CommitAsync();
}
catch
{
await _UoW.RollbackAsync();
throw;
}
return new PairingCodeResult(pairingCode.Code, pairingCode.ExpiresAt);
}
public async Task<AdminClientDevicesResult> GetDevicesAsync(Guid clientId)
{
if (!await _repository.ClientExistsAsync(clientId))
throw new NotFoundException("Client not found.");
var agents = await _agentRepo.GetByClientIdAsync(clientId);
var devices = await _accessDeviceRepo.GetDevicesByClientAsync(clientId);
var now = DateTime.UtcNow;
var activeAgentIds = agents
.Where(a => a.IsActive)
.Select(a => a.AgentId)
.ToHashSet();
var agentDtos = agents
.Select(agent => new AdminAgentDto(
agent.AgentId,
agent.Name,
agent.IsActive,
agent.LastSeenAt,
agent.IsActive && agent.LastSeenAt.HasValue &&
now - agent.LastSeenAt.Value <= HealthOnlineWindow,
agent.CreatedAt))
.ToList();
var deviceDtos = devices
.Select(device =>
{
var hasHealthReport = device.LastHealthReportedAt.HasValue;
var presenceTimestamp = hasHealthReport
? device.LastHealthReportedAt
: device.LastSeenAt;
var window = hasHealthReport
? HealthOnlineWindow
: LegacyOnlineWindow;
var isOnline =
device.IsActive &&
device.FKAgentId.HasValue &&
activeAgentIds.Contains(device.FKAgentId.Value) &&
presenceTimestamp.HasValue &&
now - presenceTimestamp.Value <= window &&
(!hasHealthReport || device.IsReachable);
return new AdminDeviceDto(
device.DeviceId,
device.DeviceName,
device.IsActive,
device.LastSeenAt,
isOnline,
isOnline ? presenceTimestamp!.Value.Add(window) : null,
device.FKAgentId.HasValue,
device.CreatedAt);
})
.ToList();
return new AdminClientDevicesResult(agentDtos, deviceDtos);
}
private static string GenerateShortCode(int length = 8)
{
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
var bytes = RandomNumberGenerator.GetBytes(length);
var chars = new char[length];
for (int i = 0; i < length; i++)
chars[i] = alphabet[bytes[i] % alphabet.Length];
return new string(chars);
}
}
}
+60
View File
@@ -0,0 +1,60 @@
using System.Text.Json;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Fitness_core_BE.Models.IOs;
namespace Fitness_core_BE.Services
{
public class AdminSuggestionService : IAdminSuggestionService
{
private readonly IAdminSuggestionRepository _repository;
private readonly UserRoleHelper _userRoleHelper;
private readonly IUnitOfWork _uow;
public AdminSuggestionService(IAdminSuggestionRepository repository, UserRoleHelper userRoleHelper, IUnitOfWork uow) => (_repository, _userRoleHelper, _uow) = (repository, userRoleHelper, uow);
public async Task<object> ListAsync()
{
var (workouts, meals) = await _repository.GetPublishedAsync();
return new { workouts, meals };
}
public async Task<object> CreateWorkoutAsync(AdminWorkoutSuggestionRequest request, HttpContext httpContext)
{
await EnsureAdminAsync(httpContext);
if (string.IsNullOrWhiteSpace(request.Name) || request.Days.Count == 0) throw new BadRequestException("Name and at least one workout day are required.");
await _uow.BeginAsync();
try {
var category = await GetWorkoutCategoryAsync(request.Category, request.CategoryDescription);
var plan = new WorkoutPlan { WorkoutPlanId = Guid.NewGuid(), WorkoutPlanName = request.Name.Trim(), Description = request.Description, FKWorkoutCategoryId = category.WorkoutCategoryId, IsTemplate = true, IsActive = true, CreateDate = DateTime.UtcNow };
foreach (var day in request.Days.Where(d => !string.IsNullOrWhiteSpace(d.Day)))
{
var item = new WorkoutItem { WorkoutItemId = Guid.NewGuid(), ExerciseDay = day.Day.Trim(), Description = day.Description, Exercises = JsonSerializer.Serialize(day.Exercises), HowToDo = JsonSerializer.Serialize(new { suitableFor = day.SuitableFor, exercises = day.Exercises }), Duration = day.Duration, WormUp = day.WarmUp, Cooldown = day.Cooldown, IsActive = true, CreateDate = DateTime.UtcNow, UpdatedAt = DateTime.UtcNow };
var order = 1;
foreach (var exercise in day.Exercises.Where(e => !string.IsNullOrWhiteSpace(e.Name)))
{
item.WorkoutExercises.Add(new WorkoutExercise { WorkoutExerciseId = Guid.NewGuid(), Name = exercise.Name.Trim(), MuscleGroup = exercise.Position, Sets = exercise.Sets, Reps = exercise.Reps, Description = exercise.Description, BreathIn = exercise.BreathIn, BreathOut = exercise.BreathOut, VideoUrl = exercise.VideoUrl, SortOrder = order++, IsActive = true, CreateDate = DateTime.UtcNow });
}
plan.WorkoutItems.Add(item);
}
await _repository.AddWorkoutAsync(plan); await _uow.CommitAsync(); return new { plan.WorkoutPlanId, numberOfDays = plan.WorkoutItems.Count };
} catch { await _uow.RollbackAsync(); throw; }
}
public async Task<object> CreateMealAsync(AdminMealSuggestionRequest request, HttpContext httpContext)
{
await EnsureAdminAsync(httpContext);
if (string.IsNullOrWhiteSpace(request.Name) || request.Meals.Count == 0) throw new BadRequestException("Name and at least one meal are required.");
await _uow.BeginAsync();
try {
var category = await GetMealCategoryAsync(request.Category, request.CategoryDescription);
var plan = new MealPlan { MealPlanId = Guid.NewGuid(), Name = request.Name.Trim(), Description = request.Description, FKMealCategoryId = category.MealCategoryId, IsTemplate = true, IsActive = true, CreateDate = DateTime.UtcNow };
foreach (var meal in request.Meals.Where(m => !string.IsNullOrWhiteSpace(m.Name))) plan.MealItems.Add(new Models.Meal { MealId = Guid.NewGuid(), Name = meal.Name.Trim(), Description = meal.Description, Calories = meal.Calories, Protein = meal.Protein, Carbohydrates = meal.Carbohydrates, Fats = meal.Fats, ServingSize = meal.ServingSize, MealItems = JsonSerializer.Serialize(meal.Foods), PreparationNotes = meal.PreparationNotes, IsActive = true, CreateDate = DateTime.UtcNow });
await _repository.AddMealAsync(plan); await _uow.CommitAsync(); return new { plan.MealPlanId, mealCount = plan.MealItems.Count };
} catch { await _uow.RollbackAsync(); throw; }
}
private async Task EnsureAdminAsync(HttpContext context) { var id = DataExtractor.GetUserIdFromContext(context); if ((await _userRoleHelper.GetAdminId(id).ConfigureAwait(false)) is not Guid) throw new ForbiddenException("Only admins can manage platform suggestions."); }
private async Task<WorkoutCategory> GetWorkoutCategoryAsync(string name, string? description) { name = string.IsNullOrWhiteSpace(name) ? "General Fitness" : name.Trim(); var category = await _repository.FindWorkoutCategoryAsync(name); if (category != null) return category; category = new WorkoutCategory { WorkoutCategoryId = Guid.NewGuid(), WorkoutCategoryName = name, Description = description, IsActive = true, CreateDate = DateTime.UtcNow }; await _repository.AddWorkoutCategoryAsync(category); return category; }
private async Task<MealCategory> GetMealCategoryAsync(string name, string? description) { name = string.IsNullOrWhiteSpace(name) ? "Balanced Nutrition" : name.Trim(); var category = await _repository.FindMealCategoryAsync(name); if (category != null) return category; category = new MealCategory { MealCategoryId = Guid.NewGuid(), Name = name, Description = description, IsActive = true, CreateDate = DateTime.UtcNow }; await _repository.AddMealCategoryAsync(category); return category; }
}
}
+731
View File
@@ -0,0 +1,731 @@
using Fitness_core_BE.DTOs;
using Fitness_core_BE.DTOs.Agent;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Hubs;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using System.Text.Json;
namespace Fitness_core_BE.Services
{
public class AgentService : IAgentService
{
private static readonly TimeSpan DevicePresenceWindow = TimeSpan.FromSeconds(90);
private readonly IUnitOfWork _UoW;
private readonly IAgentRepo _agentRepo;
private readonly IPairingCodeRepo _pairingCodeRepo;
private readonly IDeviceCommandRepo _deviceCommandRepo;
private readonly IAccessDeviceRepo _accessDeviceRepo;
private readonly IAccessDeviceService _accessDeviceService;
private readonly ILogger<AgentService> _logger;
private readonly IHubContext<AccessHub> _accessHub;
public AgentService(
IUnitOfWork unitOfWork,
IAgentRepo agentRepo,
IPairingCodeRepo pairingCodeRepo,
IDeviceCommandRepo deviceCommandRepo,
IAccessDeviceRepo accessDeviceRepo,
IAccessDeviceService accessDeviceService,
ILogger<AgentService> logger,
IHubContext<AccessHub> accessHub)
{
_UoW = unitOfWork;
_agentRepo = agentRepo;
_pairingCodeRepo = pairingCodeRepo;
_deviceCommandRepo = deviceCommandRepo;
_accessDeviceRepo = accessDeviceRepo;
_accessDeviceService = accessDeviceService;
_logger = logger;
_accessHub = accessHub;
}
public async Task<object> EnrollAsync(EnrollRequest request)
{
if (request == null || string.IsNullOrWhiteSpace(request.Code))
throw new BadRequestException("Pairing code is required.");
if (string.IsNullOrWhiteSpace(request.AgentName))
throw new BadRequestException("Agent name is required.");
var pairingCode = await _pairingCodeRepo.GetActiveByCodeAsync(request.Code);
if (pairingCode == null)
throw new BadRequestException("Pairing code is invalid, used, or expired.");
var apiKey = ApiKeyHelper.GenerateApiKey();
var agent = new Agent
{
AgentId = Guid.NewGuid(),
FKClientId = pairingCode.FKClientId,
Name = request.AgentName.Trim(),
ApiKeyHash = ApiKeyHelper.HashApiKey(apiKey),
IsActive = true,
CreatedAt = DateTime.UtcNow
};
await _UoW.BeginAsync();
try
{
await _agentRepo.AddAsync(agent);
await _pairingCodeRepo.MarkUsedAsync(pairingCode);
await _UoW.CommitAsync();
}
catch
{
await _UoW.RollbackAsync();
throw;
}
return new { agentId = agent.AgentId, apiKey };
}
public async Task<object> RegisterDeviceAsync(
Guid clientId, Guid agentId, RegisterDeviceRequest request)
{
if (request == null || string.IsNullOrWhiteSpace(request.DeviceName))
throw new BadRequestException("Device name is required.");
var hardwareId = string.IsNullOrWhiteSpace(request.HardwareId)
? null
: request.HardwareId.Trim();
AccessDevice? device = null;
if (request.DeviceId.HasValue && request.DeviceId.Value != Guid.Empty)
{
device = await _accessDeviceRepo.GetDeviceByIdAsync(request.DeviceId.Value);
if (device != null && device.FKClientId != clientId)
throw new ForbiddenException("Device belongs to another client.");
}
if (device == null && hardwareId != null)
{
device = await _accessDeviceRepo
.GetDeviceByHardwareIdAsync(clientId, hardwareId);
}
await _UoW.BeginAsync();
try
{
if (device == null)
{
device = new AccessDevice
{
DeviceId = Guid.NewGuid(),
FKClientId = clientId,
CreatedAt = DateTime.UtcNow
};
await _accessDeviceRepo.AddDeviceAsync(device);
}
device.DeviceName = request.DeviceName.Trim();
device.HardwareId = hardwareId ?? device.HardwareId;
device.FKAgentId = agentId;
device.IsActive = true;
device.LastSeenAt = DateTime.UtcNow;
// The row must exist before bindings/commands reference it.
await _UoW.SaveChangesAsync();
await _accessDeviceService.ProvisionProfilesToDeviceAsync(device);
await _UoW.CommitAsync();
}
catch
{
await _UoW.RollbackAsync();
throw;
}
return new { deviceId = device.DeviceId };
}
public async Task<object> ReconcileDeviceInventoryAsync(
Guid clientId, Guid agentId, DeviceInventoryRequest request)
{
if (request == null || request.DeviceId == Guid.Empty)
throw new BadRequestException("Device id is required.");
var device = await _accessDeviceRepo
.GetActiveDeviceForClientAsync(clientId, request.DeviceId);
if (device == null)
throw new ForbiddenException("Device does not belong to this client.");
if (device.FKAgentId != agentId)
throw new ForbiddenException("Device is assigned to another agent.");
var now = DateTime.UtcNow;
var discovered = 0;
var updated = 0;
var reportedEmployeeNos = new HashSet<string>(StringComparer.Ordinal);
await _UoW.BeginAsync();
try
{
device.LastSeenAt = now;
foreach (var person in request.People ?? new List<DeviceInventoryPerson>())
{
var employeeNo = person.EmployeeNo?.Trim();
if (string.IsNullOrWhiteSpace(employeeNo))
continue;
reportedEmployeeNos.Add(employeeNo);
var binding = await _accessDeviceRepo
.GetBindingByEmployeeNoAsync(request.DeviceId, employeeNo);
if (binding != null)
{
binding.DeviceReportedName = person.Name;
binding.FingerprintCount = Math.Max(0, person.FingerprintCount);
binding.ActualAccessState =
IsBlocked(person.UserType) ? "Disabled" : "Enabled";
var matchesDesired =
binding.ActualAccessState == binding.AccessProfile.DesiredAccessState;
binding.ProvisionStatus = matchesDesired ? "Synced" : "Pending";
binding.LastError = matchesDesired
? null
: "Terminal access state differs from the platform; repair queued.";
binding.LastSyncedAt = now;
binding.UpdatedAt = now;
if (!matchesDesired)
await QueueInventoryRepairAsync(binding, "SetAccessState", now);
updated++;
continue;
}
var unlinked = await _accessDeviceRepo
.GetUnlinkedEnrollmentByEmployeeNoAsync(
request.DeviceId, employeeNo);
if (unlinked == null)
{
unlinked = new UnlinkedDeviceEnrollment
{
Id = Guid.NewGuid(),
FKDeviceId = request.DeviceId,
DeviceEmployeeNo = employeeNo,
DeviceUserId = int.TryParse(employeeNo, out var numericId) ? numericId : 0,
DiscoveredAt = now
};
await _accessDeviceRepo.AddUnlinkedEnrollmentAsync(unlinked);
discovered++;
}
unlinked.DeviceReportedName = person.Name;
unlinked.DeviceUserType = person.UserType;
unlinked.FingerprintCount = Math.Max(0, person.FingerprintCount);
unlinked.LastSeenAt = now;
}
var missingBindings = await _accessDeviceRepo
.GetBindingsForDeviceAsync(request.DeviceId);
foreach (var binding in missingBindings.Where(
b => !reportedEmployeeNos.Contains(b.DeviceEmployeeNo)))
{
binding.ProvisionStatus = "Pending";
binding.ActualAccessState = "Unknown";
binding.FingerprintCount = 0;
binding.LastError = "Terminal person is missing; recreation queued.";
binding.LastSyncedAt = now;
binding.UpdatedAt = now;
await QueueInventoryRepairAsync(binding, "UpsertPerson", now);
}
await _UoW.CommitAsync();
}
catch
{
await _UoW.RollbackAsync();
throw;
}
var total = request.People?.Count ?? 0;
await PublishAccessEventAsync(clientId, "DeviceInventoryUpdated", new
{
deviceId = request.DeviceId,
discovered,
updated,
total,
occurredAt = now
});
return new { discovered, updated, total };
}
public async Task<object> IngestAttendanceAsync(
Guid clientId, Guid agentId, AgentAttendanceRequest request)
{
if (request == null)
throw new BadRequestException("Request body is required.");
var device = await _accessDeviceRepo
.GetActiveDeviceForClientAsync(clientId, request.DeviceId);
if (device == null)
throw new ForbiddenException("Device does not belong to this client.");
if (device.FKAgentId != agentId)
throw new ForbiddenException("Device is assigned to another agent.");
var eventCount = request.Events?.Count ?? 0;
var attendanceList = new List<AttendanceDTO>();
var unknown = 0;
foreach (var ev in request.Events ?? new List<AgentAttendanceEvent>())
{
var employeeNo = ev.EmployeeNoString?.Trim();
if (string.IsNullOrWhiteSpace(employeeNo))
continue;
var binding = await _accessDeviceRepo
.GetBindingByEmployeeNoAsync(request.DeviceId, employeeNo);
if (binding == null)
{
await UpsertUnknownFromAttendanceAsync(request.DeviceId, employeeNo);
unknown++;
continue;
}
Guid? customerId = null;
if (string.Equals(
binding.AccessProfile.PersonType,
"Customer",
StringComparison.OrdinalIgnoreCase))
{
customerId = await _accessDeviceRepo
.GetCustomerIdForUserAtClientAsync(
binding.AccessProfile.FKUserId, clientId);
}
attendanceList.Add(new AttendanceDTO
{
DeviceId = request.DeviceId,
CustomerId = customerId,
UserId = binding.AccessProfile.FKUserId,
AccessProfileId = binding.FKAccessProfileId,
DeviceUserId = int.TryParse(employeeNo, out var numericId) ? numericId : 0,
DeviceEmployeeNo = employeeNo,
EventTime = ev.Time,
EventType = string.IsNullOrWhiteSpace(ev.AttendanceStatus) ? "Unknown" : ev.AttendanceStatus
});
}
device.LastSeenAt = DateTime.UtcNow;
if (unknown > 0 || eventCount > 0)
await _UoW.SaveChangesAsync();
var inserted = await _accessDeviceService.SaveAttendanceRecords(clientId, attendanceList);
if (inserted > 0 || unknown > 0)
{
await PublishAccessEventAsync(clientId, "AttendanceUpdated", new
{
deviceId = request.DeviceId,
inserted,
unknown,
received = eventCount,
occurredAt = DateTime.UtcNow
});
}
if (unknown > 0)
{
await PublishAccessEventAsync(clientId, "DeviceInventoryUpdated", new
{
deviceId = request.DeviceId,
discovered = unknown,
updated = 0,
total = unknown,
occurredAt = DateTime.UtcNow
});
}
_logger.LogInformation(
"Attendance ingest: client {ClientId}, device {DeviceId}, {Received} received, " +
"{Unknown} unknown, {Inserted} inserted.",
clientId, request.DeviceId, eventCount, unknown, inserted);
return new { inserted, unknown };
}
public async Task<object> GetPendingCommandsAsync(Guid clientId, Guid agentId)
{
var now = DateTime.UtcNow;
// Close commands whose final lease expired without a successful report.
var exhausted = await _deviceCommandRepo
.GetExhaustedLeasesAsync(clientId, now);
foreach (var command in exhausted)
{
command.Status = "Failed";
command.ProcessedAt = now;
command.ResultJson = command.ResultJson ?? "{\"detail\":\"Command lease expired.\"}";
if (command.AccessDeviceBinding != null)
{
command.AccessDeviceBinding.ProvisionStatus = "Failed";
command.AccessDeviceBinding.LastError = "Command lease expired.";
command.AccessDeviceBinding.UpdatedAt = now;
}
}
var commands = await _deviceCommandRepo
.GetLeaseableCommandsAsync(clientId, agentId, now, 100);
foreach (var command in commands)
{
command.Status = "InProgress";
command.AttemptCount++;
command.LeaseExpiresAt = now.AddSeconds(90);
}
await _UoW.SaveChangesAsync();
return commands.Select(c => new
{
id = c.Id,
commandType = c.CommandType,
payloadJson = c.PayloadJson,
fkDeviceId = c.FKDeviceId,
attempt = c.AttemptCount,
createdAt = c.CreatedAt
}).ToList();
}
public async Task<object> SubmitCommandResultAsync(
Guid clientId, Guid agentId, Guid commandId, CommandResultRequest request)
{
if (request == null)
throw new BadRequestException("Request body is required.");
var command = await _deviceCommandRepo.GetForAgentResultAsync(commandId)
?? throw new NotFoundException($"Command {commandId} not found.");
if (command.FKClientId != clientId)
throw new ForbiddenException("Command does not belong to this client.");
if (command.AccessDevice?.FKAgentId != agentId)
throw new ForbiddenException("Command belongs to another agent.");
var now = DateTime.UtcNow;
var binding = command.AccessDeviceBinding;
var versionIsCurrent = binding == null ||
command.DesiredVersion == binding.AccessProfile.DesiredVersion;
// A face photo carries its image in the payload and reports enrolment state on
// the profile, not the binding's access state -- so it takes its own path.
var isFacePhoto = command.CommandType == "UpsertFacePhoto";
if (isFacePhoto)
{
HandleFacePhotoResult(command, binding, request, versionIsCurrent, now);
}
else if (request.Success)
{
command.Status = "Completed";
command.ProcessedAt = now;
command.LeaseExpiresAt = null;
if (binding != null && versionIsCurrent)
{
binding.ProvisionStatus = "Synced";
binding.ActualAccessState = ReadEnabled(command.PayloadJson) ? "Enabled" : "Disabled";
binding.LastError = null;
binding.LastSyncedAt = now;
binding.UpdatedAt = now;
}
}
else if (command.AttemptCount < command.MaxAttempts && versionIsCurrent)
{
command.Status = "Pending";
command.LeaseExpiresAt = null;
command.NextAttemptAt = now.AddSeconds(Math.Min(Math.Pow(2, command.AttemptCount) * 5, 300));
if (binding != null)
{
binding.ProvisionStatus = "Pending";
binding.LastError = ExtractResultDetail(request.ResultJson);
binding.UpdatedAt = now;
}
}
else
{
command.Status = "Failed";
command.ProcessedAt = now;
command.LeaseExpiresAt = null;
if (binding != null && versionIsCurrent)
{
binding.ProvisionStatus = "Failed";
binding.LastError = ExtractResultDetail(request.ResultJson);
binding.UpdatedAt = now;
}
}
command.ResultJson = request.ResultJson;
await _UoW.SaveChangesAsync();
if (isFacePhoto)
{
var profile = binding?.AccessProfile;
await PublishAccessEventAsync(clientId, "FacePhotoUpdated", new
{
commandId = command.Id,
bindingId = command.FKAccessDeviceBindingId,
accessProfileId = profile?.AccessProfileId,
facePhotoStatus = profile?.FacePhotoStatus,
hasFacePhoto = profile?.HasFacePhoto,
error = profile?.FacePhotoError,
occurredAt = now
});
}
else
{
await PublishAccessEventAsync(clientId, "AccessStateUpdated", new
{
commandId = command.Id,
bindingId = command.FKAccessDeviceBindingId,
status = command.Status,
success = request.Success,
occurredAt = now
});
}
return new { command.Id, command.Status, command.AttemptCount, command.MaxAttempts };
}
/// <summary>
/// Applies a face-photo command result to the access profile and, on any terminal
/// state, drops the image from the command payload. The photo is only ever held
/// while it is in flight to a terminal -- never stored.
/// </summary>
private void HandleFacePhotoResult(
DeviceCommand command,
AccessDeviceBinding? binding,
CommandResultRequest request,
bool versionIsCurrent,
DateTime now)
{
var profile = binding?.AccessProfile;
if (request.Success)
{
command.Status = "Completed";
command.ProcessedAt = now;
command.LeaseExpiresAt = null;
command.PayloadJson = string.Empty; // purge the image
if (profile != null && versionIsCurrent)
{
profile.HasFacePhoto = true;
profile.FacePhotoStatus = "Synced";
profile.FacePhotoError = null;
// The agent enrols the face under the person's employeeNo, so that is the
// FPID. It is read from the result when the agent reports one, and falls
// back to the binding otherwise -- either way this must be populated, as
// it is the only handle a later replace/delete has.
profile.FacePhotoFPID =
ExtractFacePhotoFpid(request.ResultJson) ?? binding?.DeviceEmployeeNo;
profile.FacePhotoUpdatedAt = now;
profile.UpdatedAt = now;
}
return;
}
// A rejected face (not frontal, eyes closed, poor lighting) will be rejected the
// same way on every retry, so the payload is kept only while retries remain.
if (command.AttemptCount < command.MaxAttempts && versionIsCurrent)
{
command.Status = "Pending";
command.LeaseExpiresAt = null;
command.NextAttemptAt = now.AddSeconds(
Math.Min(Math.Pow(2, command.AttemptCount) * 5, 300));
if (profile != null)
{
profile.FacePhotoStatus = "Pending";
profile.FacePhotoError = ExtractResultDetail(request.ResultJson);
profile.FacePhotoUpdatedAt = now;
}
return;
}
command.Status = "Failed";
command.ProcessedAt = now;
command.LeaseExpiresAt = null;
command.PayloadJson = string.Empty; // purge the image
if (profile != null && versionIsCurrent)
{
profile.FacePhotoStatus = "Failed";
profile.FacePhotoError = ExtractResultDetail(request.ResultJson);
profile.FacePhotoUpdatedAt = now;
profile.UpdatedAt = now;
}
}
/// <summary>Pulls the Hikvision face-record id out of the agent's result JSON.</summary>
private static string? ExtractFacePhotoFpid(string? resultJson)
{
if (string.IsNullOrWhiteSpace(resultJson))
return null;
try
{
using var doc = JsonDocument.Parse(resultJson);
foreach (var name in new[] { "fpid", "FPID", "faceId", "faceURL" })
{
if (doc.RootElement.TryGetProperty(name, out var value) &&
value.ValueKind == JsonValueKind.String)
{
var id = value.GetString();
if (!string.IsNullOrWhiteSpace(id))
return id.Length <= 64 ? id : id[..64];
}
}
}
catch { }
return null;
}
public async Task<object> HeartbeatAsync(
Guid clientId, Guid agentId, AgentHeartbeatRequest request)
{
var serverTime = DateTime.UtcNow;
await _agentRepo.UpdateLastSeenAsync(agentId, serverTime);
var reports = (request.Devices ?? new List<AgentDeviceHealthReport>())
.Where(report => report.DeviceId != Guid.Empty)
.GroupBy(report => report.DeviceId)
.Select(group => group.Last())
.ToList();
var reportedIds = reports.Select(report => report.DeviceId).ToList();
var devices = reportedIds.Count == 0
? new List<AccessDevice>()
: await _accessDeviceRepo.GetAgentDevicesAsync(
clientId, agentId, reportedIds);
foreach (var device in devices)
{
var report = reports.First(item => item.DeviceId == device.DeviceId);
device.IsReachable = report.IsOnline;
device.LastHealthReportedAt = serverTime;
if (report.IsOnline)
device.LastSeenAt = serverTime;
}
await _UoW.SaveChangesAsync();
var presence = devices.Select(device => new
{
deviceId = device.DeviceId,
isOnline = device.IsActive && device.IsReachable,
lastSeenAt = device.LastSeenAt,
presenceExpiresAt = device.IsActive && device.IsReachable
? serverTime.Add(DevicePresenceWindow)
: (DateTime?)null
}).ToList();
if (presence.Count > 0)
{
await PublishAccessEventAsync(clientId, "DevicePresenceUpdated", new
{
agentId,
devices = presence,
occurredAt = serverTime
});
}
return new { serverTime, devices = presence };
}
private async Task UpsertUnknownFromAttendanceAsync(Guid deviceId, string employeeNo)
{
var existing = await _accessDeviceRepo
.GetUnlinkedEnrollmentByEmployeeNoAsync(deviceId, employeeNo);
if (existing != null)
{
existing.LastSeenAt = DateTime.UtcNow;
return;
}
await _accessDeviceRepo.AddUnlinkedEnrollmentAsync(new UnlinkedDeviceEnrollment
{
Id = Guid.NewGuid(),
FKDeviceId = deviceId,
DeviceEmployeeNo = employeeNo,
DeviceUserId = int.TryParse(employeeNo, out var numericId) ? numericId : 0,
DiscoveredAt = DateTime.UtcNow,
LastSeenAt = DateTime.UtcNow
});
}
private async Task QueueInventoryRepairAsync(
AccessDeviceBinding binding, string commandType, DateTime now)
{
var profile = binding.AccessProfile;
var alreadyQueued = await _deviceCommandRepo.IsQueuedAsync(
binding.AccessDeviceBindingId,
commandType,
profile.DesiredVersion);
if (alreadyQueued)
return;
var payloadJson = JsonSerializer.Serialize(new
{
bindingId = binding.AccessDeviceBindingId,
accessProfileId = profile.AccessProfileId,
employeeNo = binding.DeviceEmployeeNo,
name = profile.DisplayName,
enabled = profile.DesiredAccessState == "Enabled",
desiredVersion = profile.DesiredVersion
});
await _deviceCommandRepo.AddAsync(new DeviceCommand
{
Id = Guid.NewGuid(),
FKClientId = profile.FKClientId,
FKDeviceId = binding.FKDeviceId,
FKAccessDeviceBindingId = binding.AccessDeviceBindingId,
CommandType = commandType,
PayloadJson = payloadJson,
DesiredVersion = profile.DesiredVersion,
Status = "Pending",
MaxAttempts = 5,
NextAttemptAt = now,
CreatedAt = now
});
}
private static bool IsBlocked(string? userType) =>
string.Equals(userType, "blackList", StringComparison.OrdinalIgnoreCase);
private static bool ReadEnabled(string payloadJson)
{
try
{
using var doc = JsonDocument.Parse(payloadJson);
return !doc.RootElement.TryGetProperty("enabled", out var enabled) || enabled.GetBoolean();
}
catch
{
return true;
}
}
private static string ExtractResultDetail(string? resultJson)
{
if (string.IsNullOrWhiteSpace(resultJson))
return "Device command failed.";
try
{
using var doc = JsonDocument.Parse(resultJson);
if (doc.RootElement.TryGetProperty("detail", out var detail))
return detail.GetString() ?? resultJson;
}
catch { }
return resultJson.Length <= 1000 ? resultJson : resultJson[..1000];
}
private async Task PublishAccessEventAsync(
Guid clientId, string eventName, object payload)
{
try
{
await _accessHub.Clients
.Group(AccessHub.ClientGroup(clientId))
.SendAsync(eventName, payload);
}
catch (Exception ex)
{
// Device ingestion is authoritative; a transient socket/backplane
// failure must never make the agent retry a committed database write.
_logger.LogWarning(
ex,
"Could not publish {EventName} for client {ClientId}.",
eventName,
clientId);
}
}
}
}
+73
View File
@@ -0,0 +1,73 @@
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services
{
public class AttendanceService : IAttendanceService
{
private readonly IAttendanceRepository _attendanceRepo;
private readonly UserRoleHelper _userRoleHelper;
private readonly IAccessDeviceService _accessDeviceService;
public AttendanceService(
IAttendanceRepository attendanceRepo,
UserRoleHelper userRoleHelper,
IAccessDeviceService accessDeviceService)
{
_attendanceRepo = attendanceRepo;
_userRoleHelper = userRoleHelper;
_accessDeviceService = accessDeviceService;
}
public async Task<object> GetAllAttendanceRecords(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
//await _accessDeviceService.GetAttendance(clientId);
return await _attendanceRepo.GetLatestPerPersonAsync(clientId);
}
public async Task<object> GetAttendanceCounts(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
//await _accessDeviceService.GetAttendance(clientId);
var fromDate = DataExtractor.GetOptionalDateTime(data, "fromDate");
var toDate = DataExtractor.GetOptionalDateTime(data, "toDate");
var now = DateTime.UtcNow;
DateTime resolvedFrom;
DateTime resolvedTo;
if (fromDate == null && toDate == null)
{
resolvedFrom = new DateTime(now.Year, now.Month, 1);
resolvedTo = now.Date.AddDays(1).AddTicks(-1);
}
else if (fromDate != null && toDate == null)
{
resolvedFrom = fromDate.Value;
resolvedTo = now.Date.AddDays(1).AddTicks(-1);
}
else
{
resolvedFrom = fromDate!.Value;
resolvedTo = toDate!.Value.Date.AddDays(1).AddTicks(-1);
}
return await _attendanceRepo.GetAttendanceCountsAsync(clientId, resolvedFrom, resolvedTo);
}
public async Task<object> GetTodayAttendance(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
//await _accessDeviceService.GetAttendance(clientId);
return await _attendanceRepo.GetTodayAttendanceAsync(clientId);
}
}
}
+689
View File
@@ -0,0 +1,689 @@
using Fitness_core_BE.DTOs.Auth;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using System.Net.Http;
using System.Text;
using System.Text.Json;
namespace Fitness_core_BE.Services.Auth
{
public class AuthService(IHttpClientFactory httpClientFactory, ICustomerRepository customerRepository, IUnitOfWork unitOfWork, IConfiguration configuration) : IAuthService
{
private readonly HttpClient _httpClient = httpClientFactory.CreateClient();
private readonly ICustomerRepository _cusRepo = customerRepository;
private readonly IUnitOfWork _UoW = unitOfWork;
private readonly string _baseUrl = configuration["AuthService:BaseUrl"] ?? "http://127.0.0.1:5602";
private string AuthAltTargetUrl => _baseUrl + "/api/alt";
private string AuthUserTargetUrl => _baseUrl + "/api/user";
private string AuthRecoveryTargetUrl => _baseUrl + "/api/recovery";
public async Task<AuthResponse> SendOtp(object payload, HttpContext httpContext)
{
var targetUrl = AuthAltTargetUrl;
var request = new
{
functionName = "sendOtp",
payload,
reference = "send-otp"
};
var json = JsonSerializer.Serialize(request);
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
try
{
var response = await _httpClient.SendAsync(forwardRequest);
var responseContent = await response.Content.ReadAsStringAsync();
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
}
catch (Exception ex)
{
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
}
}
public async Task<AuthResponse> LoginUser(object payload, HttpContext httpContext)
{
// Web clients explicitly opt in to a persistent 30-day cookie. Without
// this, closing the browser ends the session even though Auth retains the
// server-side refresh session for security/audit purposes.
var loginData = DeserializePayload.Deserialize(payload);
var rememberMe = loginData?.TryGetValue("rememberMe", out var rememberValue) == true
&& rememberValue.ValueKind is System.Text.Json.JsonValueKind.True;
var targetUrl = AuthUserTargetUrl;
var request = new
{
functionName = "loginUser",
payload,
reference = "LoginUser"
};
var json = JsonSerializer.Serialize(request);
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
try
{
var response = await _httpClient.SendAsync(forwardRequest);
var responseContent = await response.Content.ReadAsStringAsync();
var authResponse = JsonSerializer.Deserialize<AuthResponse>( responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (authResponse == null)
return new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
if (authResponse.Success && authResponse.Data is JsonElement dataElement)
{
SetAuthCookiesFromData(httpContext, dataElement, rememberMe);
}
return authResponse;
}
catch (Exception ex)
{
throw new Exception($"Auth service error: {ex.Message}");
}
}
/// <summary>Sets the access_token (and refresh_token if present) cookies from an auth-service data payload.</summary>
private void SetAuthCookiesFromData(HttpContext httpContext, JsonElement dataElement, bool persistent = true)
{
string? accessToken = null;
if (dataElement.TryGetProperty("accessToken", out var tokenProp) ||
dataElement.TryGetProperty("AccessToken", out tokenProp))
accessToken = tokenProp.GetString();
if (!string.IsNullOrEmpty(accessToken))
SetAccessTokenCookieInternal(httpContext, accessToken, persistent);
string? refreshToken = null;
if (dataElement.TryGetProperty("refreshToken", out var refreshProp) ||
dataElement.TryGetProperty("RefreshToken", out refreshProp))
refreshToken = refreshProp.GetString();
if (!string.IsNullOrEmpty(refreshToken))
SetRefreshTokenCookie(httpContext, refreshToken, persistent);
}
public void SetAccessTokenCookie(HttpContext httpContext, string token)
=> SetAccessTokenCookieInternal(httpContext, token, persistent: true);
private void SetAccessTokenCookieInternal(HttpContext httpContext, string token, bool persistent)
{
var requestHost = httpContext.Request.Host.Host;
var forwardedProto = httpContext.Request.Headers["X-Forwarded-Proto"].FirstOrDefault();
var isHttpsRequest =
httpContext.Request.IsHttps ||
string.Equals(forwardedProto, "https", StringComparison.OrdinalIgnoreCase);
httpContext.Response.Cookies.Append("access_token", token, new CookieOptions
{
HttpOnly = true,
Secure = isHttpsRequest,
SameSite = isHttpsRequest ? SameSiteMode.None : SameSiteMode.Lax,
Expires = persistent ? DateTimeOffset.UtcNow.AddDays(30) : null,
Path = "/",
Domain = IsLocalHost(requestHost) ? null : "hexafit.hexdive.com"
});
}
public void SetRefreshTokenCookie(HttpContext httpContext, string refreshToken, bool persistent = true)
{
var requestHost = httpContext.Request.Host.Host;
var forwardedProto = httpContext.Request.Headers["X-Forwarded-Proto"].FirstOrDefault();
var isHttpsRequest =
httpContext.Request.IsHttps ||
string.Equals(forwardedProto, "https", StringComparison.OrdinalIgnoreCase);
httpContext.Response.Cookies.Append("refresh_token", refreshToken, new CookieOptions
{
HttpOnly = true,
Secure = isHttpsRequest,
SameSite = isHttpsRequest ? SameSiteMode.None : SameSiteMode.Lax,
Expires = persistent ? DateTimeOffset.UtcNow.AddDays(30) : null,
Path = "/",
Domain = IsLocalHost(requestHost) ? null : "hexafit.hexdive.com"
});
}
public void ClearAuthCookies(HttpContext httpContext)
{
var requestHost = httpContext.Request.Host.Host;
var domain = IsLocalHost(requestHost) ? null : "hexafit.hexdive.com";
var options = new CookieOptions { Path = "/", Domain = domain };
httpContext.Response.Cookies.Delete("access_token", options);
httpContext.Response.Cookies.Delete("refresh_token", options);
}
public async Task<AuthResponse> RefreshToken(object payload, HttpContext httpContext)
{
var targetUrl = AuthUserTargetUrl;
// The refresh token lives in an httpOnly cookie so the browser can trigger a
// silent refresh without ever holding it in JS. Fall back to the request payload
// (e.g. mobile clients that pass it explicitly).
string? refreshToken = null;
try
{
var data = DeserializePayload.Deserialize(payload);
if (data.TryGetValue("refreshToken", out var rt) && rt.ValueKind == JsonValueKind.String)
refreshToken = rt.GetString();
}
catch { /* payload may be empty on cookie-driven refresh */ }
if (string.IsNullOrEmpty(refreshToken))
refreshToken = httpContext.Request.Cookies["refresh_token"];
if (string.IsNullOrEmpty(refreshToken))
{
ClearAuthCookies(httpContext);
return new AuthResponse { StatusCode = 401, Success = false, Message = "Missing refresh token", Data = null };
}
var deviceName = httpContext.Request.Headers["User-Agent"].ToString();
var request = new
{
functionName = "refreshToken",
payload = new { refreshToken, deviceName },
reference = "RefreshToken"
};
var json = JsonSerializer.Serialize(request);
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
if (!string.IsNullOrWhiteSpace(deviceName))
forwardRequest.Headers.TryAddWithoutValidation("User-Agent", deviceName);
var forwardedFor = httpContext.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrWhiteSpace(forwardedFor))
forwardRequest.Headers.TryAddWithoutValidation("X-Forwarded-For", forwardedFor);
try
{
var response = await _httpClient.SendAsync(forwardRequest);
var responseContent = await response.Content.ReadAsStringAsync();
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (authResponse == null)
return new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
if (authResponse.Success && authResponse.Data is JsonElement dataElement)
{
// Core-Hex-Auth rotates the refresh token; persist both new cookies.
SetAuthCookiesFromData(httpContext, dataElement);
return authResponse;
}
// Refresh token invalid/expired — clear cookies and report 401 so the
// frontend redirects to login rather than surfacing a 500.
ClearAuthCookies(httpContext);
return new AuthResponse { StatusCode = 401, Success = false, Message = "Session expired. Please log in again.", Data = null };
}
catch (Exception ex)
{
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
}
}
//customer ta only use? why
public async Task<AuthResponse> VerifyOTPForCus(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
var targetUrl = AuthRecoveryTargetUrl;
var request = new
{
functionName = "verifyOTP",
payload,
reference = "verify-otp"
};
var json = JsonSerializer.Serialize(request);
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
try
{
var response = await _httpClient.SendAsync(forwardRequest);
var responseContent = await response.Content.ReadAsStringAsync();
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (authResponse == null)
throw new Exception("Empty response from auth service");
string identifireType = DataExtractor.GetRequiredString(data, "identifireType");
var identifier = DataExtractor.GetRequiredString(data, "identifier");
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
await _UoW.BeginAsync();
try
{
bool result = await _cusRepo.VerifyCustomer(userId, identifireType, identifier);
if (!result)
throw new Exception("verfifying faled");
await _UoW.CommitAsync();
}
catch(Exception ex)
{
await _UoW.RollbackAsync();
throw new Exception($"{ex.Message}");
}
return authResponse;
}
catch (Exception ex)
{
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
}
}
public async Task<AuthResponse> VerifyOtpForLogin(object payload, HttpContext httpContext)
{
var targetUrl = AuthUserTargetUrl;
var request = new
{
functionName = "VerifyOtpForLogin",
payload,
reference = "verify-otp-for-login"
};
var json = JsonSerializer.Serialize(request);
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
try
{
var response = await _httpClient.SendAsync(forwardRequest);
var responseContent = await response.Content.ReadAsStringAsync();
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
}
catch (Exception ex)
{
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
}
}
public async Task<AuthResponse> IsAvailable(object payload, HttpContext httpContext)
{
var targetUrl = AuthAltTargetUrl;
var request = new
{
functionName = "IsAvailable",
payload,
reference = "is-available"
};
var json = JsonSerializer.Serialize(request);
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
try
{
var response = await _httpClient.SendAsync(forwardRequest);
var responseContent = await response.Content.ReadAsStringAsync();
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
}
catch (Exception ex)
{
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
}
}
public async Task<AuthResponse> RegisterUser(object payload, HttpContext httpContext)
{
var targetUrl = AuthUserTargetUrl;
var request = new
{
functionName = "registerUser",
payload,
reference = "register-user"
};
var json = JsonSerializer.Serialize(request);
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
try
{
var response = await _httpClient.SendAsync(forwardRequest);
var responseContent = await response.Content.ReadAsStringAsync();
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
}
catch (Exception ex)
{
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
}
}
public async Task<AuthResponse> GetUserDetails(object payload, HttpContext httpContext)
{
var targetUrl = AuthUserTargetUrl;
var request = new
{
functionName = "getUserDetails",
payload,
reference = "user-details"
};
var json = JsonSerializer.Serialize(request);
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
var authHeader = httpContext.Request.Headers["Authorization"].FirstOrDefault();
if (!string.IsNullOrEmpty(authHeader))
{
forwardRequest.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer",
authHeader.Replace("Bearer ", "")
);
}
try
{
var response = await _httpClient.SendAsync(forwardRequest);
var responseContent = await response.Content.ReadAsStringAsync();
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
}
catch (Exception ex)
{
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
}
}
public async Task<AuthResponse> UpdateUser(object payload, HttpContext httpContext)
{
var targetUrl = AuthUserTargetUrl;
var request = new
{
functionName = "UpdateUser",
payload,
reference = "user-details"
};
var json = JsonSerializer.Serialize(request);
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
// Web clients authenticate via the access_token cookie, not an Authorization
// header — forward whichever is present so the auth service can read the userId.
ApplyAuthHeader(forwardRequest, httpContext);
try
{
var response = await _httpClient.SendAsync(forwardRequest);
var responseContent = await response.Content.ReadAsStringAsync();
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
}
catch (Exception ex)
{
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
}
}
public async Task<AuthResponse> AdminUpdateUser(object payload, HttpContext httpContext)
{
var targetUrl = AuthUserTargetUrl;
var request = new
{
functionName = "adminUpdateUser",
payload,
reference = "admin-update-user"
};
var json = JsonSerializer.Serialize(request);
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
try
{
var response = await _httpClient.SendAsync(forwardRequest);
var responseContent = await response.Content.ReadAsStringAsync();
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
}
catch (Exception ex)
{
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
}
}
/// <summary>Forwards a recovery-manager function (forgotPassword / resetPassword) — no auth required.</summary>
private async Task<AuthResponse> ForwardToRecovery(string functionName, object payload)
{
var request = new { functionName, payload, reference = functionName };
var json = JsonSerializer.Serialize(request);
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, AuthRecoveryTargetUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
try
{
var response = await _httpClient.SendAsync(forwardRequest);
var responseContent = await response.Content.ReadAsStringAsync();
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
}
catch (Exception ex)
{
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
}
}
public Task<AuthResponse> ForgotPassword(object payload, HttpContext httpContext)
=> ForwardToRecovery("forgotPassword", payload);
public Task<AuthResponse> ResetPassword(object payload, HttpContext httpContext)
=> ForwardToRecovery("resetPassword", payload);
public Task<AuthResponse> ResetPasswordWithToken(object payload, HttpContext httpContext)
=> ForwardToRecovery("resetPasswordWithToken", payload);
// Confirms the caller's own password (used to unlock sensitive views). Returns { valid }.
public async Task<AuthResponse> VerifyPassword(object payload, HttpContext httpContext)
{
var request = new { functionName = "VerifyPassword", payload, reference = "verify-password" };
var json = JsonSerializer.Serialize(request);
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, AuthUserTargetUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
ApplyAuthHeader(forwardRequest, httpContext);
try
{
var response = await _httpClient.SendAsync(forwardRequest);
var responseContent = await response.Content.ReadAsStringAsync();
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return authResponse ?? new AuthResponse { StatusCode = 500, Success = false, Message = "Empty response from auth service", Data = null };
}
catch (Exception ex)
{
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
}
}
/// <summary>Sets the outgoing Bearer token from the Authorization header, the MobileAuthToken header, or the access_token cookie.</summary>
private static void ApplyAuthHeader(HttpRequestMessage req, HttpContext ctx)
{
string? token = null;
var authHeader = ctx.Request.Headers["Authorization"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(authHeader))
token = authHeader.Replace("Bearer ", "").Trim();
if (string.IsNullOrWhiteSpace(token))
{
var mobile = ctx.Request.Headers["MobileAuthToken"].FirstOrDefault();
if (!string.IsNullOrWhiteSpace(mobile) && mobile.StartsWith("Bearer "))
token = mobile.Substring("Bearer ".Length).Trim();
}
if (string.IsNullOrWhiteSpace(token))
token = ctx.Request.Cookies["access_token"];
if (!string.IsNullOrWhiteSpace(token))
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
}
// new otp verfy for client
public async Task<AuthResponse> VerifyOTP(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
var targetUrl = AuthRecoveryTargetUrl;
var request = new
{
functionName = "verifyOTP",
payload,
reference = "verify-otp"
};
var json = JsonSerializer.Serialize(request);
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
try
{
var response = await _httpClient.SendAsync(forwardRequest);
var responseContent = await response.Content.ReadAsStringAsync();
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (authResponse == null)
throw new Exception("Empty response from auth service");
return authResponse;
}
catch (Exception ex)
{
return new AuthResponse { StatusCode = 500, Success = false, Message = $"Error communicating with auth service: {ex.Message}", Data = null };
}
}
//Logout user by clearing cookie
public async Task<AuthResponse> LogOut(object payload, HttpContext httpContext)
{
var targetUrl = AuthUserTargetUrl;
var request = new
{
functionName = "LogoutUser",
payload,
reference = "log-out"
};
var json = JsonSerializer.Serialize(request);
var forwardRequest = new HttpRequestMessage(HttpMethod.Post, targetUrl)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
// Always clear cookies locally, even if the remote revoke fails — the user
// clicked logout and their browser session must end.
ClearAuthCookies(httpContext);
try
{
var response = await _httpClient.SendAsync(forwardRequest);
var responseContent = await response.Content.ReadAsStringAsync();
var authResponse = JsonSerializer.Deserialize<AuthResponse>(responseContent, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
return authResponse ?? new AuthResponse { StatusCode = 200, Success = true, Message = "Logged out", Data = null };
}
catch
{
return new AuthResponse { StatusCode = 200, Success = true, Message = "Logged out", Data = null };
}
}
private static bool IsLocalHost(string? host)
{
return string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) ||
string.Equals(host, "127.0.0.1", StringComparison.OrdinalIgnoreCase);
}
}
}
+308
View File
@@ -0,0 +1,308 @@
using Fitness_core_BE.DTOs.Booking;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using System.Security.Cryptography;
namespace Fitness_core_BE.Services
{
public class BookingService : IBookingService
{
private readonly IBookingRepository _bookingRepository;
private readonly IFacilityRepository _facilityRepository;
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository;
private readonly IUnitOfWork _uow;
private readonly UserRoleHelper _userRoleHelper;
public BookingService(
IBookingRepository bookingRepository,
IFacilityRepository facilityRepository,
ICustomerSubscriptionRepository customerSubscriptionRepository,
IUnitOfWork uow,
UserRoleHelper userRoleHelper)
{
_bookingRepository = bookingRepository;
_facilityRepository = facilityRepository;
_customerSubscriptionRepository = customerSubscriptionRepository;
_uow = uow;
_userRoleHelper = userRoleHelper;
}
public async Task<object> CreateMemberBooking(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
var session = await _facilityRepository.GetFacilitySessionById(facilitySessionId)
?? throw new KeyNotFoundException("Facility session not found");
if (session.IsCancelled)
throw new ArgumentException("This session has been cancelled");
if (session.StartTime <= DateTime.UtcNow)
throw new ArgumentException("This session has already started");
var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext));
if (await _bookingRepository.CustomerHasActiveBookingForSession(facilitySessionId, cusId))
throw new ArgumentException("You already have a booking for this session");
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(cusId)
?? throw new ArgumentException("No active membership plan found");
// Null SessionsPerCycle = unlimited-access plan (existing gym behavior) --
// no allowance to check. A set value caps how many Facility sessions this
// cycle's plan covers; SessionsCarriedOver (from a GracePeriod rollover, set at
// renewal approval -- see SubscriptionRenewalApprovalService.ApproveAsync)
// extends that cap only until SessionsCarriedOverExpiresAt.
if (subscription.CustomerPlan.SessionsPerCycle.HasValue)
{
var carriedOver = subscription.SessionsCarriedOverExpiresAt.HasValue
&& subscription.SessionsCarriedOverExpiresAt.Value < DateTime.UtcNow
? 0
: subscription.SessionsCarriedOver;
var allowance = subscription.CustomerPlan.SessionsPerCycle.Value + carriedOver;
if (subscription.SessionsUsedThisCycle >= allowance)
throw new ArgumentException("You've used all the sessions included in your current plan cycle");
}
var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1;
var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId);
if (bookedCount >= capacity)
throw new ArgumentException("This session is fully booked");
var booking = new Booking
{
FKFacilitySessionId = facilitySessionId,
FKClientId = session.FKClientId,
IsGuest = false,
FKCustomerId = cusId,
FKCustomerSubscriptionId = subscription.SubscriptionId,
PaymentStatus = "NotRequired",
Status = "Confirmed"
};
await _bookingRepository.CreateBooking(booking);
if (subscription.CustomerPlan.SessionsPerCycle.HasValue)
subscription.SessionsUsedThisCycle++;
await _uow.CommitAsync();
return MapToDto(booking, session, includeOtp: false, BuildShareUrl(httpContext, booking.BookingId));
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> CreateGuestBooking(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
var guestName = data["guestName"].GetString() ?? throw new ArgumentException("guestName is required");
var guestPhone = data["guestPhone"].GetString() ?? throw new ArgumentException("guestPhone is required");
var session = await _facilityRepository.GetFacilitySessionById(facilitySessionId)
?? throw new KeyNotFoundException("Facility session not found");
if (session.IsCancelled)
throw new ArgumentException("This session has been cancelled");
if (session.StartTime <= DateTime.UtcNow)
throw new ArgumentException("This session has already started");
var capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1;
var bookedCount = await _facilityRepository.GetActiveBookingCount(facilitySessionId);
if (bookedCount >= capacity)
throw new ArgumentException("This session is fully booked");
var booking = new Booking
{
FKFacilitySessionId = facilitySessionId,
FKClientId = session.FKClientId,
IsGuest = true,
GuestName = guestName,
GuestPhone = guestPhone,
OtpCode = GenerateOtp(),
// Payment happens separately -- online (not wired up yet) or at the venue,
// recorded via MarkGuestBookingPaid. The booking + OTP receipt exist either way.
PaymentStatus = "Unpaid",
Status = "Confirmed"
};
await _bookingRepository.CreateBooking(booking);
await _uow.CommitAsync();
// The only time the OTP is ever returned -- this response IS the guest's receipt.
return MapToDto(booking, session, includeOtp: true, BuildShareUrl(httpContext, booking.BookingId));
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> MarkGuestBookingPaid(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
var amountCharged = data["amountCharged"].GetDecimal();
var booking = await _bookingRepository.GetBookingById(bookingId)
?? throw new KeyNotFoundException("Booking not found");
if (!booking.IsGuest)
throw new ArgumentException("Only guest bookings take a manual payment record");
booking.PaymentStatus = "Paid";
booking.AmountCharged = amountCharged;
await _uow.CommitAsync();
return MapToDto(booking, booking.FacilitySession, includeOtp: false);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> CheckInBooking(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var otpCode = data["otpCode"].GetString() ?? throw new ArgumentException("otpCode is required");
var booking = await _bookingRepository.GetActiveBookingByOtp(otpCode)
?? throw new KeyNotFoundException("No confirmed booking found for this code");
if (booking.OtpVerifiedAt.HasValue)
throw new ArgumentException("This booking has already been checked in");
booking.OtpVerifiedAt = DateTime.UtcNow;
booking.CheckedInAt = DateTime.UtcNow;
booking.Status = "CheckedIn";
await _uow.CommitAsync();
return MapToDto(booking, booking.FacilitySession, includeOtp: false);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> CancelBooking(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
var booking = await _bookingRepository.GetBookingById(bookingId)
?? throw new KeyNotFoundException("Booking not found");
if (booking.Status == "Cancelled")
throw new ArgumentException("This booking is already cancelled");
if (booking.Status == "CheckedIn")
throw new ArgumentException("This booking has already been checked in and can't be cancelled");
booking.Status = "Cancelled";
// Give the session allowance back -- only member bookings draw one down.
if (!booking.IsGuest && booking.FKCustomerSubscriptionId.HasValue)
{
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(booking.FKCustomerId!.Value);
if (subscription != null && subscription.SubscriptionId == booking.FKCustomerSubscriptionId.Value
&& subscription.CustomerPlan.SessionsPerCycle.HasValue && subscription.SessionsUsedThisCycle > 0)
{
subscription.SessionsUsedThisCycle--;
}
}
await _uow.CommitAsync();
return MapToDto(booking, booking.FacilitySession, includeOtp: false);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> GetMyBookings(object payload, HttpContext httpContext)
{
var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext));
var bookings = await _bookingRepository.GetBookingsForCustomer(cusId);
return bookings.Select(b => MapToDto(b, b.FacilitySession, includeOtp: false)).ToList();
}
public async Task<object> GetSessionBookings(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
var bookings = await _bookingRepository.GetBookingsForSession(facilitySessionId);
// Staff view -- never expose guest OTPs here either; the door check-in flow reads
// the OTP the guest already has, it doesn't need staff to look it up beforehand.
return bookings.Select(b => MapToDto(b, b.FacilitySession, includeOtp: false)).ToList();
}
public async Task<object?> GetBookingView(object payload)
{
var data = DeserializePayload.Deserialize(payload);
var bookingId = Guid.Parse(data["bookingId"].GetString()!);
var booking = await _bookingRepository.GetBookingById(bookingId);
if (booking == null)
return null;
// The share link itself is the credential (an unguessable GUID) -- the OTP is only
// withheld once it's already been used, same rule as every other read path.
return MapToDto(booking, booking.FacilitySession, includeOtp: !booking.OtpVerifiedAt.HasValue);
}
private static string GenerateOtp() => RandomNumberGenerator.GetInt32(100000, 1000000).ToString();
// Built from the request itself (scheme/host), not a config value, so the link is
// correct in every environment (localhost during dev, whatever domain in production)
// without needing a separate "public base URL" setting kept in sync.
private static string BuildShareUrl(HttpContext httpContext, Guid bookingId)
=> $"{httpContext.Request.Scheme}://{httpContext.Request.Host}/api/public/bookings/{bookingId}/view";
private static BookingDto MapToDto(Booking booking, FacilitySession? session, bool includeOtp, string? shareUrl = null) => new()
{
BookingId = booking.BookingId,
FacilitySessionId = booking.FKFacilitySessionId,
FacilityName = session?.Facility?.Name,
SessionStartTime = session?.StartTime,
SessionEndTime = session?.EndTime,
IsGuest = booking.IsGuest,
CustomerId = booking.FKCustomerId,
GuestName = booking.GuestName,
GuestPhone = booking.GuestPhone,
OtpCode = includeOtp ? booking.OtpCode : null,
OtpVerified = booking.OtpVerifiedAt.HasValue,
PaymentStatus = booking.PaymentStatus,
AmountCharged = booking.AmountCharged,
Status = booking.Status,
BookedAt = booking.BookedAt,
CheckedInAt = booking.CheckedInAt,
ShareUrl = shareUrl
};
}
}
+89
View File
@@ -0,0 +1,89 @@
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Fitness_core_BE.Models.Enums;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Services
{
// GYM's onboarding/activation flow (ClientController's "onboarding/requests" ->
// "activation/{token}" endpoints) is a multi-gym SaaS signup wizard -- OTP checks, NIC
// upload, a payment-method + platform-billing charge step -- built for a platform onboarding
// many paying gym clients. Vijaya is a single, already-real club, not a SaaS signup; this
// is the one-time "create the club" step instead, guarded so it can only ever run once
// (before that first Client row exists) rather than being a repeatable registration path.
public class BootstrapService : IBootstrapService
{
private readonly CentralDbContext _central;
private readonly IUnitOfWork _uow;
public BootstrapService(CentralDbContext central, IUnitOfWork uow)
{
_central = central;
_uow = uow;
}
public async Task<object> BootstrapClub(object payload)
{
await _uow.BeginAsync();
try
{
if (await _central.Client.AnyAsync())
throw new BadRequestException("The club is already set up.");
var data = DeserializePayload.Deserialize(payload);
if (!data.ContainsKey("userId")) throw new ArgumentException("userId is required");
if (!data.ContainsKey("clientName")) throw new ArgumentException("clientName is required");
var userId = Guid.Parse(data["userId"].GetString()!);
var clientName = data["clientName"].GetString()!;
// core-api keeps its own local mirror of user identity (see CustomerRepository/
// StaffRepository doing the same on customer/staff creation) -- Client.FKUserId
// has a real local FK to this table, not to auth-service's database, since the
// two services no longer share a connection. Idempotent: harmless if a mirror
// row already exists for this user id.
if (!await _central.Users.AnyAsync(u => u.UserId == userId))
{
_central.Users.Add(new User
{
UserId = userId,
Email = data.ContainsKey("clientEmail") ? data["clientEmail"].GetString() : null,
PhoneNumber = data.ContainsKey("clientPhone") ? data["clientPhone"].GetString() : null,
IsActive = true
});
}
var client = new Client
{
ClientId = Guid.NewGuid(),
FKUserId = userId,
ClientName = clientName,
ClientPhone = data.ContainsKey("clientPhone") ? data["clientPhone"].GetString() : null,
ClientEmail = data.ContainsKey("clientEmail") ? data["clientEmail"].GetString() : null
};
_central.Client.Add(client);
_central.ClientBusiness.Add(new ClientBusiness
{
Bid = Guid.NewGuid(),
FKClientId = client.ClientId,
BusinessType = BusinessType.institute,
BusinessName = clientName,
IsActive = true
});
await _uow.CommitAsync();
return new { clientId = client.ClientId, clientName = client.ClientName };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
}
}
+138
View File
@@ -0,0 +1,138 @@
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Fitness_core_BE.Helpers;
namespace Fitness_core_BE.Services.Chat
{
public class ChatService : IChatService
{
private const int MAX_MESSAGE_LENGTH = 100000;
private readonly IChatRepo _repository;
private readonly IUnitOfWork _uow;
private readonly IHttpContextAccessor _httpContextAccessor;
public ChatService(IChatRepo repository, IUnitOfWork uow, IHttpContextAccessor httpContextAccessor)
{
_repository = repository;
_uow = uow;
_httpContextAccessor = httpContextAccessor;
}
public async Task<object> GetOrCreateChatAsync(object payload)
{
var httpContext = _httpContextAccessor.HttpContext;
var data = DeserializePayload.Deserialize(payload);
Guid currentUserId= DataExtractor.GetUserIdFromContext(httpContext);
Guid otherUserId = DataExtractor.GetRequiredGuid(data, "otherUserId");
if (currentUserId == otherUserId)
throw new InvalidOperationException("Cannot create chat with yourself.");
await _uow.BeginAsync();
try
{
var existingChat =
await _repository.GetChatBetweenUsersAsync(currentUserId, otherUserId);
if (existingChat != null && existingChat.IsAcitve)
{
await _uow.CommitAsync();
return existingChat;
}
var chat = new Models.Chat
{
Id = Guid.NewGuid(),
FKSenderId = currentUserId,
FKReceiverId = otherUserId
};
await _repository.CreateChatAsync(chat);
await _uow.CommitAsync();
return chat;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> SendMessageAsync(object payload)
{
var data = DeserializePayload.Deserialize(payload);
Guid chatId = DataExtractor.GetRequiredGuid(data, "chatId");
Guid senderId = DataExtractor.GetRequiredGuid(data, "senderId");
string message = DataExtractor.GetRequiredString(data, "message");
string type = DataExtractor.GetRequiredString(data, "type");
if (string.IsNullOrWhiteSpace(message))
throw new ArgumentException("Message cannot be empty.");
message = message.Trim();
if (message.Length > MAX_MESSAGE_LENGTH)
throw new ArgumentException($"Message exceeds {MAX_MESSAGE_LENGTH} characters.");
await _uow.BeginAsync();
try
{
var chat = await _repository.GetChatByIdAsync(chatId);
if (chat == null || !chat.IsAcitve)
throw new KeyNotFoundException("Chat not found or inactive.");
if (senderId != chat.FKSenderId && senderId != chat.FKReceiverId)
throw new UnauthorizedAccessException("User not part of this chat.");
var chatMessage = new ChatMessages
{
Id = Guid.NewGuid(),
FKChatId = chatId,
FKSenderId = senderId,
Message = message,
Type = type,
CreatedAt = DateTime.UtcNow,
IsAcitve = true
};
await _repository.AddMessageAsync(chatMessage);
await _uow.CommitAsync();
return chatMessage;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> GetMessagesAsync(object payload)
{
var data = DeserializePayload.Deserialize(payload);
Guid chatId = DataExtractor.GetRequiredGuid(data, "chatId");
Guid userId = DataExtractor.GetRequiredGuid(data, "userId");
int skip = DataExtractor.GetRequiredInt(data, "skip");
int take = DataExtractor.GetRequiredInt(data, "take");
if (take <= 0 || take > 100)
take = 30;
if (!await CanUserAccessChatAsync(chatId, userId))
throw new UnauthorizedAccessException("User not allowed to view this chat.");
return await _repository.GetMessagesAsync(chatId, skip, take);
}
public async Task<bool> CanUserAccessChatAsync(Guid chatId, Guid userId)
{
return await _repository.IsUserInChatAsync(chatId, userId);
}
}
}
+142
View File
@@ -0,0 +1,142 @@
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Fitness_core_BE.Infra.UoW;
using System.Text.Json;
namespace Fitness_core_BE.Services.ClientInvoice
{
public class ClientInvoiceService : IClientInvoiceService
{
private readonly IClientInvoiceRepository _clientInvoiceRepository;
private readonly IUnitOfWork _uow;
public ClientInvoiceService(IClientInvoiceRepository clientInvoiceRepository, IUnitOfWork uow)
{
_clientInvoiceRepository = clientInvoiceRepository;
_uow = uow;
}
public async Task<object> CreateClientInvoiceFromSubscription(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("subscriptionId"))
{
throw new KeyNotFoundException("subscriptionId is required");
}
var subscriptionId = Guid.Parse(data["subscriptionId"].GetString()!);
var result = await _clientInvoiceRepository.CreateClientInvoiceFromSubscription(subscriptionId);
await _uow.CommitAsync();
return result;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> GetClientInvoiceById(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("clientInvoiceId"))
{
throw new KeyNotFoundException("clientInvoiceId is required");
}
var clientInvoiceId = Guid.Parse(data["clientInvoiceId"].GetString()!);
var clientInvoice = await _clientInvoiceRepository.GetClientInvoiceById(clientInvoiceId);
if (clientInvoice == null)
{
throw new KeyNotFoundException("ClientInvoice not found");
}
return clientInvoice;
}
public async Task<object> GetClientInvoicesByClientId(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("clientId"))
{
throw new KeyNotFoundException("clientId is required");
}
var clientId = Guid.Parse(data["clientId"].GetString()!);
return await _clientInvoiceRepository.GetClientInvoicesByClientId(clientId);
}
public async Task<object> GetClientInvoicesByUserId(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("userId"))
{
throw new KeyNotFoundException("userId is required");
}
var userId = Guid.Parse(data["userId"].GetString()!);
return await _clientInvoiceRepository.GetClientInvoicesByUserId(userId);
}
public async Task<object> UpdateClientInvoiceStatus(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("clientInvoiceId") || !data.ContainsKey("status"))
{
throw new KeyNotFoundException("clientInvoiceId and status are required");
}
var clientInvoiceId = Guid.Parse(data["clientInvoiceId"].GetString()!);
var status = data["status"].GetString()!;
// Validate status
var validStatuses = new[] { "Pending", "Paid", "Failed", "Cancelled", "Refunded" };
if (!validStatuses.Contains(status))
{
throw new InvalidOperationException($"Invalid status. Valid statuses are: {string.Join(", ", validStatuses)}");
}
var result = await _clientInvoiceRepository.UpdateClientInvoiceStatus(clientInvoiceId, status);
await _uow.CommitAsync();
return result;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> CalculateClientInvoiceTotal(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("clientInvoiceId"))
{
throw new KeyNotFoundException("clientInvoiceId is required");
}
var clientInvoiceId = Guid.Parse(data["clientInvoiceId"].GetString()!);
var clientInvoice = await _clientInvoiceRepository.GetClientInvoiceById(clientInvoiceId);
var total = clientInvoice?.Amount ?? 0;
return new { Total = total };
}
}
}
+246
View File
@@ -0,0 +1,246 @@
using Fitness_core_BE.DTOs.PaymentGateways;
using Fitness_core_BE.DTOs.PlatformBilling;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Microsoft.AspNetCore.Http;
namespace Fitness_core_BE.Services
{
// The gym owner's own card(s) for paying their HexaFitness platform subscription.
// Payment-Service is the source of truth for everything about a card (masked PAN, brand,
// expiry, default flag) — this service never caches those locally. The one thing Core
// keeps is ClientCard.CardToken, because ClientInvoice.FKClientCardId is a pre-existing
// int FK that needs a stable local row to point at; a ClientCard row is only ever created
// once Payment-Service has confirmed a card (see CompleteCardOnboardingAsync) — there's no
// local "pending" bookkeeping, since Payment-Service already tracks that itself.
public class ClientPaymentMethodService : IClientPaymentMethodService
{
private const string GatewayName = "DirectPay";
private readonly IPlatformBillingRepository _platformBillingRepository;
private readonly IPaymentsEnablementRepository _paymentsEnablementRepository;
private readonly IPaymentGatewayClient _paymentGatewayClient;
private readonly UserRoleHelper _userRoleHelper;
private readonly IUnitOfWork _uow;
public ClientPaymentMethodService(
IPlatformBillingRepository platformBillingRepository,
IPaymentsEnablementRepository paymentsEnablementRepository,
IPaymentGatewayClient paymentGatewayClient,
UserRoleHelper userRoleHelper,
IUnitOfWork uow)
{
_platformBillingRepository = platformBillingRepository;
_paymentsEnablementRepository = paymentsEnablementRepository;
_paymentGatewayClient = paymentGatewayClient;
_userRoleHelper = userRoleHelper;
_uow = uow;
}
public async Task<StartCardOnboardingResult> StartCardOnboardingAsync(
HttpContext httpContext,
StartCardOnboardingRequest request,
CancellationToken ct = default)
{
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var client = await _platformBillingRepository.GetClientByIdAsync(clientId, ct)
?? throw new NotFoundException("Client not found.");
var isFirstCard = (await _platformBillingRepository.GetClientCardsAsync(clientId, ct)).Count == 0;
var response = await _paymentGatewayClient.OnboardCardAsync(
clientId,
new OnboardCardRequest
{
GatewayName = GatewayName,
CustomerId = clientId.ToString(),
CardLabel = string.IsNullOrWhiteSpace(request.CardLabel) ? "Gym billing card" : request.CardLabel,
CardholderName = client.ClientName ?? "Gym Owner",
IsDefault = isFirstCard,
ReturnUrl = request.ReturnUrl
},
ct);
if (response.Status == "Active")
{
// Synchronous gateway (no hosted redirect) — finalize immediately. DirectPay
// never takes this path today (always hosted/pending); kept for gateway-agnosticism.
await PersistConfirmedCardAsync(clientId, response.CardToken, isFirstCard, ct);
}
return new StartCardOnboardingResult
{
RedirectUrl = response.RedirectUrl ?? string.Empty,
SessionId = response.GatewayRef
};
}
public async Task<CardOnboardingStatusResult> CompleteCardOnboardingAsync(
HttpContext httpContext,
string sessionId,
CancellationToken ct = default)
{
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
// No local lookup — Payment-Service already tracks the pending onboarding by
// (gatewayName, sessionId) and verifies it belongs to this platform.
var status = await _paymentGatewayClient.GetOnboardingStatusAsync(clientId, GatewayName, sessionId, ct);
if (status.Status != "Active")
{
return new CardOnboardingStatusResult { IsComplete = false };
}
var existingLocal = await _platformBillingRepository.GetClientCardByTokenAsync(clientId, status.CardToken, ct);
if (existingLocal is null)
{
var isFirstCard = (await _platformBillingRepository.GetClientCardsAsync(clientId, ct)).Count == 0;
await PersistConfirmedCardAsync(clientId, status.CardToken, isFirstCard, ct);
}
return new CardOnboardingStatusResult
{
IsComplete = true,
Card = new ClientCardDto
{
CardToken = status.CardToken,
MaskedPan = status.MaskedPan,
CardBrand = status.CardBrand,
ExpiryMonth = status.ExpiryMonth,
ExpiryYear = status.ExpiryYear,
IsDefault = status.IsDefault
}
};
}
public async Task<List<ClientCardDto>> ListCardsAsync(HttpContext httpContext, CancellationToken ct = default)
{
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var cards = await _paymentGatewayClient.ListCardsAsync(clientId, clientId.ToString(), ct);
return cards
.Select(card => new ClientCardDto
{
CardToken = card.CardToken,
MaskedPan = card.MaskedPan,
CardBrand = card.CardBrand,
ExpiryMonth = card.ExpiryMonth,
ExpiryYear = card.ExpiryYear,
IsDefault = card.IsDefault
})
.ToList();
}
public async Task RemoveCardAsync(HttpContext httpContext, string cardToken, CancellationToken ct = default)
{
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
await _paymentGatewayClient.DeactivateCardAsync(clientId, cardToken, ct);
// Best-effort local sync: only exists to keep ClientInvoice's FK anchor consistent
// with Payment-Service, not treated as the source of truth.
var local = await _platformBillingRepository.GetClientCardByTokenAsync(clientId, cardToken, ct);
if (local is not null)
{
await _uow.BeginAsync();
try
{
local.IsActive = false;
local.IsPrimary = false;
await _uow.CommitAsync();
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
}
public async Task<ClientCardDto> SetDefaultCardAsync(HttpContext httpContext, string cardToken, CancellationToken ct = default)
{
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var result = await _paymentGatewayClient.SetDefaultCardAsync(clientId, cardToken, clientId.ToString(), ct);
await _uow.BeginAsync();
try
{
var localCards = await _platformBillingRepository.GetClientCardsAsync(clientId, ct);
foreach (var other in localCards)
{
other.IsPrimary = other.CardToken == cardToken;
}
await _uow.CommitAsync();
}
catch
{
await _uow.RollbackAsync();
throw;
}
var details = await _paymentGatewayClient.GetCardDetailsAsync(clientId, cardToken, ct);
return new ClientCardDto
{
CardToken = result.CardToken,
MaskedPan = details.MaskedPan,
CardBrand = details.CardBrand,
ExpiryMonth = details.ExpiryMonth,
ExpiryYear = details.ExpiryYear,
IsDefault = result.IsDefault
};
}
public async Task<TransactionHistoryResult> GetTransactionHistoryAsync(
HttpContext httpContext,
int page,
int pageSize,
CancellationToken ct = default)
{
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var result = await _paymentGatewayClient.ListPaymentsAsync(clientId, cardToken: null, page, pageSize, ct);
return new TransactionHistoryResult
{
Total = result.Total,
Page = result.Page,
Items = result.Items
.Select(item => new TransactionHistoryItemDto
{
PaymentId = item.PaymentId,
AmountMinorUnits = item.AmountMinorUnits,
Currency = item.Currency,
Status = item.Status,
CreatedAtUtc = item.CreatedAtUtc
})
.ToList()
};
}
private async Task PersistConfirmedCardAsync(Guid clientId, string cardToken, bool isPrimary, CancellationToken ct)
{
await _uow.BeginAsync();
try
{
await _paymentsEnablementRepository.CreateClientCardAsync(
new ClientCard
{
FK_ClientId = clientId,
CardToken = cardToken,
IsActive = true,
IsPrimary = isPrimary
},
ct);
await _uow.CommitAsync();
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
}
}
File diff suppressed because it is too large Load Diff
+149
View File
@@ -0,0 +1,149 @@
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services
{
public class ClientStatService : IClientStatService
{
// Muscle Beast Gym (gymos_musslbeast) -- the only gym that gets the "Today's Payments"
// stat, per their explicit request. Not a general feature-flag system; if another gym
// wants this later, promote it to a real per-client setting instead of adding more IDs here.
private static readonly Guid MuscleBeastClientId = Guid.Parse("b2abf7b3-a9f0-11f1-945a-54e8d4bf1eca");
private readonly IClientStatRepository _clientStatRepository;
private readonly IOverdueLockRepository _overdueLockRepository;
private readonly UserRoleHelper _userRoleHelper;
public ClientStatService(
IClientStatRepository clientStatRepository,
IOverdueLockRepository overdueLockRepository,
UserRoleHelper userRoleHelper)
{
_clientStatRepository = clientStatRepository;
_overdueLockRepository = overdueLockRepository;
_userRoleHelper = userRoleHelper;
}
public async Task<object> GetTotalCustomerCount(object payload, HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var count = await _clientStatRepository.GetTotalCustomerCountAsync(clientId);
return new { totalCustomerCount = count };
}
public async Task<object> GetMonthlyCustomerCount(object payload, HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var count = await _clientStatRepository.GetMonthlyCustomerCountAsync(clientId);
return new { monthlyCustomerCount = count };
}
public async Task<object> GetTotalWorkoutPlanCount(object payload, HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var count = await _clientStatRepository.GetTotalWorkoutPlanCountAsync(clientId);
return new { totalWorkoutPlanCount = count };
}
public async Task<object> GetTotalMealPlanCount(object payload, HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var count = await _clientStatRepository.GetTotalMealPlanCountAsync(clientId);
return new { totalMealPlanCount = count };
}
public async Task<object> GetLastMonthCustomerCount(object payload, HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var count = await _clientStatRepository.GetLastMonthCustomerCountAsync(clientId);
return new { lastMonthCustomerCount = count };
}
public async Task<object> GetLastFiveAttendances(object payload, HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var records = await _clientStatRepository.GetLastFiveAttendancesAsync(clientId);
return records;
}
public async Task<object> GetLastFivePayments(object payload, HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var records = await _clientStatRepository.GetLastFivePaymentsAsync(clientId);
return records;
}
public async Task<object> GetLastMonthRevenue(object payload, HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var revenue = await _clientStatRepository.GetLastMonthRevenueAsync(clientId);
return new { lastMonthRevenue = revenue };
}
public async Task<object> GetMonthlyRevenueVariation(object payload, HttpContext httpContext, DateTime? from = null, DateTime? to = null)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var stats = await _clientStatRepository.GetMonthlyRevenueVariationAsync(clientId, from, to);
var isOwner = (await _userRoleHelper.GetOwnedClientId(userId)).HasValue;
if (!isOwner)
{
// Revenue is owner-only, enforced here (not just hidden client-side) — staff
// still get real NewMemberCount points, just no Revenue figures.
foreach (var point in stats) point.Revenue = 0m;
}
return stats;
}
public async Task<object> GetTotalRevenue(object payload, HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var revenue = await _clientStatRepository.GetTotalRevenueAsync(clientId);
return new { totalRevenue = revenue };
}
public async Task<object> GetClientStatSummary(object payload, HttpContext httpContext, DateTime? from = null, DateTime? to = null)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var summary = await _clientStatRepository.GetClientStatSummaryAsync(clientId, from, to);
summary.OverdueCount = (await _overdueLockRepository.GetOverdueCustomerIdsAsync(clientId, DateTime.UtcNow)).Count;
summary.ShowTodayPayments = clientId == MuscleBeastClientId;
var isOwner = (await _userRoleHelper.GetOwnedClientId(userId)).HasValue;
if (!isOwner)
{
// Revenue is owner-only, enforced here (not just hidden client-side) — a staff
// member calling this endpoint directly still gets zeros, not real figures.
summary.ThisMonthRevenue = 0m;
summary.LastMonthRevenue = 0m;
summary.RevenueGrowthPercentage = 0m;
summary.TotalRevenue = 0m;
}
return summary;
}
}
}
+231
View File
@@ -0,0 +1,231 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Fitness_core_BE.Infra.UoW;
using System.Text.Json;
using Fitness_core_BE.Helpers;
namespace Fitness_core_BE.Services.CustomerInvoice
{
public class CustomerInvoiceService : ICustomerInvoiceService
{
private readonly ICustomerInvoiceRepository _customerInvoiceRepository;
private readonly IPaymentRepository _paymentRepository;
private readonly IUnitOfWork _uow;
private readonly UserRoleHelper _userRoleHelper;
public CustomerInvoiceService(ICustomerInvoiceRepository customerInvoiceRepository, IPaymentRepository paymentRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper)
{
_customerInvoiceRepository = customerInvoiceRepository;
_paymentRepository = paymentRepository;
_uow = uow;
_userRoleHelper = userRoleHelper;
}
public async Task<object> CreateCustomerInvoiceFromSubscription(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("subscriptionId"))
{
throw new KeyNotFoundException("subscriptionId is required");
}
var subscriptionId = Guid.Parse(data["subscriptionId"].GetString()!);
var result = await _customerInvoiceRepository.CreateCustomerInvoiceFromSubscription(subscriptionId);
await _uow.CommitAsync();
return result;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> GetCustomerInvoiceById(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("customerInvoiceId"))
{
throw new KeyNotFoundException("customerInvoiceId is required");
}
var customerInvoiceId = Guid.Parse(data["customerInvoiceId"].GetString()!);
var customerInvoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId);
if (customerInvoice == null)
{
throw new KeyNotFoundException("CustomerInvoice not found");
}
var paid = await _paymentRepository.GetApprovedPaidAmountAsync(customerInvoice.CustomerInvoiceId);
var carriedForward = await _customerInvoiceRepository.GetCarriedForwardAmountsAsync(
new List<Guid> { customerInvoice.CustomerInvoiceId });
carriedForward.TryGetValue(customerInvoice.CustomerInvoiceId, out var carriedForwardAmount);
var link = customerInvoice.CustomerSubscription?.TenantCustomerLink;
return new
{
customerInvoice.CustomerInvoiceId,
customerInvoice.FKCustomerSubscriptionId,
customerInvoice.FKClientId,
customerInvoice.FKUserId,
customerInvoice.Amount,
// The portion of Amount that's a prior cycle's unpaid balance rolled forward
// (vs. this cycle's own charge) -- see SubscriptionRenewalRunner/BuildAndAddInvoice.
CarriedForwardAmount = carriedForwardAmount,
customerInvoice.Status,
customerInvoice.CustomerInvoiceDate,
PaidAmount = paid,
Balance = customerInvoice.Amount - paid,
// Flattened for callers that don't want to reach through
// CustomerSubscription.TenantCustomerLink for basic display fields.
CustomerName = link?.DisplayName,
CustomerPhone = link?.PrimaryPhoneCached,
customerInvoice.CustomerSubscription
};
}
public async Task<object> GetCustomerInvoicesByCustomerId(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("customerId"))
{
throw new KeyNotFoundException("customerId is required");
}
var customerId = Guid.Parse(data["customerId"].GetString()!);
var invoices = await _customerInvoiceRepository.GetCustomerInvoicesByCustomerId(customerId);
// Same enriched shape as GetCustomerInvoicesByClientId -- this now backs a public
// "view previous invoices" route the frontend renders as MemberInvoice rows.
var result = new List<object>(invoices.Count);
foreach (var invoice in invoices)
{
var paid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId);
var link = invoice.CustomerSubscription?.TenantCustomerLink;
result.Add(new
{
invoice.CustomerInvoiceId,
invoice.FKCustomerSubscriptionId,
invoice.FKClientId,
invoice.Amount,
invoice.Status,
invoice.IsActive,
invoice.CustomerInvoiceDate,
PaidAmount = paid,
Balance = invoice.Amount - paid,
CustomerName = link?.DisplayName,
CustomerPhone = link?.PrimaryPhoneCached,
FKCustomerId = invoice.CustomerSubscription?.FKCustomerId,
invoice.CustomerSubscription
});
}
return result;
}
public async Task<object> GetCustomerInvoicesByClientId(object payload, HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
// scope=="latest": one row per customer (their latest approved invoice) instead of
// every invoice they've ever had -- used by the regular invoices tab now that the
// renewal-approval workflow can leave older invoices (Paid/RolledOver/Cancelled)
// sitting alongside the current one. Default (no scope) keeps today's full-history
// behavior for any other existing caller of this endpoint.
var data = DeserializePayload.Deserialize(payload);
var scope = data != null && data.ContainsKey("scope") ? data["scope"].GetString() : null;
var invoices = scope == "latest"
? await _customerInvoiceRepository.GetLatestApprovedInvoicePerCustomerAsync(clientId)
: await _customerInvoiceRepository.GetCustomerInvoicesByClientId(clientId);
// Enrich each invoice with its approved paid total and outstanding balance,
// preserving the nested shape the frontend reads.
var result = new List<object>(invoices.Count);
foreach (var invoice in invoices)
{
var paid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId);
var link = invoice.CustomerSubscription?.TenantCustomerLink;
result.Add(new
{
invoice.CustomerInvoiceId,
invoice.FKCustomerSubscriptionId,
invoice.FKClientId,
invoice.Amount,
invoice.Status,
invoice.IsActive,
invoice.CustomerInvoiceDate,
PaidAmount = paid,
Balance = invoice.Amount - paid,
// Flattened for callers that don't want to reach through
// CustomerSubscription.TenantCustomerLink for basic display fields.
CustomerName = link?.DisplayName,
CustomerPhone = link?.PrimaryPhoneCached,
FKCustomerId = invoice.CustomerSubscription?.FKCustomerId,
invoice.CustomerSubscription
});
}
return result;
}
public async Task<object> UpdateCustomerInvoiceStatus(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("customerInvoiceId") || !data.ContainsKey("status"))
{
throw new KeyNotFoundException("customerInvoiceId and status are required");
}
var customerInvoiceId = Guid.Parse(data["customerInvoiceId"].GetString()!);
var status = data["status"].GetString()!;
// Validate status
var validStatuses = new[] { "Pending", "Partial", "Paid", "Failed", "Cancelled", "Refunded", "RolledOver" };
if (!validStatuses.Contains(status))
{
throw new InvalidOperationException($"Invalid status. Valid statuses are: {string.Join(", ", validStatuses)}");
}
var result = await _customerInvoiceRepository.UpdateCustomerInvoiceStatus(customerInvoiceId, status);
await _uow.CommitAsync();
return result;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> CalculateCustomerInvoiceTotal(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("customerInvoiceId"))
{
throw new KeyNotFoundException("customerInvoiceId is required");
}
var customerInvoiceId = Guid.Parse(data["customerInvoiceId"].GetString()!);
var customerInvoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId);
var total = customerInvoice?.Amount ?? 0;
return new { Total = total };
}
}
}
+440
View File
@@ -0,0 +1,440 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.DTOs.CustomerPlan;
using System.Text.Json;
using Fitness_core_BE.Helpers;
namespace Fitness_core_BE.Services.CustomerPlanService
{
public class CustomerPlanService : ICustomerPlanService
{
private const string ScopeIndividual = "Individual";
private const string ScopeGroup = "Group";
private const string ModeRolling = "Rolling";
private const string ModeFixedRange = "FixedRange";
private const string RolloverNone = "NoRollover";
private const string RolloverGrace = "GracePeriod";
private const string BillingPrepaid = "Prepaid";
private const string BillingPostpaid = "Postpaid";
private readonly ICustomerPlanRepository _customerPlanRepository;
private readonly IPlaceRepo _placeRepo;
private readonly IUnitOfWork _uow;
private readonly UserRoleHelper _userRoleHelper;
public CustomerPlanService(
ICustomerPlanRepository customerPlanRepository,
IPlaceRepo placeRepo,
IUnitOfWork uow,
UserRoleHelper userRoleHelper)
{
_customerPlanRepository = customerPlanRepository;
_placeRepo = placeRepo;
_uow = uow;
_userRoleHelper = userRoleHelper;
}
public async Task<object> GetAllCustomerPlans(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
bool includeInactive = data != null &&
data.ContainsKey("includeInactive") &&
data["includeInactive"].GetBoolean();
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid contextClientId = await _userRoleHelper.GetClientId(userId);
Guid? requestedClientId = null;
if (data != null && data.ContainsKey("FKClientId") && data["FKClientId"].ValueKind != JsonValueKind.Null)
{
requestedClientId = Guid.Parse(data["FKClientId"].GetString()!);
}
var clientId = requestedClientId ?? contextClientId;
var plans = await _customerPlanRepository.GetAllCustomerPlans(includeInactive, clientId);
var placeIdsByPlan = await _placeRepo.GetPlaceIdsByPlanIdsAsync(plans.Select(p => p.CustomerPlanId).ToList());
var allPlaceIds = placeIdsByPlan.Values.SelectMany(ids => ids).Distinct().ToList();
var placeNames = await _placeRepo.GetPlaceNamesByIdsAsync(allPlaceIds);
return plans.Select(p =>
{
var placeIds = placeIdsByPlan.GetValueOrDefault(p.CustomerPlanId) ?? new List<Guid>();
return MapToDto(p, p.Client?.ClientName, placeIds, placeNames);
}).ToList();
}
public async Task<object> GetCustomerPlanById(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("customerPlanId"))
{
throw new KeyNotFoundException("customerPlanId is required");
}
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
var plan = await _customerPlanRepository.GetCustomerPlanById(customerPlanId);
if (plan == null)
{
throw new KeyNotFoundException("Customer Plan not found");
}
return await MapToDtoWithPlacesAsync(plan, plan.Client?.ClientName);
}
public async Task<object> CreateCustomerPlan(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("planName"))
{
throw new KeyNotFoundException("planName is required");
}
if (!data.ContainsKey("planPrice"))
{
throw new KeyNotFoundException("planPrice is required");
}
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var customerPlan = new Models.CustomerPlan
{
FKClientId = clientId,
PlanName = data["planName"].GetString(),
PlanDescription = data.ContainsKey("planDescription")
? data["planDescription"].GetString()
: null,
PlanPrice = data["planPrice"].GetDecimal(),
HasOnboardingFee = data.ContainsKey("hasOnboardingFee") && data["hasOnboardingFee"].GetBoolean(),
OnboardingFeeAmount = data.ContainsKey("onboardingFeeAmount") ? data["onboardingFeeAmount"].GetDecimal() : 0m,
PlanScope = data.ContainsKey("planScope") ? data["planScope"].GetString() ?? ScopeIndividual : ScopeIndividual,
MemberLimit = data.ContainsKey("memberLimit") ? data["memberLimit"].GetInt32() : 1,
DurationMode = data.ContainsKey("durationMode") ? data["durationMode"].GetString() ?? ModeRolling : ModeRolling,
DurationInDays = data.ContainsKey("durationInDays") ? data["durationInDays"].GetInt32() : 0,
ValidFrom = DataExtractor.GetOptionalDateTime(data, "validFrom"),
ValidTo = DataExtractor.GetOptionalDateTime(data, "validTo"),
AllowSplitPayments = data.ContainsKey("allowSplitPayments") && data["allowSplitPayments"].GetBoolean(),
InstallmentCount = data.ContainsKey("installmentCount") ? data["installmentCount"].GetInt32() : 0,
SessionsPerCycle = data.ContainsKey("sessionsPerCycle") && data["sessionsPerCycle"].ValueKind != JsonValueKind.Null
? data["sessionsPerCycle"].GetInt32()
: null,
SessionRolloverPolicy = data.ContainsKey("sessionRolloverPolicy") ? data["sessionRolloverPolicy"].GetString() ?? RolloverNone : RolloverNone,
RolloverGraceDays = data.ContainsKey("rolloverGraceDays") ? data["rolloverGraceDays"].GetInt32() : 0,
BillingTiming = data.ContainsKey("billingTiming") ? data["billingTiming"].GetString() ?? BillingPrepaid : BillingPrepaid
};
NormalizeAndValidate(customerPlan);
var result = await _customerPlanRepository.CreateCustomerPlan(customerPlan);
await _uow.SaveChangesAsync();
var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds");
if (placeIds != null)
await _placeRepo.SetPlanPlacesAsync(result.CustomerPlanId, placeIds);
await _uow.CommitAsync();
// Fetch the created plan with client details
var createdPlan = await _customerPlanRepository.GetCustomerPlanById(result.CustomerPlanId);
return await MapToDtoWithPlacesAsync(result, createdPlan?.Client?.ClientName);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> UpdateCustomerPlan(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("customerPlanId"))
{
throw new KeyNotFoundException("customerPlanId is required");
}
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
var existingPlan = await _customerPlanRepository.GetCustomerPlanById(customerPlanId);
if (existingPlan == null)
{
throw new KeyNotFoundException("Customer Plan not found");
}
// Merge: start from existing values, override only the provided keys,
// then re-validate the combined configuration.
if (data.ContainsKey("planName")) existingPlan.PlanName = data["planName"].GetString();
if (data.ContainsKey("planDescription")) existingPlan.PlanDescription = data["planDescription"].GetString();
if (data.ContainsKey("planPrice")) existingPlan.PlanPrice = data["planPrice"].GetDecimal();
if (data.ContainsKey("hasOnboardingFee")) existingPlan.HasOnboardingFee = data["hasOnboardingFee"].GetBoolean();
if (data.ContainsKey("onboardingFeeAmount")) existingPlan.OnboardingFeeAmount = data["onboardingFeeAmount"].GetDecimal();
if (data.ContainsKey("planScope")) existingPlan.PlanScope = data["planScope"].GetString() ?? existingPlan.PlanScope;
if (data.ContainsKey("memberLimit")) existingPlan.MemberLimit = data["memberLimit"].GetInt32();
if (data.ContainsKey("durationMode")) existingPlan.DurationMode = data["durationMode"].GetString() ?? existingPlan.DurationMode;
if (data.ContainsKey("durationInDays")) existingPlan.DurationInDays = data["durationInDays"].GetInt32();
if (data.ContainsKey("validFrom")) existingPlan.ValidFrom = DataExtractor.GetOptionalDateTime(data, "validFrom");
if (data.ContainsKey("validTo")) existingPlan.ValidTo = DataExtractor.GetOptionalDateTime(data, "validTo");
if (data.ContainsKey("allowSplitPayments")) existingPlan.AllowSplitPayments = data["allowSplitPayments"].GetBoolean();
if (data.ContainsKey("installmentCount")) existingPlan.InstallmentCount = data["installmentCount"].GetInt32();
if (data.ContainsKey("sessionsPerCycle"))
existingPlan.SessionsPerCycle = data["sessionsPerCycle"].ValueKind == JsonValueKind.Null ? null : data["sessionsPerCycle"].GetInt32();
if (data.ContainsKey("sessionRolloverPolicy")) existingPlan.SessionRolloverPolicy = data["sessionRolloverPolicy"].GetString() ?? existingPlan.SessionRolloverPolicy;
if (data.ContainsKey("rolloverGraceDays")) existingPlan.RolloverGraceDays = data["rolloverGraceDays"].GetInt32();
if (data.ContainsKey("billingTiming")) existingPlan.BillingTiming = data["billingTiming"].GetString() ?? existingPlan.BillingTiming;
if (data.ContainsKey("isActive")) existingPlan.IsActive = data["isActive"].GetBoolean();
NormalizeAndValidate(existingPlan);
var result = await _customerPlanRepository.UpdateCustomerPlan(existingPlan);
var placeIds = DataExtractor.GetOptionalGuidList(data, "placeIds");
if (placeIds != null)
await _placeRepo.SetPlanPlacesAsync(result.CustomerPlanId, placeIds);
await _uow.CommitAsync();
var updatedPlan = await _customerPlanRepository.GetCustomerPlanById(result.CustomerPlanId);
return await MapToDtoWithPlacesAsync(result, updatedPlan?.Client?.ClientName);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> SetCustomerPlanStatus(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("customerPlanId"))
{
throw new KeyNotFoundException("customerPlanId is required");
}
if (!data.ContainsKey("isActive"))
{
throw new KeyNotFoundException("isActive is required");
}
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
var isActive = data["isActive"].GetBoolean();
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
// Scoped to the caller's gym, so a plan id from another gym reads as
// "not found" rather than being toggled.
var plan = await _customerPlanRepository.SetCustomerPlanActive(customerPlanId, clientId, isActive);
if (plan == null)
{
throw new KeyNotFoundException("Customer Plan not found");
}
await _uow.CommitAsync();
return await MapToDtoWithPlacesAsync(plan, plan.Client?.ClientName);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> DeleteCustomerPlan(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("customerPlanId"))
{
throw new KeyNotFoundException("customerPlanId is required");
}
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
// Check if hard delete is requested (default is soft delete)
bool softDelete = !data.ContainsKey("hardDelete") || !data["hardDelete"].GetBoolean();
var result = await _customerPlanRepository.DeleteCustomerPlan(customerPlanId, softDelete);
if (!result)
{
throw new KeyNotFoundException("Customer Plan not found");
}
await _uow.CommitAsync();
return new
{
success = true,
message = softDelete
? "Customer Plan deactivated successfully"
: "Customer Plan deleted successfully",
softDelete = softDelete
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
/// <summary>
/// Enforces the cross-field rules of the plan configuration and fills
/// derived defaults (member limit, installment count, cleared date range).
/// Throws ArgumentException on invalid combinations.
/// </summary>
private static void NormalizeAndValidate(Models.CustomerPlan plan)
{
// Scope
if (plan.PlanScope != ScopeIndividual && plan.PlanScope != ScopeGroup)
throw new ArgumentException($"planScope must be \"{ScopeIndividual}\" or \"{ScopeGroup}\"");
if (plan.PlanScope == ScopeGroup)
{
if (plan.MemberLimit < 2)
throw new ArgumentException("memberLimit must be at least 2 for Group plans (couple = 2)");
}
else
{
plan.MemberLimit = 1;
}
// Duration
if (plan.DurationMode != ModeRolling && plan.DurationMode != ModeFixedRange)
throw new ArgumentException($"durationMode must be \"{ModeRolling}\" or \"{ModeFixedRange}\"");
int effectiveDays;
if (plan.DurationMode == ModeRolling)
{
if (plan.DurationInDays < 1)
throw new ArgumentException("durationInDays (>= 1) is required for Rolling plans");
plan.ValidFrom = null;
plan.ValidTo = null;
effectiveDays = plan.DurationInDays;
}
else
{
if (plan.ValidFrom == null || plan.ValidTo == null)
throw new ArgumentException("validFrom and validTo are required for FixedRange plans");
if (plan.ValidTo <= plan.ValidFrom)
throw new ArgumentException("validTo must be after validFrom");
plan.DurationInDays = 0;
effectiveDays = (int)(plan.ValidTo.Value - plan.ValidFrom.Value).TotalDays;
}
if (plan.PlanPrice < 0)
throw new ArgumentException("planPrice must be a positive value");
if (plan.OnboardingFeeAmount < 0)
throw new ArgumentException("onboardingFeeAmount must be zero or greater");
if (!plan.HasOnboardingFee)
plan.OnboardingFeeAmount = 0m;
else if (plan.OnboardingFeeAmount <= 0)
throw new ArgumentException("onboardingFeeAmount must be greater than zero when hasOnboardingFee is true");
// Split payments: mostly-equal installments (e.g. Rs. 6000 / 6 months -> 6 x Rs. 1000).
if (plan.AllowSplitPayments)
{
if (plan.InstallmentCount < 2)
{
// Default: one installment per month of the plan's duration.
plan.InstallmentCount = Math.Max(2, (int)Math.Round(effectiveDays / 30.0));
}
}
else
{
plan.InstallmentCount = 1;
}
// Session-count / rollover / billing timing (new SRS requirements).
if (plan.SessionRolloverPolicy != RolloverNone && plan.SessionRolloverPolicy != RolloverGrace)
throw new ArgumentException($"sessionRolloverPolicy must be \"{RolloverNone}\" or \"{RolloverGrace}\"");
if (plan.SessionsPerCycle.HasValue)
{
if (plan.SessionsPerCycle.Value < 1)
throw new ArgumentException("sessionsPerCycle must be at least 1 when set");
if (plan.SessionRolloverPolicy == RolloverGrace && plan.RolloverGraceDays < 1)
throw new ArgumentException("rolloverGraceDays must be at least 1 when sessionRolloverPolicy is \"GracePeriod\"");
}
else
{
// No session allowance on this plan (unlimited-access, existing gym behavior) --
// rollover settings are meaningless without a per-cycle count to roll over.
plan.SessionRolloverPolicy = RolloverNone;
plan.RolloverGraceDays = 0;
}
if (plan.BillingTiming != BillingPrepaid && plan.BillingTiming != BillingPostpaid)
throw new ArgumentException($"billingTiming must be \"{BillingPrepaid}\" or \"{BillingPostpaid}\"");
}
private async Task<CustomerPlanDto> MapToDtoWithPlacesAsync(Models.CustomerPlan plan, string? clientName)
{
var placeIds = await _placeRepo.GetPlaceIdsForPlanAsync(plan.CustomerPlanId);
var placeNames = await _placeRepo.GetPlaceNamesByIdsAsync(placeIds);
return MapToDto(plan, clientName, placeIds, placeNames);
}
private static CustomerPlanDto MapToDto(
Models.CustomerPlan plan,
string? clientName,
List<Guid> placeIds,
Dictionary<Guid, string> placeNames) => new()
{
CustomerPlanId = plan.CustomerPlanId,
FKClientId = plan.FKClientId,
ClientName = clientName,
PlanName = plan.PlanName,
PlanDescription = plan.PlanDescription,
PlanPrice = plan.PlanPrice,
HasOnboardingFee = plan.HasOnboardingFee,
OnboardingFeeAmount = plan.OnboardingFeeAmount,
DurationInDays = plan.DurationInDays,
PlanScope = plan.PlanScope,
MemberLimit = plan.MemberLimit,
DurationMode = plan.DurationMode,
ValidFrom = plan.ValidFrom,
ValidTo = plan.ValidTo,
AllowSplitPayments = plan.AllowSplitPayments,
InstallmentCount = plan.InstallmentCount,
SessionsPerCycle = plan.SessionsPerCycle,
SessionRolloverPolicy = plan.SessionRolloverPolicy,
RolloverGraceDays = plan.RolloverGraceDays,
BillingTiming = plan.BillingTiming,
IsActive = plan.IsActive,
CreatedAt = plan.CreatedAt,
PlaceIds = placeIds,
PlaceNames = placeIds.Select(id => placeNames.GetValueOrDefault(id, "")).Where(n => n != "").ToList()
};
}
}
File diff suppressed because it is too large Load Diff
+510
View File
@@ -0,0 +1,510 @@
using Fitness_core_BE.Domain;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.DTOs.CustomerSubscription;
using System.Text.Json;
namespace Fitness_core_BE.Services.CustomerSubscription
{
public class CustomerSubscriptionService : ICustomerSubscriptionService
{
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository;
private readonly ICustomerInvoiceRepository _customerInvoiceRepository;
private readonly IUnitOfWork _uow;
private readonly UserRoleHelper _userRoleHelper;
public CustomerSubscriptionService(
ICustomerSubscriptionRepository customerSubscriptionRepository,
ICustomerInvoiceRepository customerInvoiceRepository,
IUnitOfWork uow,
UserRoleHelper userRoleHelper)
{
_customerSubscriptionRepository = customerSubscriptionRepository;
_customerInvoiceRepository = customerInvoiceRepository;
_uow = uow;
_userRoleHelper = userRoleHelper;
}
public async Task<object> CreateCustomerSubscription(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("customerId"))
{
throw new KeyNotFoundException("customerId is required");
}
if (!data.ContainsKey("customerPlanId"))
{
throw new KeyNotFoundException("customerPlanId is required");
}
var customerId = Guid.Parse(data["customerId"].GetString()!);
var customerPlanId = Guid.Parse(data["customerPlanId"].GetString()!);
// Get plan details for creating the initial sub-item
var plan = await _customerSubscriptionRepository
.GetPlanByIdAsync(customerPlanId);
if (plan == null)
{
throw new KeyNotFoundException("Customer Plan not found");
}
// Create subscription
var subscription = new Models.CustomerSubscription
{
FKCustomerId = customerId,
FKCustomerPlanId = customerPlanId,
OnboardDate = DataExtractor.GetOptionalDateTime(data, "onboardDate") ?? DateTime.UtcNow,
ExpiryDate = DataExtractor.GetOptionalDateTime(data, "expiryDate"),
IsActive = true
};
// Create initial sub-item representing the main plan
var initialSubItem = new CustomerSubItem
{
SubItemName = plan.PlanName,
SubItemDescription = plan.PlanDescription,
SubItemPrice = plan.PlanPrice,
IsActive = true
};
var aggregate = CustomerSubscriptionFactory.Create(
subscription, plan, initialSubItem);
await _customerSubscriptionRepository.AddCustomerSubscriptionAsync(
aggregate.Subscription,
aggregate.InitialSubItem,
aggregate.PrimaryMember,
aggregate.Installments);
await _uow.CommitAsync();
return new
{
subscriptionId = subscription.SubscriptionId,
customerId = subscription.FKCustomerId,
customerPlanId = subscription.FKCustomerPlanId,
onboardDate = subscription.OnboardDate,
expiryDate = subscription.ExpiryDate,
isActive = subscription.IsActive,
message = "Customer subscription created successfully with initial plan item"
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> GetCustomerSubscriptionById(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("subscriptionId"))
{
throw new KeyNotFoundException("subscriptionId is required");
}
var subscriptionId = Guid.Parse(data["subscriptionId"].GetString()!);
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId);
if (subscription == null)
{
throw new KeyNotFoundException("Customer Subscription not found");
}
// Get sub-items
var subItems = (await _customerSubscriptionRepository.GetSubItemsAsync(
subscriptionId, activeOnly: false, newestFirst: false))
.Select(ToSubItemDto)
.ToList();
return new CustomerSubscriptionDto
{
SubscriptionId = subscription.SubscriptionId,
CustomerId = subscription.FKCustomerId,
CustomerName = subscription.TenantCustomerLink?.DisplayName,
CustomerPlanId = subscription.FKCustomerPlanId,
PlanName = subscription.CustomerPlan?.PlanName,
PlanPrice = subscription.CustomerPlan?.PlanPrice ?? 0,
DurationInDays = subscription.CustomerPlan?.DurationInDays ?? 0,
OnboardDate = subscription.OnboardDate,
ExpiryDate = subscription.ExpiryDate,
RenewalDate = subscription.RenewalDate,
IsActive = subscription.IsActive,
CreatedAt = subscription.CreatedAt,
DiscountType = subscription.DiscountType,
DiscountValue = subscription.DiscountValue,
SubItems = subItems
};
}
public async Task<object> GetCustomerSubscriptionsByCustomerId(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("customerId"))
{
throw new KeyNotFoundException("customerId is required");
}
var customerId = Guid.Parse(data["customerId"].GetString()!);
var subscriptions = await _customerSubscriptionRepository.GetCustomerSubscriptionsByCustomerId(customerId);
return subscriptions.Select(s => new
{
subscriptionId = s.SubscriptionId,
customerPlanId = s.FKCustomerPlanId,
planName = s.CustomerPlan?.PlanName,
planPrice = s.CustomerPlan?.PlanPrice,
durationInDays = s.CustomerPlan?.DurationInDays,
onboardDate = s.OnboardDate,
expiryDate = s.ExpiryDate,
renewalDate = s.RenewalDate,
isActive = s.IsActive,
createdAt = s.CreatedAt
}).ToList();
}
public async Task<object> GetActiveSubscriptionByCustomerId(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("customerId"))
{
throw new KeyNotFoundException("customerId is required");
}
var customerId = Guid.Parse(data["customerId"].GetString()!);
var subscription = await _customerSubscriptionRepository.GetActiveSubscriptionByCustomerId(customerId);
if (subscription == null)
{
return new { message = "No active subscription found for this customer" };
}
// Get sub-items
var subItems = (await _customerSubscriptionRepository.GetSubItemsAsync(
subscription.SubscriptionId,
activeOnly: false,
newestFirst: false))
.Select(ToSubItemDto)
.ToList();
return new CustomerSubscriptionDto
{
SubscriptionId = subscription.SubscriptionId,
CustomerId = subscription.FKCustomerId,
CustomerName = subscription.TenantCustomerLink?.DisplayName,
CustomerPlanId = subscription.FKCustomerPlanId,
PlanName = subscription.CustomerPlan?.PlanName,
PlanPrice = subscription.CustomerPlan?.PlanPrice ?? 0,
DurationInDays = subscription.CustomerPlan?.DurationInDays ?? 0,
OnboardDate = subscription.OnboardDate,
ExpiryDate = subscription.ExpiryDate,
RenewalDate = subscription.RenewalDate,
IsActive = subscription.IsActive,
CreatedAt = subscription.CreatedAt,
DiscountType = subscription.DiscountType,
DiscountValue = subscription.DiscountValue,
SubItems = subItems
};
}
// ---------- Group membership & summaries (client dashboard) ----------
public async Task<object> GetSubscriptionSummary(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
var subscriptionId = Guid.Parse(data!["subscriptionId"].GetString()!);
var subscription = await LoadAuthorizedSubscription(subscriptionId, httpContext);
return await BuildSummaryAsync(subscription);
}
public async Task<object> AddMemberToSubscription(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var subscriptionId = Guid.Parse(data!["subscriptionId"].GetString()!);
var customerId = Guid.Parse(data["customerId"].GetString()!);
var subscription = await LoadAuthorizedSubscription(subscriptionId, httpContext);
var plan = subscription.CustomerPlan;
if (plan == null || plan.PlanScope != "Group")
throw new ArgumentException("Members can only be added to Group plans");
var activeCount = await _customerSubscriptionRepository.GetActiveMemberCountAsync(subscriptionId);
if (activeCount >= plan.MemberLimit)
throw new ArgumentException($"This plan allows at most {plan.MemberLimit} members");
var customerExists = await _customerSubscriptionRepository
.IsActiveCustomerAsync(customerId);
if (!customerExists)
throw new KeyNotFoundException("Customer not found");
var alreadyMember = await _customerSubscriptionRepository
.GetActiveSubscriptionMemberByCustomerAsync(subscriptionId, customerId);
if (alreadyMember != null)
throw new ArgumentException("This customer is already a member of the subscription");
// One customer, one plan at a time -- catches both "already primary on their own
// plan" and "already a secondary on a different group", neither of which the
// check above sees (it only looks at this one subscription).
var hasOtherActivePlan = await _customerSubscriptionRepository.HasAnyActiveSubscriptionAsync(customerId);
if (hasOtherActivePlan)
throw new ArgumentException("This customer already has an active plan. Remove them from it before adding them to another.");
var member = await _customerSubscriptionRepository.AddSubscriptionMemberAsync(new SubscriptionMember
{
FKSubscriptionId = subscriptionId,
FKCustomerId = customerId,
IsPrimary = false
});
await _uow.CommitAsync();
return new
{
success = true,
subscriptionMemberId = member.SubscriptionMemberId,
memberCount = activeCount + 1,
memberLimit = plan.MemberLimit
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> RemoveMemberFromSubscription(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var subscriptionId = Guid.Parse(data!["subscriptionId"].GetString()!);
var subscriptionMemberId = Guid.Parse(data["subscriptionMemberId"].GetString()!);
await LoadAuthorizedSubscription(subscriptionId, httpContext);
var member = await _customerSubscriptionRepository.GetSubscriptionMemberAsync(subscriptionMemberId);
if (member == null || member.FKSubscriptionId != subscriptionId || !member.IsActive)
throw new KeyNotFoundException("Subscription member not found");
if (member.IsPrimary)
throw new ArgumentException("The primary member cannot be removed from a subscription");
member.IsActive = false;
await _uow.CommitAsync();
return new { success = true };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
/// <summary>Typed variant used by ClientService.GetCustomerDetailsById (covers group secondaries).</summary>
public async Task<List<SubscriptionSummaryDto>> BuildSummariesForCustomerAsync(Guid customerId)
{
var subscriptions = await _customerSubscriptionRepository.GetSubscriptionsForCustomerAsync(customerId);
var summaries = new List<SubscriptionSummaryDto>(subscriptions.Count);
foreach (var subscription in subscriptions)
{
summaries.Add(await BuildSummaryAsync(subscription));
}
return summaries;
}
public async Task<List<CustomerSubItemDto>> GetExtraFeesAsync(Guid subscriptionId, HttpContext httpContext)
{
await LoadAuthorizedSubscription(subscriptionId, httpContext);
return (await _customerSubscriptionRepository.GetSubItemsAsync(
subscriptionId, activeOnly: true, newestFirst: true))
.Select(ToSubItemDto)
.ToList();
}
public async Task<object> AddExtraFeeAsync(Guid subscriptionId, AddExtraFeeRequest request, HttpContext httpContext)
{
await LoadAuthorizedSubscription(subscriptionId, httpContext);
if (string.IsNullOrWhiteSpace(request.Name))
throw new ArgumentException("A name is required for the extra fee.");
if (request.Amount <= 0)
throw new ArgumentException("Amount must be greater than zero.");
if (request.Frequency != "OneTime" && request.Frequency != "Recurring")
throw new ArgumentException("Frequency must be 'OneTime' or 'Recurring'.");
await _uow.BeginAsync();
try
{
var subItem = new CustomerSubItem
{
CustomerSubItemId = Guid.NewGuid(),
FKCustomerSubscriptionId = subscriptionId,
SubItemName = request.Name,
SubItemDescription = request.Description,
SubItemPrice = request.Amount,
Frequency = request.Frequency,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
await _customerSubscriptionRepository.AddSubItemAsync(subItem);
// Charged immediately as its own invoice — the subscription's original
// invoice is idempotent and won't retroactively pick up items added later.
var invoice = await _customerInvoiceRepository.CreateSupplementalInvoiceAsync(
subscriptionId, request.Name, request.Description, request.Amount);
await _uow.CommitAsync();
return new
{
customerSubItemId = subItem.CustomerSubItemId,
customerInvoiceId = invoice.CustomerInvoiceId,
amount = invoice.Amount,
frequency = subItem.Frequency
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
private async Task<Models.CustomerSubscription> LoadAuthorizedSubscription(Guid subscriptionId, HttpContext httpContext)
{
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId);
if (subscription == null)
throw new KeyNotFoundException("Subscription not found");
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
if (subscription.CustomerPlan == null || subscription.CustomerPlan.FKClientId != clientId)
throw new UnauthorizedAccessException("This subscription does not belong to your gym");
return subscription;
}
private async Task<SubscriptionSummaryDto> BuildSummaryAsync(Models.CustomerSubscription subscription)
{
var plan = subscription.CustomerPlan;
var invoice = await _customerSubscriptionRepository
.GetLatestInvoiceAsync(subscription.SubscriptionId);
decimal invoiceAmount = invoice?.Amount ?? plan?.PlanPrice ?? 0m;
decimal paid = 0m;
if (invoice != null)
{
paid = await _customerSubscriptionRepository
.GetApprovedPaymentTotalAsync(invoice.CustomerInvoiceId);
}
var members = (subscription.SubscriptionMembers ?? new List<SubscriptionMember>())
.Where(m => m.IsActive)
.OrderByDescending(m => m.IsPrimary)
.ThenBy(m => m.JoinedAt)
.Select(m => new SubscriptionMemberDto
{
SubscriptionMemberId = m.SubscriptionMemberId,
CustomerId = m.FKCustomerId,
CusName = m.TenantCustomerLink?.DisplayName,
Email = m.TenantCustomerLink?.EmailCached,
PrimaryPH = m.TenantCustomerLink?.PrimaryPhoneCached,
IsPrimary = m.IsPrimary,
JoinedAt = m.JoinedAt,
IsActive = m.IsActive,
IsPlanAssigned = m.IsPlanAssigned
})
.ToList();
var installments = (subscription.SubscriptionInstallments ?? new List<SubscriptionInstallment>())
.Where(i => i.IsActive)
.OrderBy(i => i.InstallmentNo)
.Select(i => new SubscriptionInstallmentDto
{
SubscriptionInstallmentId = i.SubscriptionInstallmentId,
InstallmentNo = i.InstallmentNo,
DueDate = i.DueDate,
Amount = i.Amount,
PaidAmount = i.PaidAmount,
Status = i.Status
})
.ToList();
AutoChargeCardDto? autoChargeCard = null;
if (subscription.FKAutoChargeCardId.HasValue)
{
var card = await _customerSubscriptionRepository
.GetCardByIdAsync(subscription.FKAutoChargeCardId.Value);
if (card != null)
{
autoChargeCard = new AutoChargeCardDto
{
CardId = card.JobId,
CardLast4 = card.CardNumber,
CardType = card.CardType
};
}
}
return new SubscriptionSummaryDto
{
SubscriptionId = subscription.SubscriptionId,
CustomerPlanId = subscription.FKCustomerPlanId,
PlanName = plan?.PlanName,
PlanScope = plan?.PlanScope ?? "Individual",
MemberLimit = plan?.MemberLimit ?? 1,
DurationMode = plan?.DurationMode ?? "Rolling",
AllowSplitPayments = plan?.AllowSplitPayments ?? false,
PlanPrice = plan?.PlanPrice ?? 0m,
OnboardDate = subscription.OnboardDate,
ExpiryDate = subscription.ExpiryDate,
RenewalDate = subscription.RenewalDate,
IsActive = subscription.IsActive,
CustomerInvoiceId = invoice?.CustomerInvoiceId,
InvoiceAmount = invoiceAmount,
PaidAmount = paid,
Balance = invoiceAmount - paid,
PaymentStatus = paid >= invoiceAmount && invoiceAmount > 0
? "Paid"
: paid > 0 ? "Partial" : "Pending",
Members = members,
Installments = installments,
AutoChargeEnabled = subscription.AutoChargeEnabled,
AutoChargeCard = autoChargeCard
};
}
private static CustomerSubItemDto ToSubItemDto(CustomerSubItem item) => new()
{
CustomerSubItemId = item.CustomerSubItemId,
SubItemName = item.SubItemName,
SubItemDescription = item.SubItemDescription,
SubItemPrice = item.SubItemPrice,
Frequency = item.Frequency,
IsActive = item.IsActive,
CreatedAt = item.CreatedAt
};
}
}
// add function to add new sub-item to existing subscription, which can be used for add-on purchases
+263
View File
@@ -0,0 +1,263 @@
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Fitness_core_BE.Infra.UoW;
using System.Text.Json;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.DTOs;
namespace Fitness_core_BE.Services.Document
{
public class DocumentService : IDocumentService
{
private readonly IDocumentRepository _docRepository;
private readonly IUnitOfWork _uow;
public DocumentService(IDocumentRepository docRepository, IUnitOfWork uow)
{
_docRepository = docRepository;
_uow = uow;
}
public async Task<object> UploadDocument(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
var data = DeserializePayload.Deserialize(payload);
var files = data["document"].EnumerateArray();
bool isTicketDoc = data.TryGetValue("IsTicketDoc", out JsonElement isTicketDocElement) && isTicketDocElement.GetBoolean();
var uploadResults = new List<object>();
Guid? ticketId = null;
if (isTicketDoc && data.TryGetValue("TicketId", out JsonElement ticketIdElement))
{
ticketId = ticketIdElement.GetGuid();
}
string uploadsRoot = "./desktop";
if (isTicketDoc && ticketId.HasValue)
{
// uploadsRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "Uploads", userId.ToString(), ticketId.ToString()));
uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "Uploads", userId.ToString(), ticketId.Value.ToString()));
}
else
{
// uploadsRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "Uploads", userId.ToString()));
uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "Uploads", userId.ToString()));
}
if (!Directory.Exists(uploadsRoot))
{
Directory.CreateDirectory(uploadsRoot);
}
foreach (var file in files)
{
string lastnum = await _docRepository.GetLastSequnce();
string originalFileName = file.GetProperty("FileName").GetString() ?? "document";
string extension = Path.GetExtension(originalFileName).ToLowerInvariant();
string[] allowedExtensions = { ".pdf", ".jpg", ".jpeg", ".png", ".docx", ".mp4", ".mov", ".avi", ".mkv", ".webm", ".3gp" };
if (!allowedExtensions.Contains(extension))
throw new BadRequestException("File type not allowed.");
string safeTimestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
string filename = $"{lastnum}_{userId}_{safeTimestamp}{extension}";
string baseFile = file.GetProperty("Base64").GetString();
if (baseFile.Contains(","))
baseFile = baseFile.Substring(baseFile.IndexOf(",") + 1);
byte[] fileBytes = Convert.FromBase64String(baseFile);
if (fileBytes.Length > 10 * 1024 * 1024)
throw new BadRequestException("File too large.");
string filePath = Path.Combine(uploadsRoot, filename);
await File.WriteAllBytesAsync(filePath, fileBytes);
// Extract FileType from the file element
var fileData = new Dictionary<string, JsonElement> { { "FileType", file.GetProperty("FileType") } };
int docTypeId = DataExtractor.GetRequiredInt(fileData, "FileType");
UserDocument documentMeta = new UserDocument { };
if (isTicketDoc && ticketId.HasValue)
{
documentMeta = new UserDocument
{
UserId = userId,
DocumentName = originalFileName,
DocumentUrl = $"/Uploads/{userId}/{ticketId.Value}/{Uri.EscapeDataString(filename)}",
FkDocumentTypeId = docTypeId,
};
}
else
{
documentMeta = new UserDocument
{
UserId = userId,
DocumentName = originalFileName,
DocumentUrl = $"/Uploads/{userId}/{Uri.EscapeDataString(filename)}",
FkDocumentTypeId = docTypeId,
};
}
var savedDocument = await _docRepository.UploadDocumentAsync(documentMeta);
await _uow.SaveChangesAsync();
uploadResults.Add(new
{
savedDocument.DocumentId,
savedDocument.DocumentName,
DocumentURL = savedDocument.DocumentUrl
});
}
await _uow.CommitAsync();
return new { Message = "Documents uploaded successfully.", uploadResults };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> GetDocumentsByUserId(object payload, HttpContext httpContext)
{
// TODO: Uncomment for production - Currently using static userId for testing
// if (!Guid.TryParse(httpContext.User.FindFirst("UserId")?.Value, out Guid userId))
// {
// throw new InvalidOperationException("Invalid UserId in token.");
// }
Guid userId = Guid.Parse("5b664468-9ba5-4e6e-b73a-97c9a791c9a0");
List<UserDocument> docResults = await _docRepository.GetDocumentsByUserId(userId);
if (docResults == null || docResults.Count == 0)
return new List<object>();
string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), ".."));
var documents = new List<object>();
foreach (var doc in docResults)
{
if (string.IsNullOrEmpty(doc.DocumentUrl))
continue;
string relativePath = doc.DocumentUrl.Replace("/", Path.DirectorySeparatorChar.ToString());
Console.WriteLine("Relative Path: " + relativePath);
string filePath = uploadsRoot + relativePath;
Console.WriteLine("File Path: " + filePath);
if (!File.Exists(filePath))
continue; // Skip missing files safely
byte[] fileBytes = await File.ReadAllBytesAsync(filePath);
documents.Add(new
{
FileName = doc.DocumentName,
FileType = Path.GetExtension(doc.DocumentName),
doc.DocumentUrl,
doc.DocumentType.DocumentType,
Data = Convert.ToBase64String(fileBytes)
});
}
return documents;
}
public async Task<object> GetDocumentById(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
var documentId = DataExtractor.GetRequiredInt(data, "documentId");
var document = await _docRepository.GetDocumentById(documentId);
if (document != null && !string.IsNullOrEmpty(document.DocumentUrl))
{
string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), ".."));
Console.WriteLine("Uploads Root: " + uploadsRoot);
string relativePath = document.DocumentUrl.Replace("/", Path.DirectorySeparatorChar.ToString());
Console.WriteLine("Relative Path: " + relativePath);
string filePath = uploadsRoot + relativePath;
Console.WriteLine("File Path: " + filePath);
if (!File.Exists(filePath))
throw new FileNotFoundException("Document file not found.");
byte[] fileBytes = await File.ReadAllBytesAsync(filePath);
return new
{
FileName = document.DocumentName,
FileType = Path.GetExtension(document.DocumentName),
document.DocumentUrl,
document.DocumentType.DocumentType,
Data = Convert.ToBase64String(fileBytes)
};
}
throw new InvalidOperationException("Document not found or invalid document data.");
}
public async Task<object> RemoveDocument(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var documentId = DataExtractor.GetRequiredInt(data, "documentId");
var result = await _docRepository.RemoveDocument(documentId);
if (!result)
{
throw new NotFoundException("Document not found");
}
await _uow.CommitAsync();
return new { success = true, message = "Document removed successfully" };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<DocumentDownloadModel> GetFileBytesById(int documentId)
{
var document = await _docRepository.GetDocumentById(documentId);
if (document == null || string.IsNullOrEmpty(document.DocumentUrl))
throw new NotFoundException("Document record not found.");
string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), ".."));
string relativePath = document.DocumentUrl.Replace("/", Path.DirectorySeparatorChar.ToString());
string filePath = uploadsRoot + relativePath;
if (!File.Exists(filePath))
throw new FileNotFoundException("Physical file is missing on the server.");
byte[] fileBytes = await File.ReadAllBytesAsync(filePath);
return new DocumentDownloadModel
{
FileName = document.DocumentName,
FileType = Path.GetExtension(document.DocumentName),
FileData = fileBytes
};
}
}
}
+262
View File
@@ -0,0 +1,262 @@
using Fitness_core_BE.DTOs.Facility;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using System.Text.Json;
namespace Fitness_core_BE.Services
{
public class FacilityService : IFacilityService
{
private static readonly string[] ValidFacilityTypes = { "Gym", "Pool", "Court", "Class" };
private readonly IFacilityRepository _facilityRepository;
private readonly IUnitOfWork _uow;
private readonly UserRoleHelper _userRoleHelper;
public FacilityService(IFacilityRepository facilityRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper)
{
_facilityRepository = facilityRepository;
_uow = uow;
_userRoleHelper = userRoleHelper;
}
public async Task<object> GetAllFacilities(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean();
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var facilities = await _facilityRepository.GetAllFacilities(clientId, includeInactive);
return facilities.Select(MapToDto).ToList();
}
public async Task<object> CreateFacility(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
if (!data.ContainsKey("facilityType")) throw new KeyNotFoundException("facilityType is required");
var facilityType = data["facilityType"].GetString() ?? "";
if (!ValidFacilityTypes.Contains(facilityType))
throw new ArgumentException($"facilityType must be one of: {string.Join(", ", ValidFacilityTypes)}");
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var facility = new Facility
{
FKClientId = clientId,
Name = data["name"].GetString() ?? "",
FacilityType = facilityType,
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
DefaultCapacity = data.ContainsKey("defaultCapacity") ? data["defaultCapacity"].GetInt32() : 1
};
if (facility.DefaultCapacity < 1)
throw new ArgumentException("defaultCapacity must be at least 1");
var result = await _facilityRepository.CreateFacility(facility);
await _uow.CommitAsync();
return MapToDto(result);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> UpdateFacility(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data.ContainsKey("facilityId")) throw new KeyNotFoundException("facilityId is required");
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
var existing = await _facilityRepository.GetFacilityById(facilityId)
?? throw new KeyNotFoundException("Facility not found");
if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name;
if (data.ContainsKey("facilityType"))
{
var facilityType = data["facilityType"].GetString() ?? existing.FacilityType;
if (!ValidFacilityTypes.Contains(facilityType))
throw new ArgumentException($"facilityType must be one of: {string.Join(", ", ValidFacilityTypes)}");
existing.FacilityType = facilityType;
}
if (data.ContainsKey("description")) existing.Description = data["description"].GetString();
if (data.ContainsKey("defaultCapacity"))
{
var capacity = data["defaultCapacity"].GetInt32();
if (capacity < 1) throw new ArgumentException("defaultCapacity must be at least 1");
existing.DefaultCapacity = capacity;
}
if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean();
var result = await _facilityRepository.UpdateFacility(existing);
await _uow.CommitAsync();
return MapToDto(result);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> SetFacilityStatus(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
var isActive = data["isActive"].GetBoolean();
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var facility = await _facilityRepository.SetFacilityActive(facilityId, clientId, isActive)
?? throw new KeyNotFoundException("Facility not found");
await _uow.CommitAsync();
return MapToDto(facility);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> CreateFacilitySession(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data.ContainsKey("facilityId")) throw new KeyNotFoundException("facilityId is required");
if (!data.ContainsKey("startTime")) throw new KeyNotFoundException("startTime is required");
if (!data.ContainsKey("endTime")) throw new KeyNotFoundException("endTime is required");
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var facility = await _facilityRepository.GetFacilityById(facilityId);
if (facility == null || facility.FKClientId != clientId)
throw new KeyNotFoundException("Facility not found");
var startTime = DateTime.Parse(data["startTime"].GetString()!).ToUniversalTime();
var endTime = DateTime.Parse(data["endTime"].GetString()!).ToUniversalTime();
if (endTime <= startTime)
throw new ArgumentException("endTime must be after startTime");
int? capacity = data.ContainsKey("capacity") && data["capacity"].ValueKind != JsonValueKind.Null
? data["capacity"].GetInt32()
: null;
if (capacity.HasValue && capacity.Value < 1)
throw new ArgumentException("capacity must be at least 1 when set");
var session = new FacilitySession
{
FKFacilityId = facilityId,
FKClientId = clientId,
StartTime = startTime,
EndTime = endTime,
Capacity = capacity
};
var result = await _facilityRepository.CreateFacilitySession(session);
await _uow.CommitAsync();
return MapToDto(result, facility.Name, 0);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> GetSessions(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
Guid? facilityId = data.ContainsKey("facilityId") && data["facilityId"].ValueKind != JsonValueKind.Null
? Guid.Parse(data["facilityId"].GetString()!)
: null;
var fromUtc = data.ContainsKey("fromDate") && data["fromDate"].ValueKind != JsonValueKind.Null
? DateTime.Parse(data["fromDate"].GetString()!).ToUniversalTime()
: DateTime.UtcNow.Date;
var toUtc = data.ContainsKey("toDate") && data["toDate"].ValueKind != JsonValueKind.Null
? DateTime.Parse(data["toDate"].GetString()!).ToUniversalTime()
: fromUtc.AddDays(7);
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var sessions = await _facilityRepository.GetSessions(clientId, facilityId, fromUtc, toUtc);
var result = new List<FacilitySessionDto>();
foreach (var session in sessions)
{
var bookedCount = await _facilityRepository.GetActiveBookingCount(session.FacilitySessionId);
result.Add(MapToDto(session, session.Facility?.Name, bookedCount));
}
return result;
}
public async Task<object> CancelFacilitySession(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var facilitySessionId = Guid.Parse(data["facilitySessionId"].GetString()!);
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var session = await _facilityRepository.CancelFacilitySession(facilitySessionId, clientId)
?? throw new KeyNotFoundException("Facility session not found");
await _uow.CommitAsync();
return MapToDto(session);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
private static FacilityDto MapToDto(Facility facility) => new()
{
FacilityId = facility.FacilityId,
Name = facility.Name,
FacilityType = facility.FacilityType,
Description = facility.Description,
DefaultCapacity = facility.DefaultCapacity,
IsActive = facility.IsActive,
CreatedAt = facility.CreatedAt
};
private static FacilitySessionDto MapToDto(FacilitySession session, string? facilityName = null, int bookedCount = 0) => new()
{
FacilitySessionId = session.FacilitySessionId,
FacilityId = session.FKFacilityId,
FacilityName = facilityName ?? session.Facility?.Name,
StartTime = session.StartTime,
EndTime = session.EndTime,
Capacity = session.Capacity ?? session.Facility?.DefaultCapacity ?? 1,
BookedCount = bookedCount,
IsCancelled = session.IsCancelled
};
}
}
+355
View File
@@ -0,0 +1,355 @@
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Fitness_core_BE.Models.IOs;
namespace Fitness_core_BE.Services.Feed
{
public class FeedService : IFeedService
{
// DocumentTypes.DocumentId for FEED_POST (seeded in CentralDbContext/AppDBContext).
private const int FeedPostDocumentTypeId = 1005;
private readonly IFeedRepository _repository;
private readonly UserRoleHelper _userRoleHelper;
private readonly IUnitOfWork _uow;
private readonly IActivityLogService _activityLogService;
private readonly IDocumentService _documentService;
private readonly IPushNotificationService _pushNotificationService;
public FeedService(IFeedRepository repository, UserRoleHelper userRoleHelper, IUnitOfWork uow, IActivityLogService activityLogService, IDocumentService documentService, IPushNotificationService pushNotificationService)
{
_repository = repository;
_userRoleHelper = userRoleHelper;
_uow = uow;
_activityLogService = activityLogService;
_documentService = documentService;
_pushNotificationService = pushNotificationService;
}
public async Task<object> GetFeedPostById(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid postId = DataExtractor.GetRequiredGuid(data, "postId");
FeedPost post = await _repository.GetFeedPostByIdAsync(userId, postId);
return post;
}
public async Task<object> GetFeedForCustomer(object payload, HttpContext httpContext)
{
try
{
var data = DeserializePayload.Deserialize(payload);
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
int skip = data.ContainsKey("skip") && data["skip"].TryGetInt32(out var skipValue) ? skipValue : 0;
int take = data.ContainsKey("take") && data["take"].TryGetInt32(out var takeValue) ? takeValue : 10;
// A customer with no active gym has no single client feed to scope to — the
// repository already falls back to the platform-wide feed for them (clientId
// stays null), so this no longer short-circuits to an empty list.
var (clientId, feedList) =
await _repository.GetFeedForCustomerAsync(userId, skip, take);
var feedListWithLikeCount = new List<object>();
foreach (var feed in feedList)
{
var likeCount = await _repository.GetLikeCountAsync(feed.FeedPostId);
feedListWithLikeCount.Add(new
{
feedPostId = feed.FeedPostId,
caption = feed.Caption,
description = feed.Description,
userId = feed.FKUserId,
docId = feed.FkDocId,
isActive = feed.IsActive,
createdAt = feed.CreatedAt,
creator = feed.User.UserName,
likeCount,
clientId = clientId
});
}
return feedListWithLikeCount;
}
catch
{
throw;
}
}
//like
public async Task<object> LikeAsync(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid postId = Guid.Parse(data["postId"].GetString()!);
if (!await _repository.IsLikedAsync(postId, userId))
{
await _repository.AddLikeAsync(postId, userId);
await _uow.CommitAsync();
return new { success = true, message = "Post liked successfully" };
}
return new { success = false, message = "Post already liked" };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
//check like
public async Task<object> IsLikedAsync(object payload, HttpContext httpContext)
{
try
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
var data = DeserializePayload.Deserialize(payload);
Guid postId = Guid.Parse(data["postId"].GetString()!);
bool isLiked = await _repository.IsLikedAsync(postId, userId);
return new { isLiked };
}
catch
{
throw;
}
}
public async Task<object> UnlikeAsync(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid postId = Guid.Parse(data["postId"].GetString()!);
Guid userId = Guid.Parse(data["userId"].GetString()!);
if (await _repository.IsLikedAsync(postId, userId))
{
await _repository.RemoveLikeAsync(postId, userId);
await _uow.CommitAsync();
return new { success = true, message = "Post unliked successfully" };
}
return new { success = false, message = "Post not liked yet" };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
//create feed
public async Task<object> CreateFeed(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
// Tag every attachment with the FEED_POST document type unless the caller
// already chose one. Must match a DocumentTypes row (UserDocuments FKs to it).
object uploadPayload = payload;
if (data.TryGetValue("document", out var documentsElement) &&
documentsElement.ValueKind == System.Text.Json.JsonValueKind.Array)
{
var payloadJson = System.Text.Json.JsonSerializer.Serialize(data);
var payloadDict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, object>>(
payloadJson) ?? new Dictionary<string, object>();
var documentsArray = System.Text.Json.JsonSerializer.Deserialize<List<Dictionary<string, object>>>(
documentsElement.GetRawText()) ?? new List<Dictionary<string, object>>();
foreach (var document in documentsArray)
{
if (!document.ContainsKey("FileType"))
{
document["FileType"] = FeedPostDocumentTypeId;
}
}
payloadDict["document"] = documentsArray;
uploadPayload = payloadDict;
}
var docResponseObj = await _documentService.UploadDocument(uploadPayload, httpContext);
dynamic docResponse = docResponseObj;
if (docResponse == null || docResponse?.uploadResults == null || docResponse?.uploadResults.Count == 0)
{
await _uow.RollbackAsync();
return new { success = false, message = "Document upload failed" };
}
var firstDoc = docResponse?.uploadResults[0];
int docId = firstDoc?.DocumentId ?? 0;
var feedPost = new FeedPost
{
Caption = data.ContainsKey("caption") ? data["caption"].GetString() : null,
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
FkDocId = docId,
FKUserId = userId
};
await _repository.CreateFeedAsync(feedPost);
await _uow.CommitAsync();
try
{
var customerIds = await _repository.GetActiveCustomerIdsByPosterUserIdAsync(userId);
if (customerIds.Count > 0)
{
var caption = feedPost.Caption;
var body = string.IsNullOrWhiteSpace(caption)
? "Your gym shared a new post. Tap to view."
: caption!.Length > 120 ? caption[..120] + "…" : caption;
await _pushNotificationService.NotifyCustomersAsync(
customerIds,
"New post from your gym",
body,
"FeedPostCreated",
"MainTabs",
new { screen = "Connect" }
);
}
}
catch (Exception notifyEx)
{
Console.WriteLine($"Push notification failed (non-critical): {notifyEx.Message}");
}
return new { success = true, message = "Feed created successfully" };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> ShowFeedByClient(object payload, HttpContext httpContext)
{
try
{
var data = DeserializePayload.Deserialize(payload);
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var feedList = await _repository.GetFeedByClientAsync(clientId);
if (feedList == null || !feedList.Any())
{
return new { success = false, message = "No feeds found", data = new List<object>() };
}
var FeedListWithDoc = new List<object>();
foreach (var feed in feedList)
{
object? documentData = null;
if (feed.FkDocId.HasValue)
{
try
{
var docPayload = new { documentId = feed.FkDocId.Value };
documentData = await _documentService.GetDocumentById(docPayload, httpContext);
}
catch
{
documentData = null;
}
}
var likeCount = await _repository.GetLikeCountAsync(feed.FeedPostId);
FeedListWithDoc.Add(new
{
feedPostId = feed.FeedPostId,
caption = feed.Caption,
description = feed.Description,
userId = feed.FKUserId,
docId = feed.FkDocId,
isActive = feed.IsActive,
createdAt = feed.CreatedAt,
likeCount = likeCount,
document = documentData
});
}
return new { success = true, message = "Feeds retrieved successfully", data = FeedListWithDoc };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> DeleteFeedByPostId(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
Guid createUserId = DataExtractor.GetUserIdFromContext(httpContext);
Guid postId = DataExtractor.GetRequiredGuid(data, "postId");
await _uow.BeginAsync();
try
{
var result =await _repository.DeletePostPostIdAsync(createUserId, postId);
await _uow.CommitAsync();
return result;
}
catch
{
await _uow.RollbackAsync();
throw new BadRequestException ("delete post failed") ;
}
}
}
}
@@ -0,0 +1,128 @@
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Fitness_core_BE.Models.IOs;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class AccessDeviceFunctionHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public AccessDeviceFunctionHandler(IAccessDeviceService accessDeviceService)
{
// Admin-auth actions only. The cloud no longer talks to devices directly:
// - AddDevice / RemoveDevice / GeneratePairingCode are pure DB operations.
// - AddUser / WhiteListMember / BlackListMember do their local DB work and
// ENQUEUE a DeviceCommand for the on-prem agent to execute.
// Device communication (attendance pull, command execution, enrollment)
// is handled by the agent against the AgentApiKey-protected endpoints in AgentController.
_functionHandler = new()
{
{ "AddDevice", (payload, _) => accessDeviceService.AddDevice(payload) },
{ "RemoveDevice", (payload, _) => accessDeviceService.RemoveDevice(payload) },
{ "GeneratePairingCode", (payload, _) => accessDeviceService.GeneratePairingCode(payload) },
{ "AddUser", AddUser },
{ "WhiteListMember", WhiteListMember },
{ "BlackListMember", BlackListMember }
};
Task<object> AddUser(object payload, HttpContext _)
{
var data = DeserializePayload.Deserialize(payload);
var customerAccess = new CustomerAccess
{
Name = DataExtractor.GetRequiredString(data, "Name"),
FKDeviceId = DataExtractor.GetRequiredGuid(data, "FKDeviceId"),
FKCustomerId = DataExtractor.GetRequiredGuid(data, "FKCustomerId")
};
return accessDeviceService.AddUser(customerAccess);
}
Task<object> WhiteListMember(object payload, HttpContext _)
{
var data = DeserializePayload.Deserialize(payload);
var customerId = DataExtractor.GetRequiredGuid(data, "customerId");
return accessDeviceService.WhiteListMember(customerId).ContinueWith(t => (object)t.Result);
}
Task<object> BlackListMember(object payload, HttpContext _)
{
var data = DeserializePayload.Deserialize(payload);
var customerId = DataExtractor.GetRequiredGuid(data, "customerId");
return accessDeviceService.BlackListMember(customerId).ContinueWith(t => (object)t.Result);
}
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null!
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null!
};
}
catch (NotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = ex.Message,
Data = null!
};
}
catch (BadRequestException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (ForbiddenException ex)
{
return new ApiResponse<object>
{
StatusCode = 403,
Message = ex.Message,
Data = null!
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null!
};
}
}
}
}
@@ -0,0 +1,25 @@
using Fitness_core_BE.Services.ActivityLog;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class ActivityLogFuncHandler
{
private readonly IActivityLogService _activityLogService;
public ActivityLogFuncHandler(IActivityLogService activityLogService)
{
_activityLogService = activityLogService;
}
public async Task<object> ExecuteAsync(string functionName, object payload, HttpContext httpContext)
{
return functionName switch
{
"LogActivity" => await _activityLogService.LogActivity(payload, httpContext),
"GetActivityLogs" => await _activityLogService.GetActivityLogs(payload, httpContext),
_ => throw new NotImplementedException($"Function '{functionName}' is not implemented.")
};
}
}
}
@@ -0,0 +1,90 @@
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models.IOs;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class AttendanceFunctionHandler
{
private readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public AttendanceFunctionHandler(IAttendanceService attendanceService)
{
_functionHandler = new()
{
{ "GetAllAttendanceRecords", attendanceService.GetAllAttendanceRecords },
{ "GetAttendanceCounts", attendanceService.GetAttendanceCounts },
{ "GetTodayAttendance", attendanceService.GetTodayAttendance }
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null!
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null!
};
}
catch (NotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = ex.Message,
Data = null!
};
}
catch (BadRequestException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (ForbiddenException ex)
{
return new ApiResponse<object>
{
StatusCode = 403,
Message = ex.Message,
Data = null!
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null!
};
}
}
}
}
+100
View File
@@ -0,0 +1,100 @@
using Fitness_core_BE.Models.IOs;
using Fitness_core_BE.Services.Auth;
using Fitness_core_BE.DTOs.Auth;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class AuthFuncHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<AuthResponse>>> _functionHandler;
public AuthFuncHandler(IAuthService authService)
{
_functionHandler = new()
{
{"SendOtp", authService.SendOtp },
{"VerifyOTP", authService.VerifyOTP },
{"IsAvailable", authService.IsAvailable },
{"RegisterUser", authService.RegisterUser },
{"UpdateUser", authService.UpdateUser },
{"GetUserDetails", authService.GetUserDetails },
{"LoginUser", authService.LoginUser },
{"RefreshToken", authService.RefreshToken },
{"VerifyOtpForLogin", authService.VerifyOtpForLogin },
{"forgotPassword", authService.ForgotPassword },
{"resetPassword", authService.ResetPassword },
{"resetPasswordWithToken", authService.ResetPasswordWithToken },
{"LogOut", authService.LogOut }
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null
};
}
try
{
var authResponse = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = authResponse.StatusCode,
Message = authResponse.Message,
Data = authResponse.Data
};
}
catch (UnauthorizedException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
};
}
catch (NotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = ex.Message,
};
}
catch (BadRequestException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
};
}
catch (ForbiddenException ex)
{
return new ApiResponse<object>
{
StatusCode = 403,
Message = ex.Message,
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
};
}
}
}
}
@@ -0,0 +1,81 @@
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models.IOs;
using Fitness_core_BE.Services;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class ChatFunctionHandler
{
public readonly Dictionary<string, Func<object, Task<object>>> _functionHandler;
public ChatFunctionHandler(IChatService chatService)
{
_functionHandler = new()
{
{ "GetOrCreateChatAsync", chatService.GetOrCreateChatAsync },
{ "GetClientById", chatService.SendMessageAsync },
{ "RegisterClient", chatService.GetMessagesAsync }
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null
};
}
try
{
var results = await handler(request.Payload);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedAccessException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null
};
}
catch (KeyNotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null
};
}
catch (InvalidOperationException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null
};
}
}
}
}
@@ -0,0 +1,106 @@
using Fitness_core_BE.Models.IOs;
using Fitness_core_BE.Services;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class ClientFuncHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public ClientFuncHandler(IClientService clientService)
{
_functionHandler = new()
{
{ "GetClientList", clientService.GetClientList },
{ "GetClientById", clientService.GetClientById },
{ "GetCustomerDetailsById", clientService.GetCustomerDetailsById },
{ "CreateCustomerWithPlans", clientService.CreateCustomerWithPlans },
{ "UpdateCustomerByClient", clientService.UpdateCustomerByClient },
{"GetCustomersByClientId", clientService.GetCustomersByClientId},
{"GetCustomerPlansByClientId", clientService.GetCustomerPlansByClientId},
{"RegisterClient", clientService.RegisterClient},
{ "GetPackageList", clientService.GetPackageList },
{"GetAllCustomers", clientService.GetAllCustomers },
{"DeleteCustomerByClient", clientService.DeleteCustomerByClient },
{"ClientLogOut", clientService.ClientLogOut },
{"GetAllCustomerPlans", clientService.GetAllCustomerPlans },
{"GetMealPlansByClientId", clientService.GetMealPlansByClientId },
{"GetWorkoutPlansByClientId", clientService.GetWorkoutPlansByClientId },
{"GetMealPlanById", clientService.GetMealPlanById },
{"GetWorkoutPlanById", clientService.GetWorkoutPlanById },
{"GetCustomerPlanById", clientService.GetCustomerPlanById },
{"GetOwnCards", clientService.GetCardDetails },
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null
};
}
catch (NotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = ex.Message,
Data = null
};
}
catch (BadRequestException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null
};
}
catch (ForbiddenException ex)
{
return new ApiResponse<object>
{
StatusCode = 403,
Message = ex.Message,
Data = null
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null
};
}
}
}
}
@@ -0,0 +1,83 @@
using Fitness_core_BE.Models.IOs;
using Fitness_core_BE.Services.ClientInvoice;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class ClientInvoiceFunctionHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public ClientInvoiceFunctionHandler(IClientInvoiceService clientInvoiceService)
{
_functionHandler = new()
{
{ "CreateClientInvoiceFromSubscription", clientInvoiceService.CreateClientInvoiceFromSubscription },
{ "GetClientInvoiceById", clientInvoiceService.GetClientInvoiceById },
{ "GetClientInvoicesByClientId", clientInvoiceService.GetClientInvoicesByClientId },
{ "GetClientInvoicesByUserId", clientInvoiceService.GetClientInvoicesByUserId },
{ "UpdateClientInvoiceStatus", clientInvoiceService.UpdateClientInvoiceStatus },
{ "CalculateClientInvoiceTotal", clientInvoiceService.CalculateClientInvoiceTotal }
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null!
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedAccessException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null!
};
}
catch (KeyNotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (InvalidOperationException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null!
};
}
}
}
}
@@ -0,0 +1,77 @@
using Fitness_core_BE.Models.IOs;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Exceptions;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class ClientStatFuncHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public ClientStatFuncHandler(IClientStatService clientStatService)
{
_functionHandler = new()
{
{ "GetTotalCustomerCount", clientStatService.GetTotalCustomerCount },
{ "GetMonthlyCustomerCount", clientStatService.GetMonthlyCustomerCount },
{ "GetTotalWorkoutPlanCount", clientStatService.GetTotalWorkoutPlanCount },
{ "GetTotalMealPlanCount", clientStatService.GetTotalMealPlanCount },
{ "GetLastMonthCustomerCount", clientStatService.GetLastMonthCustomerCount },
{ "GetLastFiveAttendances", clientStatService.GetLastFiveAttendances },
{ "GetLastFivePayments", clientStatService.GetLastFivePayments },
{ "GetLastMonthRevenue", clientStatService.GetLastMonthRevenue },
{ "GetMonthlyRevenueVariation", (payload, ctx) => clientStatService.GetMonthlyRevenueVariation(payload, ctx) },
{ "GetTotalRevenue", clientStatService.GetTotalRevenue },
{ "GetClientStatSummary", (payload, ctx) => clientStatService.GetClientStatSummary(payload, ctx) }
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedException ex)
{
return new ApiResponse<object> { StatusCode = 401, Message = ex.Message, Data = null };
}
catch (NotFoundException ex)
{
return new ApiResponse<object> { StatusCode = 404, Message = ex.Message, Data = null };
}
catch (BadRequestException ex)
{
return new ApiResponse<object> { StatusCode = 400, Message = ex.Message, Data = null };
}
catch (ForbiddenException ex)
{
return new ApiResponse<object> { StatusCode = 403, Message = ex.Message, Data = null };
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null
};
}
}
}
}
@@ -0,0 +1,104 @@
using Fitness_core_BE.Models.IOs;
using Fitness_core_BE.Services.CustomerService;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class CustomerFunctionHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public CustomerFunctionHandler(ICustomerService customerService)
{
_functionHandler = new()
{
{ "CreateCustomer", customerService.CreateCustomer },
{ "DeleteCustomer", customerService.DeleteCustomer },
{ "UpdateCustomer", customerService.UpdateCustomer },
{ "GetCustomerMealPlan", customerService.GetCustomerMealPlan },
{ "GetCustomerMealPlanById", customerService.GetCustomerMealPlanById },
{ "GetCustomerWorkOutPlan", customerService.GetCustomerWorkOutPlan },
{ "GetCustomerWorkoutPlanById", customerService.GetCustomerWorkoutPlanById },
{ "FollowMealPlan", customerService.FollowMealPlan },
{ "GetFollowedMealPlans", customerService.GetFollowedMealPlans },
{ "GetFollowedWorkoutPlans", customerService.GetFollowedWorkoutPlans },
{ "FollowWorkOutPlan", customerService.FollowWorkOutPlan },
{ "GetClientDataById", customerService.GetClientDataById },
{ "CreateCusHealthRec", customerService.CreateCusHealthRec },
{ "CreateCusProgressRec", customerService.CreateCusProgressRec },
{ "UpdateCusHealthRec", customerService.UpdateCusHealthRec },
{ "UpdateCusProgressRec", customerService.UpdateCusProgressRec },
{ "UpdateCustomerTargetNutritions", customerService.UpdateCustomerTargetNutritions },
{ "GetClientGyms", customerService.GetClientGyms },
{ "GetCustomerProgress", customerService.GetCustomerProgress },
{ "UpdateWeeklyProgress", customerService.UpdateWeeklyProgress },
{ "GetWeeklyProgressForCustomer", customerService.GetWeeklyProgressForCustomer },
{ "GetCusBasicDetails", customerService.GetCusBasicDetails },
{ "GetCusHealthDetails", customerService.GetCusHealthDetails },
{ "GetCusProgressDetails", customerService.GetCusProgressDetails },
{"CreateFeed", customerService.CreateFeed },
{"GetOwnCards", customerService.GetCardDetails }
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null!
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedAccessException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null!
};
}
catch (KeyNotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (InvalidOperationException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null!
};
}
}
}
}
@@ -0,0 +1,83 @@
using Fitness_core_BE.Models.IOs;
using Fitness_core_BE.Services.CustomerInvoice;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class CustomerInvoiceFunctionHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public CustomerInvoiceFunctionHandler(ICustomerInvoiceService customerInvoiceService)
{
_functionHandler = new()
{
{ "CreateCustomerInvoiceFromSubscription", customerInvoiceService.CreateCustomerInvoiceFromSubscription },
{ "GetCustomerInvoiceById", customerInvoiceService.GetCustomerInvoiceById },
{ "GetCustomerInvoicesByCustomerId", customerInvoiceService.GetCustomerInvoicesByCustomerId },
{ "GetCustomerInvoicesByClientId", customerInvoiceService.GetCustomerInvoicesByClientId },
{ "UpdateCustomerInvoiceStatus", customerInvoiceService.UpdateCustomerInvoiceStatus },
{ "CalculateCustomerInvoiceTotal", customerInvoiceService.CalculateCustomerInvoiceTotal }
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null!
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedAccessException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null!
};
}
catch (KeyNotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (InvalidOperationException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null!
};
}
}
}
}
@@ -0,0 +1,83 @@
using Fitness_core_BE.Models.IOs;
using Fitness_core_BE.Services.CustomerPlanService;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class CustomerPlanFunctionHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public CustomerPlanFunctionHandler(ICustomerPlanService customerPlanService)
{
_functionHandler = new()
{
{ "GetAllCustomerPlans", customerPlanService.GetAllCustomerPlans },
{ "GetCustomerPlanById", customerPlanService.GetCustomerPlanById },
{ "CreateCustomerPlan", customerPlanService.CreateCustomerPlan },
{ "UpdateCustomerPlan", customerPlanService.UpdateCustomerPlan },
{ "SetCustomerPlanStatus", customerPlanService.SetCustomerPlanStatus },
{ "DeleteCustomerPlan", customerPlanService.DeleteCustomerPlan }
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null!
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedAccessException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null!
};
}
catch (KeyNotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (InvalidOperationException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null!
};
}
}
}
}
@@ -0,0 +1,81 @@
using Fitness_core_BE.Models.IOs;
using Fitness_core_BE.Services.CustomerSubscription;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class CustomerSubscriptionFunctionHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public CustomerSubscriptionFunctionHandler(ICustomerSubscriptionService customerSubscriptionService)
{
_functionHandler = new()
{
{ "CreateCustomerSubscription", customerSubscriptionService.CreateCustomerSubscription },
{ "GetCustomerSubscriptionById", customerSubscriptionService.GetCustomerSubscriptionById },
{ "GetCustomerSubscriptionsByCustomerId", customerSubscriptionService.GetCustomerSubscriptionsByCustomerId },
{ "GetActiveSubscriptionByCustomerId", customerSubscriptionService.GetActiveSubscriptionByCustomerId }
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null!
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedAccessException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null!
};
}
catch (KeyNotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (InvalidOperationException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null!
};
}
}
}
}
@@ -0,0 +1,90 @@
using Fitness_core_BE.Models.IOs;
using Fitness_core_BE.Services.Document;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class DocumentFunctionHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public DocumentFunctionHandler(IDocumentService documentService)
{
_functionHandler = new()
{
{ "UploadDocument", documentService.UploadDocument },
{ "GetDocumentsByUserId", documentService.GetDocumentsByUserId },
{ "GetDocumentById", documentService.GetDocumentById },
{ "RemoveDocument", documentService.RemoveDocument }
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null!
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedAccessException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null!
};
}
catch (KeyNotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (InvalidOperationException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (FileNotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = ex.Message,
Data = null!
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null!
};
}
}
}
}
@@ -0,0 +1,96 @@
using Fitness_core_BE.Models.IOs;
using Fitness_core_BE.Services.Feed;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class FeedFuncHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public FeedFuncHandler(IFeedService feedService)
{
_functionHandler = new()
{
{ "GetFeedPostById", feedService.GetFeedPostById },
{ "GetFeedList", feedService.GetFeedForCustomer },
{ "Like", feedService.LikeAsync },
{ "Unlike", feedService.UnlikeAsync },
{ "IsLiked", feedService.IsLikedAsync },
{ "CreateFeed", feedService.CreateFeed },
{ "ShowFeedByClientId", feedService.ShowFeedByClient },
{ "DeleteFeedByPostId", feedService.DeleteFeedByPostId }
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null
};
}
catch (NotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = ex.Message,
Data = null
};
}
catch (BadRequestException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null
};
}
catch (ForbiddenException ex)
{
return new ApiResponse<object>
{
StatusCode = 403,
Message = ex.Message,
Data = null
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null
};
}
}
}
}
@@ -0,0 +1,88 @@
using Fitness_core_BE.Models.IOs;
using Fitness_core_BE.Services.Meal;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class MealFunctionHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public MealFunctionHandler(IMealService mealService)
{
_functionHandler = new()
{
{ "GetMealCategoryList", mealService.GetMealCategoryList },
{ "CreateMealCategory", mealService.CreateMealCategory },
{ "GetMealPlanList", mealService.GetMealPlanList },
{ "GetMealPlanById", mealService.GetMealPlanById },
{ "GetMealPlanByCustomerId", mealService.GetMealPlanByCustomerId },
{ "CreateMealPlan", mealService.CreateMealPlan },
{ "CreateCustomerMealPlan", mealService.CreateCustomerMealPlan },
{ "UpdateMealPlan", mealService.UpdateMealPlan },
{ "GetMealItemsByMealPlanId", mealService.GetMealItemsByMealPlanId },
{ "CreateMealItem", mealService.CreateMealItem },
{ "DeleteMealPlan", mealService.DeleteMealPlan }
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedAccessException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null
};
}
catch (KeyNotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null
};
}
catch (InvalidOperationException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null
};
}
}
}
}
@@ -0,0 +1,83 @@
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models.IOs;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class PaymentFunctionHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public PaymentFunctionHandler(IPaymentService paymentService)
{
_functionHandler = new()
{
{ "CreateCustomerPayment", paymentService.CreateCustomerPayment },
{ "CreateClientPayment", paymentService.CreateClientPayment },
{ "GetPaymentById", paymentService.GetPaymentById },
{ "GetPaymentsByCustomerInvoice", paymentService.GetPaymentsByCustomerInvoice },
{ "GetPaymentsByClientInvoice", paymentService.GetPaymentsByClientInvoice },
{ "ApprovePayment", paymentService.ApprovePayment },
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null!
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (NotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = ex.Message,
Data = null!
};
}
catch (BadRequestException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (UnauthorizedException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null!
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null!
};
}
}
}
}
@@ -0,0 +1,83 @@
using Fitness_core_BE.Models.IOs;
using Fitness_core_BE.Services.Staff;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class StaffFunctionHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public StaffFunctionHandler(IStaffService staffService)
{
_functionHandler = new()
{
{ "GetAllStaff", staffService.GetAllStaff },
{ "GetStaffById", staffService.GetStaffById },
{ "GetStaffByClientId", staffService.GetStaffByClientId },
{ "CreateStaff", staffService.CreateStaff },
{ "UpdateStaff", staffService.UpdateStaff },
{ "DeleteStaff", staffService.DeleteStaff }
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null!
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedAccessException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null!
};
}
catch (KeyNotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (InvalidOperationException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null!
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null!
};
}
}
}
}
@@ -0,0 +1,87 @@
using Fitness_core_BE.Models.IOs;
using Fitness_core_BE.Services.Ticket;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class TicketFuncHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public TicketFuncHandler(ITicketService ticketService)
{
_functionHandler = new()
{
{ "GetTicketList", ticketService.GetTicketList },
{ "GetTicketById", ticketService.GetTicketById },
{ "GetTicketByUserId", ticketService.GetTicketByUserId },
{ "GetTicketCategoryList", ticketService.GetTicketCategoryList },
{ "CreateTicket", ticketService.CreateTicket },
{ "UpdateTicket", ticketService.UpdateTicket },
{ "SendMessage", ticketService.SendMessage },
{ "SubmitTicketFeedback", ticketService.SubmitTicketFeedback },
{ "CloseTicket", ticketService.CloseTicket },
{ "DeleteTicket", ticketService.DeleteTicket }
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedAccessException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null
};
}
catch (KeyNotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null
};
}
catch (InvalidOperationException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null
};
}
}
}
}
@@ -0,0 +1,78 @@
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models.IOs;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class UserRegisterHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public UserRegisterHandler(ICustomerService customerService, IClientService clientService)
{
_functionHandler = new()
{
{ "CreateCustomer", customerService.CreateCustomer },
{"RegisterClient", clientService.RegisterClient }
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedAccessException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null
};
}
catch (KeyNotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null
};
}
catch (InvalidOperationException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null
};
}
}
}
}
@@ -0,0 +1,88 @@
using Fitness_core_BE.Models.IOs;
using Fitness_core_BE.Services.Workout;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services.FunctionHandler
{
public class WorkoutFunctionHandler
{
public readonly Dictionary<string, Func<object, HttpContext, Task<object>>> _functionHandler;
public WorkoutFunctionHandler(IWorkoutService workoutService)
{
_functionHandler = new()
{
{ "GetWorkoutCategoryList", workoutService.GetWorkoutCategoryList },
{ "CreateWorkoutCategory", workoutService.CreateWorkoutCategory },
{ "GetWorkoutPlanList", workoutService.GetWorkoutPlanList },
{ "GetWorkoutPlanById", workoutService.GetWorkoutPlanById },
{ "GetWorkoutPlanByCustomerId", workoutService.GetWorkoutPlanByCustomerId },
{ "CreateWorkoutPlan", workoutService.CreateWorkoutPlan },
{ "CreateCustomerWorkout", workoutService.CreateCustomerWorkout },
{ "UpdateWorkoutPlan", workoutService.UpdateWorkoutPlan },
{ "DeleteWorkoutPlan", workoutService.DeleteWorkoutPlan },
{ "GetWorkoutItemsByWorkoutPlanId", workoutService.GetWorkoutItemsByWorkoutPlanId },
{ "CreateWorkoutItem", workoutService.CreateWorkoutItem }
};
}
public async Task<ApiResponse<object>> Execute(ApiRequest request, HttpContext httpContext)
{
if (!_functionHandler.TryGetValue(request.FunctionName, out var handler))
{
return new ApiResponse<object>
{
StatusCode = 404,
Message = $"Function '{request.FunctionName}' not found.",
Data = null
};
}
try
{
var results = await handler(request.Payload, httpContext);
return new ApiResponse<object>
{
StatusCode = 200,
Message = "success",
Data = results
};
}
catch (UnauthorizedAccessException ex)
{
return new ApiResponse<object>
{
StatusCode = 401,
Message = ex.Message,
Data = null
};
}
catch (KeyNotFoundException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null
};
}
catch (InvalidOperationException ex)
{
return new ApiResponse<object>
{
StatusCode = 400,
Message = ex.Message,
Data = null
};
}
catch (Exception ex)
{
return new ApiResponse<object>
{
StatusCode = 500,
Message = $"An error occurred: {ex.Message}",
Data = null
};
}
}
}
}
+126
View File
@@ -0,0 +1,126 @@
using Fitness_core_BE.DTOs.Rbac;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Microsoft.AspNetCore.Http;
namespace Fitness_core_BE.Services
{
public class GymRoleService : IGymRoleService
{
private readonly IGymRoleRepository _gymRoleRepository;
private readonly INavItemRepository _navItemRepository;
private readonly UserRoleHelper _userRoleHelper;
private readonly IUnitOfWork _uow;
public GymRoleService(
IGymRoleRepository gymRoleRepository,
INavItemRepository navItemRepository,
UserRoleHelper userRoleHelper,
IUnitOfWork uow)
{
_gymRoleRepository = gymRoleRepository;
_navItemRepository = navItemRepository;
_userRoleHelper = userRoleHelper;
_uow = uow;
}
public async Task<List<GymRoleDto>> GetRolesAsync(HttpContext httpContext)
{
var clientId = await GetOwnerClientId(httpContext);
var roles = await _gymRoleRepository.GetRolesByClientId(clientId);
return roles.Select(ToDto).ToList();
}
public async Task<GymRoleDto> CreateRoleAsync(CreateGymRoleRequest request, HttpContext httpContext)
{
var clientId = await GetOwnerClientId(httpContext);
var role = new GymRole { FKClientId = clientId, Name = request.Name };
var created = await _gymRoleRepository.CreateRole(role);
await _uow.SaveChangesAsync();
return ToDto(created);
}
public async Task<GymRoleDto> UpdateRoleAsync(Guid gymRoleId, UpdateGymRoleRequest request, HttpContext httpContext)
{
var clientId = await GetOwnerClientId(httpContext);
var role = await GetOwnedRole(gymRoleId, clientId);
if (request.Name != null) role.Name = request.Name;
if (request.IsActive.HasValue) role.IsActive = request.IsActive.Value;
var updated = await _gymRoleRepository.UpdateRole(role);
await _uow.SaveChangesAsync();
return ToDto(updated);
}
public async Task DeleteRoleAsync(Guid gymRoleId, HttpContext httpContext)
{
var clientId = await GetOwnerClientId(httpContext);
var role = await GetOwnedRole(gymRoleId, clientId);
await _gymRoleRepository.DeleteRole(role);
await _uow.SaveChangesAsync();
}
public async Task<GymRoleDto> SetRolePermissionsAsync(Guid gymRoleId, SetRolePermissionsRequest request, HttpContext httpContext)
{
var clientId = await GetOwnerClientId(httpContext);
var role = await GetOwnedRole(gymRoleId, clientId);
var requestedKeys = request.NavKeys?.Distinct().ToList() ?? new List<string>();
var assignableKeys = (await _navItemRepository.GetActiveNavItemsOrdered())
.Where(item => !item.IsOwnerOnly && item.Key != "dashboard")
.Select(item => item.Key)
.ToHashSet();
var invalidKeys = requestedKeys.Where(key => !assignableKeys.Contains(key)).ToList();
if (invalidKeys.Count > 0)
throw new BadRequestException($"Invalid role permission: {string.Join(", ", invalidKeys)}.");
await _gymRoleRepository.ReplacePermissions(gymRoleId, requestedKeys);
await _uow.SaveChangesAsync();
var refreshed = await _gymRoleRepository.GetRoleById(gymRoleId)
?? throw new NotFoundException("Role not found.");
return ToDto(refreshed);
}
public async Task AssignStaffRoleAsync(Guid staffId, AssignStaffRoleRequest request, HttpContext httpContext)
{
var clientId = await GetOwnerClientId(httpContext);
var assigned = await _gymRoleRepository.AssignStaffRole(staffId, clientId, request.GymRoleId);
if (!assigned)
throw new NotFoundException("Staff member or role not found for this gym.");
await _uow.SaveChangesAsync();
}
// Every public method funnels through here: only a caller who resolves to a Client
// (the gym owner) can manage roles. Staff — regardless of any nav-section permission
// they've been granted, including "staff" — are rejected before any GymRole/
// GymRolePermission data is ever touched.
private async Task<Guid> GetOwnerClientId(HttpContext httpContext)
{
var userId = DataExtractor.GetUserIdFromContext(httpContext);
return await _userRoleHelper.GetOwnedClientId(userId)
?? throw new UnauthorizedAccessException("Only the gym owner can manage roles and permissions.");
}
private async Task<GymRole> GetOwnedRole(Guid gymRoleId, Guid clientId)
{
var role = await _gymRoleRepository.GetRoleById(gymRoleId)
?? throw new NotFoundException("Role not found.");
if (role.FKClientId != clientId)
throw new NotFoundException("Role not found.");
return role;
}
private static GymRoleDto ToDto(GymRole role) => new()
{
GymRoleId = role.GymRoleId,
Name = role.Name,
IsActive = role.IsActive,
NavKeys = role.Permissions?.Select(p => p.NavItemKey).ToList() ?? new List<string>()
};
}
}
+49
View File
@@ -0,0 +1,49 @@
using Fitness_core_BE.Models;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Services
{
public sealed record JobRunRecord(
string JobName, Guid ClientId, DateTime RunAtUtc, string Status,
int SubscriptionsRenewed, string? ErrorMessage);
// Persisted in CentralDbContext.JobRunLogs. Callers pass in whichever CentralDbContext they
// already have open -- background jobs get theirs from TenantSchemaIterator (central is one
// fixed database regardless of tenant, so any instance points at the same schema), the admin
// controller gets a normal DI-injected one since it runs inside a real HTTP request.
public interface IJobRunHistory
{
Task RecordAsync(CentralDbContext central, JobRunRecord record, CancellationToken ct = default);
Task<IReadOnlyList<JobRunRecord>> GetRecentAsync(CentralDbContext central, string jobName, int take, CancellationToken ct = default);
}
public sealed class DbJobRunHistory : IJobRunHistory
{
public async Task RecordAsync(CentralDbContext central, JobRunRecord record, CancellationToken ct = default)
{
central.JobRunLogs.Add(new JobRunLog
{
JobRunLogId = Guid.NewGuid(),
JobName = record.JobName,
FKClientId = record.ClientId,
RunAtUtc = record.RunAtUtc,
Status = record.Status,
SubscriptionsRenewed = record.SubscriptionsRenewed,
ErrorMessage = record.ErrorMessage
});
await central.SaveChangesAsync(ct);
}
public async Task<IReadOnlyList<JobRunRecord>> GetRecentAsync(
CentralDbContext central, string jobName, int take, CancellationToken ct = default)
{
return await central.JobRunLogs
.AsNoTracking()
.Where(r => r.JobName == jobName)
.OrderByDescending(r => r.RunAtUtc)
.Take(take)
.Select(r => new JobRunRecord(r.JobName, r.FKClientId, r.RunAtUtc, r.Status, r.SubscriptionsRenewed, r.ErrorMessage))
.ToListAsync(ct);
}
}
}
+975
View File
@@ -0,0 +1,975 @@
using Fitness_core_BE.Models;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Exceptions;
namespace Fitness_core_BE.Services.Meal
{
public class MealService : IMealService
{
private readonly IMealRepository _repository;
private readonly UserRoleHelper _userRoleHelper;
private readonly IUnitOfWork _uow;
private readonly IActivityLogService _activityLogService;
private readonly IPushNotificationService _pushNotificationService;
public MealService(IMealRepository repository, UserRoleHelper userRoleHelper, IUnitOfWork uow, IActivityLogService activityLogService, IPushNotificationService pushNotificationService)
{
_repository = repository;
_userRoleHelper = userRoleHelper;
_uow = uow;
_activityLogService = activityLogService;
_pushNotificationService = pushNotificationService;
}
// Meal Category operations
public async Task<object> GetMealCategoryList(object payload, HttpContext httpContext)
{
try
{
var categories = await _repository.GetMealCategoryListAsync();
return categories;
}
catch
{
throw;
}
}
public async Task<object> CreateMealCategory(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var mealCategory = new MealCategory
{
Name = data["name"].GetString()!,
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
CreateDate = DateTime.UtcNow
};
await _repository.CreateMealCategoryAsync(mealCategory);
await _uow.CommitAsync();
try
{
await _activityLogService.LogActivity(new { description = $"Meal category created with ID {mealCategory.MealCategoryId}" }, httpContext);
}
catch (Exception logEx)
{
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
}
return mealCategory;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
// Meal Plan operations
public async Task<object> GetMealPlanList(object payload, HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
string userType = await _userRoleHelper.GetUserType(userId);
Guid clientId;
if (userType == "Customer")
{
Guid cusId = await _userRoleHelper.GetCusId(userId);
try
{
clientId = await _userRoleHelper.GetClientIdByCusId(cusId);
}
catch (NotFoundException)
{
// Not every customer has joined a gym yet — they should still see the
// platform-wide admin suggestions (FKClientId == null in the repo query).
clientId = Guid.Empty;
}
}
else
{
clientId = await _userRoleHelper.GetClientId(userId);
}
try
{
var mealPlans = await _repository.GetMealPlanListAsync(userType, clientId);
return mealPlans;
}
catch
{
throw;
}
}
public async Task<object> GetMealPlanById(object payload, HttpContext httpContext)
{
try
{
var data = DeserializePayload.Deserialize(payload);
Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!);
var mealPlan = await _repository.GetMealPlanByIdAsync(mealPlanId);
if (mealPlan == null)
throw new KeyNotFoundException($"Meal plan with ID {mealPlanId} not found");
// Parse MealItems JSON strings to objects in nested meal items
if (mealPlan.MealItems != null)
{
foreach (var item in mealPlan.MealItems)
{
var itemAsString = item.MealItems as string;
if (!string.IsNullOrEmpty(itemAsString))
{
try
{
item.MealItems = System.Text.Json.JsonSerializer.Deserialize<object>(itemAsString);
}
catch
{
// Keep as string if parsing fails
}
}
}
}
return mealPlan;
}
catch
{
throw new Exception("An error occurred while fetching the meal plan details.");
}
}
public async Task<object> GetMealPlanByCustomerId(object payload, HttpContext httpContext)
{
try
{
Guid customerId;
var data = DeserializePayload.Deserialize(payload);
if (data.ContainsKey("customerId") &&
!string.IsNullOrEmpty(data["customerId"].GetString()))
{
customerId = Guid.Parse(data["customerId"].GetString()!);
}
else
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
customerId = await _userRoleHelper.GetCusId(userId);
}
var mealPlans = await _repository.GetMealPlansByCustomerIdAsync(customerId);
foreach (var mealPlan in mealPlans)
{
if (mealPlan.MealItems != null)
{
foreach (var item in mealPlan.MealItems)
{
var itemAsString = item.MealItems as string;
if (!string.IsNullOrEmpty(itemAsString))
{
try
{
item.MealItems = System.Text.Json.JsonSerializer.Deserialize<object>(itemAsString);
}
catch
{
// Keep as string if parsing fails
}
}
}
}
}
return mealPlans;
}
catch
{
throw new Exception("An error occurred while fetching meal plans for the customer.");
}
}
public async Task<object> CreateMealPlan(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var newMealCategoryId = Guid.Parse(data["mealCategoryId"].GetString()!);
if (!await _repository.MealCategoryExistsAsync(newMealCategoryId))
throw new NotFoundException("Meal category not found.");
// Create meal plan
var mealPlan = new MealPlan
{
Name = data["name"].GetString()!,
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
IsTemplate = data.ContainsKey("isTemplate") && data["isTemplate"].GetBoolean(),
FKMealCategoryId = newMealCategoryId,
FKClientId = clientId,
CreateDate = DateTime.UtcNow
};
await _repository.CreateMealPlanAsync(mealPlan);
// Handle customer assignments if provided
if (data.ContainsKey("customerIds") && data["customerIds"].ValueKind == System.Text.Json.JsonValueKind.Array)
{
var customerIdsArray = data["customerIds"].EnumerateArray();
foreach (var customerIdElement in customerIdsArray)
{
if (Guid.TryParse(customerIdElement.GetString(), out var customerId))
{
var customerMeal = new CustomerMeal
{
FKCustomerId = customerId,
FKMealPlanId = mealPlan.MealPlanId,
AssignedDate = DateTime.UtcNow,
IsActive = true
};
await _repository.AssignMealToCustomerAsync(customerMeal);
}
}
}
// Backward compatibility: single customerId field
else if (data.ContainsKey("customerId") && !string.IsNullOrEmpty(data["customerId"].GetString()))
{
var customerId = Guid.Parse(data["customerId"].GetString()!);
var customerMeal = new CustomerMeal
{
FKCustomerId = customerId,
FKMealPlanId = mealPlan.MealPlanId,
AssignedDate = DateTime.UtcNow,
IsActive = true
};
await _repository.AssignMealToCustomerAsync(customerMeal);
}
// Handle meal items if provided
if (data.ContainsKey("mealItems") && data["mealItems"].ValueKind == System.Text.Json.JsonValueKind.Array)
{
var mealItemsArray = data["mealItems"].EnumerateArray();
foreach (var mealItemData in mealItemsArray)
{
// Only create meal if name is provided
if (mealItemData.TryGetProperty("name", out var nameElement) &&
!string.IsNullOrEmpty(nameElement.GetString()))
{
// Parse nested mealItems JSON
string? mealItemsJson = null;
if (mealItemData.TryGetProperty("mealItems", out var mealItemsElement))
{
if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.Array)
{
mealItemsJson = System.Text.Json.JsonSerializer.Serialize(mealItemsElement);
}
else if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.String)
{
mealItemsJson = mealItemsElement.GetString();
}
}
var meal = new Models.Meal
{
Name = nameElement.GetString()!,
Description = mealItemData.TryGetProperty("description", out var desc) ? desc.GetString() : null,
Calories = TryParseDecimal(mealItemData, "calories"),
Protein = TryParseDecimal(mealItemData, "protein"),
Carbohydrates = TryParseDecimal(mealItemData, "carbohydrates"),
Fats = TryParseDecimal(mealItemData, "fats"),
ServingSize = mealItemData.TryGetProperty("servingSize", out var serving) ? serving.GetString() : null,
MealItems = mealItemsJson,
PreparationNotes = mealItemData.TryGetProperty("preparationNotes", out var notes) ? notes.GetString() : null,
FKMealPlanId = mealPlan.MealPlanId,
CreateDate = DateTime.UtcNow
};
await _repository.CreateMealAsync(meal);
}
}
}
await _uow.CommitAsync();
try
{
await _activityLogService.LogActivity(
new { description = $"Meal plan created with ID {mealPlan.MealPlanId}" },
httpContext
);
}
catch (Exception logEx)
{
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
}
// Fetch and return the created meal plan with parsed JSON
var createdMealPlan = await _repository.GetMealPlanByIdAsync(mealPlan.MealPlanId);
if (createdMealPlan == null)
throw new KeyNotFoundException($"Meal plan with ID {mealPlan.MealPlanId} not found after creation");
// Parse MealItems JSON strings to objects in nested meal items
if (createdMealPlan.MealItems != null)
{
foreach (var item in createdMealPlan.MealItems)
{
var itemAsString = item.MealItems as string;
if (!string.IsNullOrEmpty(itemAsString))
{
try
{
item.MealItems = System.Text.Json.JsonSerializer.Deserialize<object>(itemAsString);
}
catch
{
// Keep as string if parsing fails
}
}
}
}
return createdMealPlan;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
// Plans created via the AI chat flow aren't given a category by the customer — the
// mobile app used to guess one by fetching the category list and taking the first
// entry, which was both an extra round-trip and an arbitrary pick. Categories are a
// global taxonomy (MealCategory has no FKClientId), so a single well-known "AI
// Generated" category is get-or-created here and reused for every gym's AI plans.
private const string AiGeneratedCategoryName = "AI Generated";
private async Task<Guid> GetOrCreateAiMealCategoryIdAsync()
{
var categories = await _repository.GetMealCategoryListAsync();
var existing = categories.FirstOrDefault(c =>
string.Equals(c.Name, AiGeneratedCategoryName, StringComparison.OrdinalIgnoreCase));
if (existing != null)
return existing.MealCategoryId;
var created = await _repository.CreateMealCategoryAsync(new MealCategory
{
MealCategoryId = Guid.NewGuid(),
Name = AiGeneratedCategoryName,
Description = "Plans generated by the AI meal planner",
IsActive = true,
CreateDate = DateTime.UtcNow
});
return created.MealCategoryId;
}
public async Task<object> CreateCustomerMealPlan(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid customerId;
if (data.ContainsKey("customerId") &&
!string.IsNullOrEmpty(data["customerId"].GetString()))
{
customerId = Guid.Parse(data["customerId"].GetString()!);
}
else
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
customerId = await _userRoleHelper.GetCusId(userId);
}
// The plan belongs to the customer's own gym, not a fixed placeholder client.
Guid clientId = await _userRoleHelper.GetClientIdByCusId(customerId);
Guid mealCategoryId = data.ContainsKey("mealCategoryId") && !string.IsNullOrEmpty(data["mealCategoryId"].GetString())
? Guid.Parse(data["mealCategoryId"].GetString()!)
: await GetOrCreateAiMealCategoryIdAsync();
var mealPlan = new MealPlan
{
Name = data["name"].GetString()!,
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
IsTemplate = data.ContainsKey("isTemplate") && data["isTemplate"].GetBoolean(),
FKMealCategoryId = mealCategoryId,
FKClientId = clientId,
CreateDate = DateTime.UtcNow
};
await _repository.CreateMealPlanAsync(mealPlan);
if (data.ContainsKey("mealItems") && data["mealItems"].ValueKind == System.Text.Json.JsonValueKind.Array)
{
var mealItemsArray = data["mealItems"].EnumerateArray();
foreach (var mealItemData in mealItemsArray)
{
if (mealItemData.TryGetProperty("name", out var nameElement) &&
!string.IsNullOrEmpty(nameElement.GetString()))
{
string? mealItemsJson = null;
if (mealItemData.TryGetProperty("mealItems", out var mealItemsElement))
{
if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.Array)
{
mealItemsJson = System.Text.Json.JsonSerializer.Serialize(mealItemsElement);
}
else if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.String)
{
mealItemsJson = mealItemsElement.GetString();
}
}
var meal = new Models.Meal
{
Name = nameElement.GetString()!,
Description = mealItemData.TryGetProperty("description", out var desc) ? desc.GetString() : null,
Calories = TryParseDecimal(mealItemData, "calories"),
Protein = TryParseDecimal(mealItemData, "protein"),
Carbohydrates = TryParseDecimal(mealItemData, "carbohydrates"),
Fats = TryParseDecimal(mealItemData, "fats"),
ServingSize = mealItemData.TryGetProperty("servingSize", out var serving) ? serving.GetString() : null,
MealItems = mealItemsJson,
PreparationNotes = mealItemData.TryGetProperty("preparationNotes", out var notes) ? notes.GetString() : null,
FKMealPlanId = mealPlan.MealPlanId,
CreateDate = DateTime.UtcNow
};
await _repository.CreateMealAsync(meal);
}
}
}
var customerMeal = new CustomerMeal
{
FKCustomerId = customerId,
FKMealPlanId = mealPlan.MealPlanId,
AssignedDate = DateTime.UtcNow,
IsActive = true,
IsFollowing = true
};
await _repository.AssignMealToCustomerAsync(customerMeal);
await _uow.CommitAsync();
try
{
await _activityLogService.LogActivity(
new { description = $"Customer meal plan created with ID {mealPlan.MealPlanId}" },
httpContext
);
}
catch (Exception logEx)
{
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
}
var createdMealPlan = await _repository.GetMealPlanByIdAsync(mealPlan.MealPlanId);
if (createdMealPlan == null)
throw new KeyNotFoundException($"Meal plan with ID {mealPlan.MealPlanId} not found after creation");
if (createdMealPlan.MealItems != null)
{
foreach (var item in createdMealPlan.MealItems)
{
var itemAsString = item.MealItems as string;
if (!string.IsNullOrEmpty(itemAsString))
{
try
{
item.MealItems = System.Text.Json.JsonSerializer.Deserialize<object>(itemAsString);
}
catch
{
// Keep as string if parsing fails
}
}
}
}
return createdMealPlan;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
// Meal Item operations
public async Task<object> GetMealItemsByMealPlanId(object payload, HttpContext httpContext)
{
try
{
var data = DeserializePayload.Deserialize(payload);
Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!);
var mealItems = await _repository.GetMealsByMealPlanIdAsync(mealPlanId);
// Parse MealItems JSON strings to objects
foreach (var item in mealItems)
{
var itemAsString = item.MealItems as string;
if (!string.IsNullOrEmpty(itemAsString))
{
try
{
item.MealItems = System.Text.Json.JsonSerializer.Deserialize<object>(itemAsString);
}
catch
{
// Keep as string if parsing fails
}
}
}
return mealItems;
}
catch
{
throw;
}
}
public async Task<object> CreateMealItem(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
// Parse mealItems array and serialize to JSON string
string? mealItemsJson = null;
if (data.ContainsKey("mealItems"))
{
var mealItemsElement = data["mealItems"];
if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.Array)
{
mealItemsJson = System.Text.Json.JsonSerializer.Serialize(mealItemsElement);
}
else if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.String)
{
mealItemsJson = mealItemsElement.GetString();
}
}
var mealItem = new Models.Meal
{
Name = data["name"].GetString()!,
Description = data.ContainsKey("description") ? data["description"].GetString() : null,
Calories = data.ContainsKey("calories") && !string.IsNullOrEmpty(data["calories"].GetString())
? decimal.Parse(data["calories"].GetString()!)
: (decimal?)null,
Protein = data.ContainsKey("protein") && !string.IsNullOrEmpty(data["protein"].GetString())
? decimal.Parse(data["protein"].GetString()!)
: (decimal?)null,
Carbohydrates = data.ContainsKey("carbohydrates") && !string.IsNullOrEmpty(data["carbohydrates"].GetString())
? decimal.Parse(data["carbohydrates"].GetString()!)
: (decimal?)null,
Fats = data.ContainsKey("fats") && !string.IsNullOrEmpty(data["fats"].GetString())
? decimal.Parse(data["fats"].GetString()!)
: (decimal?)null,
ServingSize = data.ContainsKey("servingSize") ? data["servingSize"].GetString() : null,
MealItems = mealItemsJson,
PreparationNotes = data.ContainsKey("preparationNotes") ? data["preparationNotes"].GetString() : null,
FKMealPlanId = Guid.Parse(data["mealPlanId"].GetString()!),
CreateDate = DateTime.UtcNow
};
await _repository.CreateMealAsync(mealItem);
await _uow.CommitAsync();
// Log activity - non-blocking, failures won't crash the operation
try
{
await _activityLogService.LogActivity(new { description = $"Meal item created with ID {mealItem.MealId}" }, httpContext);
}
catch (Exception logEx)
{
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
}
// Parse mealItems JSON string to object before returning
object? parsedMealItems = mealItem.MealItems;
if (!string.IsNullOrEmpty(mealItem.MealItems))
{
try
{
parsedMealItems = System.Text.Json.JsonSerializer.Deserialize<object>(mealItem.MealItems);
}
catch
{
// Keep as string if parsing fails
}
}
return new
{
mealItem.MealId,
mealItem.Name,
mealItem.Description,
mealItem.Calories,
mealItem.Protein,
mealItem.Carbohydrates,
mealItem.Fats,
mealItem.ServingSize,
MealItems = parsedMealItems,
mealItem.PreparationNotes,
FKMealPlanId = mealItem.FKMealPlanId,
mealItem.IsActive,
mealItem.CreateDate
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
// Update Meal Plan
public async Task<object> UpdateMealPlan(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!);
var existingMealPlan = await _repository.GetMealPlanEntityByIdAsync(mealPlanId);
if (existingMealPlan == null)
throw new KeyNotFoundException($"Meal plan with ID {mealPlanId} not found");
// Update meal plan properties
if (data.ContainsKey("name") && !string.IsNullOrEmpty(data["name"].GetString()))
existingMealPlan.Name = data["name"].GetString()!;
if (data.ContainsKey("description"))
existingMealPlan.Description = data["description"].GetString();
if (data.ContainsKey("isTemplate"))
existingMealPlan.IsTemplate = data["isTemplate"].GetBoolean();
if (data.ContainsKey("mealCategoryId"))
existingMealPlan.FKMealCategoryId = Guid.Parse(data["mealCategoryId"].GetString()!);
await _repository.UpdateMealPlanAsync(existingMealPlan);
// Handle meal items update if provided
if (data.ContainsKey("mealItems") && data["mealItems"].ValueKind == System.Text.Json.JsonValueKind.Array)
{
// Get existing meal items for this plan
var existingMeals = await _repository.GetMealsByMealPlanIdAsync(mealPlanId);
// Delete all existing meals (clean slate approach)
foreach (var existingMeal in existingMeals)
{
await _repository.DeleteMealAsync(existingMeal.MealId);
}
// Create new meals from payload
var mealItemsArray = data["mealItems"].EnumerateArray();
foreach (var mealItemData in mealItemsArray)
{
// Only create meal if name is provided
if (mealItemData.TryGetProperty("name", out var nameElement) &&
!string.IsNullOrEmpty(nameElement.GetString()))
{
string? mealItemsJson = null;
if (mealItemData.TryGetProperty("mealItems", out var mealItemsElement))
{
if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.Array)
{
mealItemsJson = System.Text.Json.JsonSerializer.Serialize(mealItemsElement);
}
else if (mealItemsElement.ValueKind == System.Text.Json.JsonValueKind.String)
{
mealItemsJson = mealItemsElement.GetString();
}
}
var meal = new Models.Meal
{
Name = nameElement.GetString()!,
Description = mealItemData.TryGetProperty("description", out var desc) ? desc.GetString() : null,
Calories = TryParseDecimal(mealItemData, "calories"),
Protein = TryParseDecimal(mealItemData, "protein"),
Carbohydrates = TryParseDecimal(mealItemData, "carbohydrates"),
Fats = TryParseDecimal(mealItemData, "fats"),
ServingSize = mealItemData.TryGetProperty("servingSize", out var serving) ? serving.GetString() : null,
MealItems = mealItemsJson,
PreparationNotes = mealItemData.TryGetProperty("preparationNotes", out var notes) ? notes.GetString() : null,
FKMealPlanId = mealPlanId,
CreateDate = DateTime.UtcNow
};
await _repository.CreateMealAsync(meal);
}
}
}
await _uow.CommitAsync();
try
{
await _activityLogService.LogActivity(
new { description = $"Meal plan updated with ID {mealPlanId}" },
httpContext
);
}
catch (Exception logEx)
{
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
}
var updatedMealPlan = await _repository.GetMealPlanByIdAsync(mealPlanId);
if (updatedMealPlan == null)
throw new KeyNotFoundException($"Meal plan with ID {mealPlanId} not found after update");
// Parse MealItems JSON strings to objects in nested meal items
if (updatedMealPlan.MealItems != null)
{
foreach (var item in updatedMealPlan.MealItems)
{
var itemAsString = item.MealItems as string;
if (!string.IsNullOrEmpty(itemAsString))
{
try
{
item.MealItems = System.Text.Json.JsonSerializer.Deserialize<object>(itemAsString);
}
catch
{
// Keep as string if parsing fails
}
}
}
}
return updatedMealPlan;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
// Delete Meal Plan
public async Task<object> DeleteMealPlan(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!);
var existingMealPlan = await _repository.GetMealPlanEntityByIdAsync(mealPlanId);
if (existingMealPlan == null)
throw new KeyNotFoundException($"Meal plan with ID {mealPlanId} not found");
// Delete all associated meal items first (foreign key constraint)
var existingMeals = await _repository.GetMealsByMealPlanIdAsync(mealPlanId);
foreach (var meal in existingMeals)
{
await _repository.DeleteMealAsync(meal.MealId);
}
// Delete the meal plan
await _repository.DeleteMealPlanAsync(mealPlanId);
await _uow.CommitAsync();
try
{
await _activityLogService.LogActivity(
new { description = $"Meal plan deleted with ID {mealPlanId}" },
httpContext
);
}
catch (Exception logEx)
{
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
}
return new
{
Success = true,
Message = $"Meal plan with ID {mealPlanId} has been deleted successfully"
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
// Helper method
private decimal? TryParseDecimal(System.Text.Json.JsonElement element, string propertyName)
{
if (element.TryGetProperty(propertyName, out var prop))
{
if (prop.ValueKind == System.Text.Json.JsonValueKind.Number)
{
return prop.GetDecimal();
}
else if (prop.ValueKind == System.Text.Json.JsonValueKind.String)
{
var strValue = prop.GetString();
if (!string.IsNullOrEmpty(strValue) && decimal.TryParse(strValue, out var result))
{
return result;
}
}
}
return null;
}
// Helper for JsonElement
private decimal? TryParseDecimal(Dictionary<string, System.Text.Json.JsonElement> data, string key)
{
if (!data.ContainsKey(key))
return null;
var element = data[key];
if (element.ValueKind == System.Text.Json.JsonValueKind.Number)
return element.GetDecimal();
if (element.ValueKind == System.Text.Json.JsonValueKind.String)
{
var strValue = element.GetString();
if (!string.IsNullOrEmpty(strValue) && decimal.TryParse(strValue, out var result))
return result;
}
return null;
}
// Customer Meal Assignment operations
public async Task<object> AssignMealToCustomer(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!);
Guid customerId = Guid.Parse(data["customerId"].GetString()!);
var customerMeal = new CustomerMeal
{
FKCustomerId = customerId,
FKMealPlanId = mealPlanId,
AssignedDate = DateTime.UtcNow,
Notes = data.ContainsKey("notes") ? data["notes"].GetString() : null,
IsActive = true
};
await _repository.AssignMealToCustomerAsync(customerMeal);
await _uow.CommitAsync();
try
{
await _activityLogService.LogActivity(
new { description = $"Meal plan {mealPlanId} assigned to customer {customerId}" },
httpContext
);
}
catch (Exception logEx)
{
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
}
try
{
var plan = await _repository.GetMealPlanEntityByIdAsync(mealPlanId);
var planName = plan?.Name ?? "a meal plan";
await _pushNotificationService.NotifyCustomerAsync(
customerId,
"New meal plan assigned",
$"Your coach assigned you \"{planName}\". Tap to view.",
"MealPlanAssigned",
"MealPlanSuggestionDetail",
new { mealPlanId }
);
}
catch (Exception notifyEx)
{
Console.WriteLine($"Push notification failed (non-critical): {notifyEx.Message}");
}
return new
{
customerMeal.CustomerMealId,
customerMeal.FKCustomerId,
customerMeal.FKMealPlanId,
customerMeal.AssignedDate,
customerMeal.Notes,
customerMeal.IsActive
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> UnassignMealFromCustomer(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid customerMealId = Guid.Parse(data["customerMealId"].GetString()!);
var result = await _repository.UnassignMealFromCustomerAsync(customerMealId);
if (!result)
{
throw new KeyNotFoundException($"Customer meal assignment with ID {customerMealId} not found");
}
await _uow.CommitAsync();
try
{
await _activityLogService.LogActivity(
new { description = $"Meal assignment {customerMealId} removed" },
httpContext
);
}
catch (Exception logEx)
{
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
}
return new { success = true, message = "Meal unassigned from customer successfully" };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
}
}
+49
View File
@@ -0,0 +1,49 @@
using Fitness_core_BE.DTOs.Rbac;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services
{
public class NavigationService : INavigationService
{
private readonly IPermissionService _permissionService;
private readonly INavItemRepository _navItemRepository;
public NavigationService(
IPermissionService permissionService,
INavItemRepository navItemRepository)
{
_permissionService = permissionService;
_navItemRepository = navItemRepository;
}
public async Task<NavResponseDto> GetNavigationAsync(HttpContext httpContext)
{
var permissionContext =
await _permissionService.GetPermissionContextAsync(httpContext);
var allItems = await _navItemRepository.GetActiveNavItemsOrdered();
var visibleItems = allItems
.Where(item =>
item.Key == "dashboard" ||
permissionContext.IsOwner ||
permissionContext.AllowedNavKeys.Contains(item.Key))
.Where(item => !item.IsOwnerOnly || permissionContext.IsOwner)
.Select(item => new NavItemDto
{
Key = item.Key,
Label = item.Label,
ShortLabel = item.ShortLabel,
Href = item.Href,
Icon = item.Icon
})
.ToList();
return new NavResponseDto
{
IsOwner = permissionContext.IsOwner,
Items = visibleItems
};
}
}
}
+86
View File
@@ -0,0 +1,86 @@
using Fitness_core_BE.Infra.Tenancy;
using Fitness_core_BE.Interfaces;
namespace Fitness_core_BE.Services
{
/// <summary>
/// Periodically locks (and un-locks) member fingerprint access based on each client's
/// configurable Client.LockAfterDaysOverdue setting (req 7). Reuses the existing
/// AccessDeviceService.LockCustomer/UnlockCustomer (BlackListMember/WhiteListMember)
/// pipeline — this job only decides *who* should be locked, not *how*.
///
/// This is a background service with no HttpContext, so TenantConnectionResolver's normal
/// per-request tenant resolution can never apply here -- a DI-scoped TenantDbContext always
/// resolves to the same single fallback schema regardless of which client is being checked.
/// Every client's overdue-check therefore opens its OWN schema directly via
/// TenantSchemaIterator instead of using the scope's injected TenantDbContext.
///
/// The per-client algorithm itself lives in OverdueLockRunner so
/// AdminBackgroundJobsController's manual "trigger now" endpoint can call the exact same
/// code path instead of duplicating it -- this class only owns the 6-hour loop and the
/// central-only client lookup.
/// </summary>
public class OverdueLockJob : BackgroundService
{
private static readonly TimeSpan Interval = TimeSpan.FromHours(6);
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<OverdueLockJob> _logger;
public OverdueLockJob(IServiceScopeFactory scopeFactory, ILogger<OverdueLockJob> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
await RunOnceAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "OverdueLockJob run failed.");
}
try
{
await Task.Delay(Interval, stoppingToken);
}
catch (TaskCanceledException)
{
// Shutting down.
}
}
}
private async Task RunOnceAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var iterator = scope.ServiceProvider.GetRequiredService<TenantSchemaIterator>();
var runner = scope.ServiceProvider.GetRequiredService<OverdueLockRunner>();
var now = DateTime.UtcNow;
// Central-only lookup -- safe to use the scope's normal DI-resolved repository,
// since GetClientsWithOverdueLockPolicyAsync only ever touches CentralDbContext.
var bootstrapRepo = scope.ServiceProvider.GetRequiredService<IOverdueLockRepository>();
var clients = await bootstrapRepo.GetClientsWithOverdueLockPolicyAsync(ct);
foreach (var client in clients)
{
try
{
await runner.RunForClientAsync(client, now, iterator, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "OverdueLockJob failed for client {ClientId}.", client.ClientId);
}
}
}
}
}
+69
View File
@@ -0,0 +1,69 @@
using Fitness_core_BE.Infra.Tenancy;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Models;
using Fitness_core_BE.Repos;
namespace Fitness_core_BE.Services
{
// The per-client overdue-lock algorithm, extracted verbatim from OverdueLockJob so the
// admin "trigger now" endpoint (AdminBackgroundJobsController) can call the exact same code
// path instead of duplicating it. Pure extraction -- no change to the locking logic itself.
// Mirrors the SubscriptionRenewalRunner/SubscriptionRenewalJob split.
public class OverdueLockRunner(ILogger<OverdueLockRunner> logger)
{
private readonly ILogger<OverdueLockRunner> _logger = logger;
public async Task RunForClientAsync(Client client, DateTime now, TenantSchemaIterator iterator, CancellationToken ct)
{
await using var schemaCtx = await iterator.OpenSchemaForClientAsync(client.ClientId, ct);
if (schemaCtx == null)
{
_logger.LogWarning(
"OverdueLockJob: client {ClientId} has a lock policy but no provisioned tenant schema; skipping.",
client.ClientId);
return;
}
var repository = new OverdueLockRepository(schemaCtx.Tenant, schemaCtx.Central);
var accessDeviceService = new AccessDeviceService(
new EFUnitOfWork(schemaCtx.Central, schemaCtx.Tenant),
new AccessDeviceRepo(schemaCtx.Tenant, schemaCtx.Central),
new AttendanceRepository(schemaCtx.Tenant),
new DeviceCommandRepo(schemaCtx.Tenant),
new PairingCodeRepo(schemaCtx.Central));
var effectiveDays = client.LockAfterDaysOverdue ?? Client.DefaultLockAfterDaysOverdue;
var thresholdDate = now.AddDays(-effectiveDays);
var overdueCustomerIds = await repository.GetOverdueCustomerIdsAsync(
client.ClientId, thresholdDate, ct);
foreach (var customerId in overdueCustomerIds)
{
var customer = await repository.GetCustomerByIdAsync(customerId, ct);
if (customer == null)
continue;
var alreadyLocked = await repository.IsCustomerAccessDisabledAsync(
client.ClientId, customer.FKUserId, ct);
if (alreadyLocked)
continue;
await accessDeviceService.SetCustomerAccessAsync(
client.ClientId, customerId, enabled: false, reason: "Overdue");
_logger.LogInformation("Auto-locked customer {CustomerId} for client {ClientId} (overdue).", customerId, client.ClientId);
}
// Auto-unlock members who were locked for being overdue but are no longer
// (paid, or their overdue invoice fell out of scope) — never touches manual locks.
var lockedOverdueCustomerIds =
await repository.GetCustomersLockedForOverdueAsync(client.ClientId, ct);
foreach (var customerId in lockedOverdueCustomerIds.Except(overdueCustomerIds))
{
await accessDeviceService.SetCustomerAccessAsync(
client.ClientId, customerId, enabled: true, reason: null);
_logger.LogInformation("Auto-unlocked customer {CustomerId} for client {ClientId} (no longer overdue).", customerId, client.ClientId);
}
}
}
}
+178
View File
@@ -0,0 +1,178 @@
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using Fitness_core_BE.DTOs.PaymentGateways;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services
{
public class PaymentGatewayClient : IPaymentGatewayClient
{
private readonly IClientRepository _clientRepository;
private readonly IPaymentServiceClient _paymentServiceClient;
private readonly IUnitOfWork _uow;
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
public PaymentGatewayClient(
IClientRepository clientRepository,
IPaymentServiceClient paymentServiceClient,
IUnitOfWork uow,
IHttpClientFactory httpClientFactory,
IConfiguration configuration)
{
_clientRepository = clientRepository;
_paymentServiceClient = paymentServiceClient;
_uow = uow;
_httpClient = httpClientFactory.CreateClient();
_baseUrl = (configuration["PaymentService:BaseUrl"] ?? "http://127.0.0.1:5120").TrimEnd('/');
}
public async Task<OnboardCardResult> OnboardCardAsync(Guid clientId, OnboardCardRequest request, CancellationToken ct = default)
{
var body = await SendAsync(clientId, HttpMethod.Post, "/api/v1/cards/onboard", request, ct);
return Deserialize<OnboardCardResult>(body);
}
public async Task<CardDetailsResult> GetCardDetailsAsync(Guid clientId, string cardToken, CancellationToken ct = default)
{
var body = await SendAsync(clientId, HttpMethod.Get, $"/api/v1/cards/{Uri.EscapeDataString(cardToken)}", null, ct);
return Deserialize<CardDetailsResult>(body);
}
public async Task<IReadOnlyCollection<CardDetailsResult>> ListCardsAsync(Guid clientId, string customerId, CancellationToken ct = default)
{
var body = await SendAsync(clientId, HttpMethod.Get, "/api/v1/cards", null, ct,
query: $"customerId={Uri.EscapeDataString(customerId)}");
return Deserialize<List<CardDetailsResult>>(body);
}
public async Task DeactivateCardAsync(Guid clientId, string cardToken, CancellationToken ct = default)
{
await SendAsync(clientId, HttpMethod.Delete, $"/api/v1/cards/{Uri.EscapeDataString(cardToken)}", null, ct);
}
public async Task<SetDefaultCardResult> SetDefaultCardAsync(Guid clientId, string cardToken, string customerId, CancellationToken ct = default)
{
var body = await SendAsync(clientId, HttpMethod.Patch, $"/api/v1/cards/{Uri.EscapeDataString(cardToken)}/default",
new { customerId }, ct);
return Deserialize<SetDefaultCardResult>(body);
}
public async Task<OnboardCardResult> GetOnboardingStatusAsync(Guid clientId, string gatewayName, string sessionId, CancellationToken ct = default)
{
var body = await SendAsync(clientId, HttpMethod.Get,
$"/api/v1/cards/onboarding/{Uri.EscapeDataString(gatewayName)}/{Uri.EscapeDataString(sessionId)}", null, ct);
return Deserialize<OnboardCardResult>(body);
}
public async Task<AttemptPaymentResult> AttemptPaymentAsync(Guid clientId, AttemptPaymentRequest request, CancellationToken ct = default)
{
var body = await SendAsync(clientId, HttpMethod.Post, "/api/v1/payments/attempt", request, ct);
return Deserialize<AttemptPaymentResult>(body);
}
public async Task<RefundPaymentResult> RefundPaymentAsync(Guid clientId, Guid paymentId, bool isVoid, CancellationToken ct = default)
{
var body = await SendAsync(clientId, HttpMethod.Post, $"/api/v1/refunds/{paymentId}", new { isVoid }, ct);
return Deserialize<RefundPaymentResult>(body);
}
public async Task<PaymentListResult> ListPaymentsAsync(Guid clientId, string? cardToken, int page, int pageSize, CancellationToken ct = default)
{
var queryParts = new List<string> { $"page={page}", $"pageSize={pageSize}" };
if (!string.IsNullOrWhiteSpace(cardToken))
{
queryParts.Add($"cardToken={Uri.EscapeDataString(cardToken)}");
}
var body = await SendAsync(clientId, HttpMethod.Get, "/api/v1/payments", null, ct, query: string.Join("&", queryParts));
return Deserialize<PaymentListResult>(body);
}
private static T Deserialize<T>(string body)
{
return JsonSerializer.Deserialize<T>(body, JsonOptions)
?? throw new ServiceUnavailableException("Payment-Service returned an empty response.");
}
/// <summary>
/// Mints this client's Payment-Service Platform the first time it's needed, storing the
/// ApiKey/HmacSecret on Client since Payment-Service only ever returns them once.
/// </summary>
private async Task<(string ApiKey, string HmacSecret)> EnsureCredentialsAsync(Guid clientId, CancellationToken ct)
{
var client = await _clientRepository.GetClientEntityByIdAsync(clientId, ct)
?? throw new NotFoundException($"Client {clientId} not found.");
if (string.IsNullOrEmpty(client.ClientPaymentApiKey) || string.IsNullOrEmpty(client.ClientPaymentHmacSecret))
{
var (platformId, apiKey, hmacSecret) = await _paymentServiceClient.ProvisionPlatformAsync(
clientId, client.ClientName ?? "Gym", ct);
client.FKPaymentPlatformId = platformId;
client.ClientPaymentApiKey = apiKey;
client.ClientPaymentHmacSecret = hmacSecret;
await _uow.SaveChangesAsync(ct);
}
return (client.ClientPaymentApiKey!, client.ClientPaymentHmacSecret!);
}
private async Task<string> SendAsync(Guid clientId, HttpMethod method, string path, object? body, CancellationToken ct, string? query = null)
{
var (apiKey, hmacSecret) = await EnsureCredentialsAsync(clientId, ct);
var bodyJson = body is null ? string.Empty : JsonSerializer.Serialize(body);
var timestamp = DateTimeOffset.UtcNow.ToString("o");
// Signed over the path only — Payment-Service's HmacValidationMiddleware signs
// against Request.Path.Value, which never includes the query string.
var signature = ComputeSignature(hmacSecret, method.Method, path, timestamp, bodyJson);
var url = string.IsNullOrEmpty(query) ? $"{_baseUrl}{path}" : $"{_baseUrl}{path}?{query}";
using var httpRequest = new HttpRequestMessage(method, url);
httpRequest.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
httpRequest.Headers.Add("X-Api-Key", apiKey);
httpRequest.Headers.Add("X-Timestamp", timestamp);
httpRequest.Headers.Add("X-Signature", signature);
if (body is not null)
{
httpRequest.Content = new StringContent(bodyJson, Encoding.UTF8, "application/json");
}
HttpResponseMessage response;
try
{
response = await _httpClient.SendAsync(httpRequest, ct);
}
catch (HttpRequestException ex)
{
throw new ServiceUnavailableException("Could not reach Payment-Service.", ex);
}
var responseBody = await response.Content.ReadAsStringAsync(ct);
if (!response.IsSuccessStatusCode)
{
throw new ServiceUnavailableException($"Payment-Service call failed ({(int)response.StatusCode}): {responseBody}");
}
return responseBody;
}
// Must match Payment-Service's Middleware/HmacValidationMiddleware.ComputeSignature
// exactly: METHOD\nPATH\nTIMESTAMP\nBODY, HMACSHA256, hex lowercase.
private static string ComputeSignature(string secret, string method, string path, string timestamp, string body)
{
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var payload = $"{method}\n{path}\n{timestamp}\n{body}";
var hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(payload));
return Convert.ToHexString(hash).ToLowerInvariant();
}
}
}
+691
View File
@@ -0,0 +1,691 @@
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Fitness_core_BE.Models.Enums;
namespace Fitness_core_BE.Services.Payment
{
public class PaymentService : IPaymentService
{
private readonly IPaymentRepository _paymentRepository;
private readonly ICustomerInvoiceRepository _customerInvoiceRepository;
private readonly IClientInvoiceRepository _clientInvoiceRepository;
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository;
private readonly ICustomerRepository _customerRepository;
private readonly IReceiptService _receiptService;
private readonly IThirdPartyService _thirdPartyService;
private readonly IAccessDeviceService _accessDeviceService;
private readonly InvoicePaymentTokenHelper _invoicePaymentTokenHelper;
private readonly IConfiguration _config;
private readonly UserRoleHelper _userRoleHelper;
private readonly IUnitOfWork _uow;
public PaymentService(
IPaymentRepository paymentRepository,
ICustomerInvoiceRepository customerInvoiceRepository,
IClientInvoiceRepository clientInvoiceRepository,
ICustomerSubscriptionRepository customerSubscriptionRepository,
ICustomerRepository customerRepository,
IReceiptService receiptService,
IThirdPartyService thirdPartyService,
IAccessDeviceService accessDeviceService,
InvoicePaymentTokenHelper invoicePaymentTokenHelper,
IConfiguration config,
UserRoleHelper userRoleHelper,
IUnitOfWork uow)
{
_paymentRepository = paymentRepository;
_customerInvoiceRepository = customerInvoiceRepository;
_clientInvoiceRepository = clientInvoiceRepository;
_customerSubscriptionRepository = customerSubscriptionRepository;
_customerRepository = customerRepository;
_receiptService = receiptService;
_thirdPartyService = thirdPartyService;
_accessDeviceService = accessDeviceService;
_invoicePaymentTokenHelper = invoicePaymentTokenHelper;
_config = config;
_userRoleHelper = userRoleHelper;
_uow = uow;
}
public async Task<object> CreateCustomerPayment(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid customerInvoiceId = DataExtractor.GetRequiredGuid(data, "customerInvoiceId");
decimal payAmount = DataExtractor.GetRequiredDecimal(data, "payAmount");
var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId)
?? throw new NotFoundException($"Customer invoice {customerInvoiceId} not found");
Guid? cardId = null;
var cardIdStr = DataExtractor.GetOptionalString(data, "cardId");
if (!string.IsNullOrEmpty(cardIdStr) && Guid.TryParse(cardIdStr, out var parsedCardId))
cardId = parsedCardId;
var attempt = new PaymentTransaction
{
PayDate = DateTime.UtcNow,
PayStatus = "Pending",
PayAmount = payAmount,
PayNote = DataExtractor.GetOptionalString(data, "payNote"),
FK_CardId = cardId,
FK_CustomerInvoiceId = customerInvoiceId,
FK_ClientId = invoice.FKClientId,
FK_CustomerId = invoice.CustomerSubscription?.FKCustomerId,
BeneficiaryType = BeneficiaryType.Customer,
RefType = DataExtractor.GetOptionalString(data, "refType") ?? "invoice",
BankRefNo = DataExtractor.GetOptionalString(data, "bankRefNo"),
PaymentReferenceId = DataExtractor.GetOptionalString(data, "paymentReferenceId"),
IsActive = true,
IsApproved = false
};
var result = await _paymentRepository.CreatePaymentAttemptAsync(attempt);
await _uow.CommitAsync();
return new { success = true, message = "Payment attempt created", payment = result };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> CreateClientPayment(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid clientInvoiceId = DataExtractor.GetRequiredGuid(data, "clientInvoiceId");
decimal payAmount = DataExtractor.GetRequiredDecimal(data, "payAmount");
var invoice = await _clientInvoiceRepository.GetClientInvoiceById(clientInvoiceId)
?? throw new NotFoundException($"Client invoice {clientInvoiceId} not found");
Guid? cardId = null;
var cardIdStr = DataExtractor.GetOptionalString(data, "cardId");
if (!string.IsNullOrEmpty(cardIdStr) && Guid.TryParse(cardIdStr, out var parsedCardId))
cardId = parsedCardId;
var attempt = new PaymentTransaction
{
PayDate = DateTime.UtcNow,
PayStatus = "Pending",
PayAmount = payAmount,
PayNote = DataExtractor.GetOptionalString(data, "payNote"),
FK_CardId = cardId,
FK_ClientInvoiceId = clientInvoiceId,
FK_ClientId = invoice.ClientSubscription?.FKClientId,
BeneficiaryType = BeneficiaryType.Client,
RefType = DataExtractor.GetOptionalString(data, "refType") ?? "invoice",
BankRefNo = DataExtractor.GetOptionalString(data, "bankRefNo"),
PaymentReferenceId = DataExtractor.GetOptionalString(data, "paymentReferenceId"),
IsActive = true,
IsApproved = false
};
var result = await _paymentRepository.CreatePaymentAttemptAsync(attempt);
await _uow.CommitAsync();
return new { success = true, message = "Payment attempt created", payment = result };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> GetPaymentById(object payload, HttpContext httpContext)
{
try
{
var data = DeserializePayload.Deserialize(payload);
Guid attemptId = DataExtractor.GetRequiredGuid(data, "attemptId");
var payment = await _paymentRepository.GetPaymentByIdAsync(attemptId)
?? throw new NotFoundException($"Payment {attemptId} not found");
return payment;
}
catch
{
throw;
}
}
public async Task<object> GetPaymentsByCustomerInvoice(object payload, HttpContext httpContext)
{
try
{
var data = DeserializePayload.Deserialize(payload);
Guid customerInvoiceId = DataExtractor.GetRequiredGuid(data, "customerInvoiceId");
var payments = await _paymentRepository.GetPaymentsByCustomerInvoiceIdAsync(customerInvoiceId);
return payments;
}
catch
{
throw;
}
}
public async Task<object> GetPaymentsByClientInvoice(object payload, HttpContext httpContext)
{
try
{
var data = DeserializePayload.Deserialize(payload);
Guid clientInvoiceId = DataExtractor.GetRequiredGuid(data, "clientInvoiceId");
var payments = await _paymentRepository.GetPaymentsByClientInvoiceIdAsync(clientInvoiceId);
return payments;
}
catch
{
throw;
}
}
public async Task<object> ApprovePayment(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid attemptId = DataExtractor.GetRequiredGuid(data, "attemptId");
var payment = await _paymentRepository.GetPaymentByIdAsync(attemptId)
?? throw new NotFoundException($"Payment {attemptId} not found");
payment.IsApproved = true;
payment.PayStatus = "Approved";
await _paymentRepository.UpdatePaymentAttemptAsync(payment);
if (payment.FK_CustomerInvoiceId.HasValue)
{
// Roll the invoice status up from the approved total (Pending/Partial/Paid)
// and re-allocate the schedule, rather than blindly marking "Paid".
await _uow.SaveChangesAsync();
await RollupCustomerInvoiceAsync(payment.FK_CustomerInvoiceId.Value);
}
else if (payment.FK_ClientInvoiceId.HasValue)
{
await _clientInvoiceRepository.UpdateClientInvoiceStatus(payment.FK_ClientInvoiceId.Value, "Paid");
}
await _uow.CommitAsync();
if (payment.FK_ClientInvoiceId.HasValue)
{
// Post-commit, best-effort: email/SMS the gym owner a receipt PDF for their
// platform-fee payment. Never let a notification failure affect the approval.
try
{
var receipt = await _receiptService.BuildForClientInvoiceAsync(payment.FK_ClientInvoiceId.Value, payment.PayAmount);
await _receiptService.SendReceiptAsync(receipt);
}
catch (Exception ex)
{
Console.WriteLine($"Client invoice receipt notification failed (non-critical): {ex.Message}");
}
}
return new { success = true, message = "Payment approved", payment };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
/// <summary>
/// Owner records a counter payment against a subscription. Immediately approved.
/// Fills the earliest unpaid installments; non-split plans must be settled in full.
/// </summary>
public async Task<object> RecordSubscriptionPayment(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid subscriptionId = DataExtractor.GetRequiredGuid(data, "subscriptionId");
decimal amount = DataExtractor.GetRequiredDecimal(data, "amount");
string? note = DataExtractor.GetOptionalString(data, "note");
string paymentMethod = DataExtractor.GetRequiredString(data, "paymentMethod");
string? bankRefNo = DataExtractor.GetOptionalString(data, "bankRefNo");
if (paymentMethod != "Cash" && paymentMethod != "Bank")
throw new ArgumentException("paymentMethod must be 'Cash' or 'Bank'");
if (amount <= 0)
throw new ArgumentException("Payment amount must be greater than zero");
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId)
?? throw new NotFoundException("Subscription not found");
var plan = subscription.CustomerPlan
?? throw new NotFoundException("Subscription plan not found");
// Authorization: the subscription's plan must belong to the caller's gym.
Guid clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
if (plan.FKClientId != clientId)
throw new UnauthorizedException("This subscription does not belong to your gym");
var invoice = await _customerInvoiceRepository.GetInvoiceBySubscriptionIdAsync(subscriptionId);
if (invoice == null)
{
// GetInvoiceBySubscriptionIdAsync also returns null while a renewal invoice
// is awaiting approval (its only invoices are unapproved and/or RolledOver
// right now) -- that is not a legacy subscription, and self-healing here
// would silently create a duplicate, uncarried-forward invoice. Only
// self-heal a genuine "never had an invoice" subscription.
if (await _customerInvoiceRepository.HasPendingApprovalInvoiceAsync(subscriptionId))
throw new BadRequestException(
"This member's renewal is awaiting approval. Approve, change plan, or reject it before recording a payment.");
// Self-heal legacy subscriptions that predate automatic invoicing.
invoice = await _customerInvoiceRepository.CreateCustomerInvoiceFromSubscription(subscriptionId);
await _uow.SaveChangesAsync();
}
var alreadyPaid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId);
var balance = invoice.Amount - alreadyPaid;
if (amount > balance)
throw new ArgumentException($"Amount exceeds the outstanding balance ({balance:0.##})");
if (!plan.AllowSplitPayments && amount != balance)
throw new ArgumentException("This plan must be paid in full; split payments are not enabled");
var attempt = new PaymentTransaction
{
PayDate = DateTime.UtcNow,
PayStatus = "Approved",
PayAmount = amount,
PayNote = note,
FK_CustomerInvoiceId = invoice.CustomerInvoiceId,
FK_ClientId = invoice.FKClientId,
FK_CustomerId = subscription.FKCustomerId,
BeneficiaryType = BeneficiaryType.Customer,
RefType = "manual",
PaymentMethod = paymentMethod,
BankRefNo = paymentMethod == "Bank" ? bankRefNo : null,
IsActive = true,
IsApproved = true
};
var result = await _paymentRepository.CreatePaymentAttemptAsync(attempt);
await _uow.SaveChangesAsync();
var summary = await RollupCustomerInvoiceAsync(invoice.CustomerInvoiceId);
await _uow.CommitAsync();
// Post-commit, best-effort: text the member and email them a receipt PDF.
// Never let a notification failure affect the recorded payment.
try
{
var receipt = await _receiptService.BuildForInvoiceAsync(invoice.CustomerInvoiceId, amount);
await _receiptService.SendReceiptAsync(receipt);
}
catch (Exception ex)
{
Console.WriteLine($"Receipt notification failed (non-critical): {ex.Message}");
}
// Post-commit, best-effort: once the invoice is fully settled, undo whatever
// overdue lock put the member's fingerprint access on hold -- the owner just
// collected the money, they shouldn't also have to remember to hit the separate
// unlock endpoint. SetCustomerAccessAsync already carries this onto every
// co-member on a shared group/couple subscription, not just the payer.
if (summary.Status == "Paid")
{
try
{
var customer = await _customerRepository.GetCustomerById(subscription.FKCustomerId);
if (customer != null)
{
var accessStatus = await _accessDeviceService.GetAccessStatusAsync(clientId, customer.FKUserId);
if (accessStatus?.DesiredAccessState == "Disabled")
await _accessDeviceService.SetCustomerAccessAsync(clientId, subscription.FKCustomerId, enabled: true, reason: null);
}
}
catch (Exception ex)
{
Console.WriteLine($"Auto-unlock after payment failed (non-critical): {ex.Message}");
}
}
return new
{
success = true,
invoiceId = invoice.CustomerInvoiceId,
invoiceAmount = summary.InvoiceAmount,
paidAmount = summary.Paid,
balance = summary.Balance,
status = summary.Status,
payment = result
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> GetSubscriptionPayments(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
Guid subscriptionId = DataExtractor.GetRequiredGuid(data, "subscriptionId");
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(subscriptionId)
?? throw new NotFoundException("Subscription not found");
Guid clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
if (subscription.CustomerPlan == null || subscription.CustomerPlan.FKClientId != clientId)
throw new UnauthorizedException("This subscription does not belong to your gym");
var invoice = await _customerInvoiceRepository.GetInvoiceBySubscriptionIdAsync(subscriptionId);
decimal invoiceAmount = invoice?.Amount ??
subscription.CustomerPlan.PlanPrice +
(subscription.CustomerPlan.HasOnboardingFee ? subscription.CustomerPlan.OnboardingFeeAmount : 0m);
var payments = invoice != null
? await _paymentRepository.GetPaymentsByCustomerInvoiceIdAsync(invoice.CustomerInvoiceId)
: new List<PaymentTransaction>();
var installments = await _customerSubscriptionRepository.GetInstallmentsAsync(subscriptionId);
decimal paid = payments.Where(p => p.IsApproved == true).Sum(p => p.PayAmount);
return new
{
invoiceId = invoice?.CustomerInvoiceId,
invoiceAmount,
paidAmount = paid,
balance = invoiceAmount - paid,
status = paid >= invoiceAmount && invoiceAmount > 0 ? "Paid" : paid > 0 ? "Partial" : "Pending",
installments = installments.Select(i => new
{
i.SubscriptionInstallmentId,
i.InstallmentNo,
i.DueDate,
i.Amount,
i.PaidAmount,
i.Status
}),
payments = payments.Select(p => new
{
p.AttemptId,
p.PayDate,
p.PayStatus,
p.PayAmount,
p.PayNote,
p.RefType,
p.PaymentMethod,
p.BankRefNo,
p.IsApproved
})
};
}
/// <summary>
/// Owner-triggered "Charge now" against the member's consented auto-charge card.
/// No live payment gateway is wired up anywhere in this backend yet (see
/// ActivationService's own deferred platform-billing charge) — this records the attempt
/// and leaves it Pending, exactly like the rest of the app defers real settlement. Replace
/// the body of this method with a real PayHere/Stripe charge call once a gateway exists;
/// until then the owner falls back to SendPaymentLink or RecordSubscriptionPayment.
/// </summary>
public async Task<object> AttemptAutoCharge(Guid customerInvoiceId, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId)
?? throw new NotFoundException("Invoice not found");
Guid clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
if (invoice.FKClientId != clientId)
throw new UnauthorizedException("This invoice does not belong to your gym");
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(invoice.FKCustomerSubscriptionId)
?? throw new NotFoundException("Subscription not found");
if (!subscription.AutoChargeEnabled || subscription.FKAutoChargeCardId == null)
throw new BadRequestException("Auto-deduct isn't enabled for this member. Send a payment link or mark as paid instead.");
var card = await _customerRepository.GetCardByIdAsync(subscription.FKAutoChargeCardId.Value);
if (card == null || !card.ConsentGiven)
throw new BadRequestException("This member hasn't consented to auto-deduct. Send a payment link or mark as paid instead.");
var alreadyPaid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId);
var balance = invoice.Amount - alreadyPaid;
if (balance <= 0)
throw new BadRequestException("This invoice is already fully paid.");
var attempt = new PaymentTransaction
{
PayDate = DateTime.UtcNow,
PayStatus = "Pending",
PayAmount = balance,
FK_CardId = card.JobId,
FK_CustomerInvoiceId = invoice.CustomerInvoiceId,
FK_ClientId = invoice.FKClientId,
FK_CustomerId = subscription.FKCustomerId,
BeneficiaryType = BeneficiaryType.Customer,
RefType = "auto",
IsActive = true,
IsApproved = false
};
var result = await _paymentRepository.CreatePaymentAttemptAsync(attempt);
await _uow.CommitAsync();
return new
{
success = true,
status = "Pending",
message = "Charge attempt recorded — pending confirmation.",
payment = result
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
/// <summary>
/// Emails/texts the member a link to the public pay-by-link page so they can pay this
/// invoice online themselves — the other fallback alongside AttemptAutoCharge/RecordSubscriptionPayment.
/// </summary>
public async Task<object> SendPaymentLink(Guid customerInvoiceId, HttpContext httpContext)
{
Guid clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var model = await _receiptService.BuildForInvoiceAsync(customerInvoiceId, requireClientId: clientId);
if (model.Balance <= 0)
throw new BadRequestException("This invoice is already fully paid.");
if (string.IsNullOrWhiteSpace(model.MemberEmail) && string.IsNullOrWhiteSpace(model.MemberPhone))
throw new BadRequestException("This member has no phone or email on file to send the link to.");
string token = _invoicePaymentTokenHelper.Generate(customerInvoiceId);
string baseUrl = (_config["App:FrontendBaseUrl"] ?? string.Empty).TrimEnd('/');
string link = $"{baseUrl}/pay/{token}";
bool emailSent = false;
bool smsSent = false;
if (!string.IsNullOrWhiteSpace(model.MemberEmail))
{
try
{
string html =
$"<p>Hi {model.MemberName},</p>" +
$"<p>You have an outstanding balance of <b>{model.Currency} {model.Balance:N2}</b> for <b>{model.PlanName}</b> at {model.GymName}.</p>" +
$"<p><a href=\"{link}\">Pay now</a></p>" +
$"<p>— {model.GymName}<br/><small>powered by GYM OS</small></p>";
await _thirdPartyService.SendEmailAsync(model.MemberEmail!, $"Payment link — {model.GymName}", html);
emailSent = true;
}
catch (Exception ex)
{
Console.WriteLine($"Payment link email failed (non-critical): {ex.Message}");
}
}
if (!string.IsNullOrWhiteSpace(model.MemberPhone))
{
try
{
string sms = $"Pay {model.Currency} {model.Balance:N2} for {model.PlanName} at {model.GymName}: {link}";
var smsResult = await _thirdPartyService.SendSmsAsync(model.MemberPhone!, message: sms);
smsSent = smsResult.StatusCode is >= 200 and < 300;
}
catch (Exception ex)
{
Console.WriteLine($"Payment link SMS failed (non-critical): {ex.Message}");
}
}
if (!emailSent && !smsSent)
throw new BadRequestException("Couldn't send the payment link by email or SMS. Check that notifications are configured.");
string via = emailSent && smsSent ? "email and SMS" : emailSent ? "email" : "SMS";
return new { success = true, message = $"Payment link sent via {via}" };
}
public async Task<object> GetInvoiceByPaymentToken(string token)
{
var invoiceId = _invoicePaymentTokenHelper.ValidateAndGetInvoiceId(token)
?? throw new NotFoundException("This payment link is invalid or has expired.");
var model = await _receiptService.BuildForInvoiceAsync(invoiceId);
return new
{
memberName = model.MemberName,
gymName = model.GymName,
planName = model.PlanName,
amount = model.InvoiceAmount,
balance = model.Balance,
currency = model.Currency
};
}
/// <summary>
/// Public, one-time self-serve payment from the pay-by-link page. Since the member is
/// completing this live (not a background auto-charge), it's approved immediately — same
/// treatment as RecordSubscriptionPayment. cardToken is a mock value today; this is the
/// seam where a real gateway checkout replaces the stub later.
/// </summary>
public async Task<object> ChargeInvoiceByPaymentToken(string token, object payload)
{
var invoiceId = _invoicePaymentTokenHelper.ValidateAndGetInvoiceId(token)
?? throw new NotFoundException("This payment link is invalid or has expired.");
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
DataExtractor.GetRequiredString(data, "cardToken");
var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(invoiceId)
?? throw new NotFoundException("Invoice not found");
var alreadyPaid = await _paymentRepository.GetApprovedPaidAmountAsync(invoice.CustomerInvoiceId);
var balance = invoice.Amount - alreadyPaid;
if (balance <= 0)
throw new BadRequestException("This invoice is already fully paid.");
var attempt = new PaymentTransaction
{
PayDate = DateTime.UtcNow,
PayStatus = "Approved",
PayAmount = balance,
PayNote = "Paid online via payment link",
FK_CustomerInvoiceId = invoice.CustomerInvoiceId,
FK_ClientId = invoice.FKClientId,
FK_CustomerId = invoice.CustomerSubscription?.FKCustomerId,
BeneficiaryType = BeneficiaryType.Customer,
RefType = "link",
IsActive = true,
IsApproved = true
};
await _paymentRepository.CreatePaymentAttemptAsync(attempt);
await _uow.SaveChangesAsync();
var summary = await RollupCustomerInvoiceAsync(invoice.CustomerInvoiceId);
await _uow.CommitAsync();
try
{
var receipt = await _receiptService.BuildForInvoiceAsync(invoice.CustomerInvoiceId, balance);
await _receiptService.SendReceiptAsync(receipt);
}
catch (Exception ex)
{
Console.WriteLine($"Receipt notification failed (non-critical): {ex.Message}");
}
return new
{
success = true,
invoiceId = invoice.CustomerInvoiceId,
paidAmount = summary.Paid,
balance = summary.Balance,
status = summary.Status
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
/// <summary>
/// Recomputes an invoice's status from its approved payment total and
/// re-allocates the installment schedule (idempotent — always derived from the total).
/// </summary>
private async Task<(decimal InvoiceAmount, decimal Paid, decimal Balance, string Status)> RollupCustomerInvoiceAsync(Guid customerInvoiceId)
{
var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId)
?? throw new NotFoundException("Invoice not found");
decimal paid = await _paymentRepository.GetApprovedPaidAmountAsync(customerInvoiceId);
string status = paid >= invoice.Amount && invoice.Amount > 0
? "Paid"
: paid > 0 ? "Partial" : "Pending";
await _customerInvoiceRepository.UpdateCustomerInvoiceStatus(customerInvoiceId, status);
// Distribute the total across installments in order.
var installments = await _customerSubscriptionRepository.GetInstallmentsAsync(invoice.FKCustomerSubscriptionId);
decimal remaining = paid;
foreach (var installment in installments)
{
decimal fill = Math.Min(remaining, installment.Amount);
installment.PaidAmount = fill;
installment.Status = fill >= installment.Amount
? "Paid"
: fill > 0 ? "Partial" : "Pending";
remaining -= fill;
}
return (invoice.Amount, paid, invoice.Amount - paid, status);
}
}
}
+104
View File
@@ -0,0 +1,104 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using Fitness_core_BE.DTOs.PaymentGateways;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Interfaces.Services;
namespace Fitness_core_BE.Services
{
public class PaymentServiceClient : IPaymentServiceClient
{
private readonly HttpClient _httpClient;
private readonly string _baseUrl;
private readonly string _internalKey;
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
public PaymentServiceClient(IHttpClientFactory httpClientFactory, IConfiguration configuration)
{
_httpClient = httpClientFactory.CreateClient();
_baseUrl = (configuration["PaymentService:BaseUrl"] ?? "http://127.0.0.1:5120").TrimEnd('/');
_internalKey = configuration["PaymentService:InternalApiKey"] ?? string.Empty;
}
public async Task<List<GatewayDefinitionDto>> ListGatewaysAsync(CancellationToken cancellationToken = default)
{
var response = await SendAsync(HttpMethod.Get, "/api/v1/gateways", null, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
return JsonSerializer.Deserialize<List<GatewayDefinitionDto>>(body, JsonOptions) ?? new List<GatewayDefinitionDto>();
}
public async Task<GatewayDefinitionDto> UpsertGatewayAsync(UpsertGatewayDefinitionRequest request, CancellationToken cancellationToken = default)
{
var response = await SendAsync(HttpMethod.Post, "/api/v1/gateways", request, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
return JsonSerializer.Deserialize<GatewayDefinitionDto>(body, JsonOptions)
?? throw new ServiceUnavailableException("Payment-Service returned an empty gateway response.");
}
public async Task<GatewayDefinitionDto> SetGatewayEnabledAsync(Guid id, bool isEnabled, CancellationToken cancellationToken = default)
{
var response = await SendAsync(HttpMethod.Put, $"/api/v1/gateways/{id}/enabled", new { isEnabled }, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
return JsonSerializer.Deserialize<GatewayDefinitionDto>(body, JsonOptions)
?? throw new ServiceUnavailableException("Payment-Service returned an empty gateway response.");
}
public async Task UpsertPlatformCredentialAsync(UpsertPlatformGatewayCredentialRequest request, CancellationToken cancellationToken = default)
{
await SendAsync(
HttpMethod.Put,
$"/api/v1/gateways/platforms/{request.PlatformId}/credentials/{request.GatewayName}",
new { credentialsJson = request.CredentialsJson },
cancellationToken);
}
public async Task<(Guid PlatformId, string ApiKey, string HmacSecret)> ProvisionPlatformAsync(
Guid clientId, string displayName, CancellationToken cancellationToken = default)
{
var response = await SendAsync(
HttpMethod.Post,
"/api/v1/platforms",
new { externalClientId = clientId, displayName },
cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
using var doc = JsonDocument.Parse(body);
var platformId = doc.RootElement.GetProperty("platformId").GetGuid();
var apiKey = doc.RootElement.GetProperty("apiKey").GetString() ?? string.Empty;
var hmacSecret = doc.RootElement.GetProperty("hmacSecret").GetString() ?? string.Empty;
return (platformId, apiKey, hmacSecret);
}
private async Task<HttpResponseMessage> SendAsync(HttpMethod method, string path, object? body, CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(method, $"{_baseUrl}{path}");
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
request.Headers.Add("X-Internal-Key", _internalKey);
if (body is not null)
{
request.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
}
HttpResponseMessage response;
try
{
response = await _httpClient.SendAsync(request, cancellationToken);
}
catch (HttpRequestException ex)
{
throw new ServiceUnavailableException("Could not reach Payment-Service.", ex);
}
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
throw new ServiceUnavailableException($"Payment-Service call failed ({(int)response.StatusCode}): {errorBody}");
}
return response;
}
}
}
+187
View File
@@ -0,0 +1,187 @@
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.DTOs.PaymentsEnablement;
using Fitness_core_BE.DTOs.PaymentGateways;
using Fitness_core_BE.Models;
using Fitness_core_BE.Models.Enums;
namespace Fitness_core_BE.Services
{
public class PaymentsEnablementService : IPaymentsEnablementService
{
private readonly IPaymentsEnablementRepository _paymentsRepository;
private readonly IClientRepository _clientRepository;
private readonly IPaymentServiceClient _paymentServiceClient;
private readonly UserRoleHelper _userRoleHelper;
private readonly IUnitOfWork _uow;
public PaymentsEnablementService(
IPaymentsEnablementRepository paymentsRepository,
IClientRepository clientRepository,
IPaymentServiceClient paymentServiceClient,
UserRoleHelper userRoleHelper,
IUnitOfWork uow)
{
_paymentsRepository = paymentsRepository;
_clientRepository = clientRepository;
_paymentServiceClient = paymentServiceClient;
_userRoleHelper = userRoleHelper;
_uow = uow;
}
public Task<List<PaymentTrackOption>> GetTrackOptionsAsync()
{
return Task.FromResult(new List<PaymentTrackOption>
{
new(PaymentTrack.OwnGateway.ToString(), new { description = "Use your own PayHere/Stripe merchant account.", feePercentage = 0 }),
new(PaymentTrack.HexaFitness.ToString(), new { description = "Use HexaFitness's payment gateway; requires KYC before first payout.", feePercentage = 2.5 })
});
}
public async Task<PaymentTrackSelectionResult> SelectTrackAsync(HttpContext httpContext, SelectPaymentTrackRequest request)
{
var userId = DataExtractor.GetUserIdFromContext(httpContext);
var clientId = await _userRoleHelper.GetClientId(userId);
if (!Enum.TryParse<PaymentTrack>(request.Track, true, out var track))
throw new BadRequestException($"Unknown payment track '{request.Track}'.");
var client = await _clientRepository.GetClientEntityByIdAsync(clientId)
?? throw new NotFoundException("Client not found.");
client.PaymentTrack = track;
await _uow.SaveChangesAsync();
return new PaymentTrackSelectionResult(track.ToString(), track == PaymentTrack.HexaFitness);
}
public async Task<List<GatewayDefinitionDto>> GetAvailableGatewaysAsync(
CancellationToken ct = default)
{
return (await _paymentServiceClient.ListGatewaysAsync(ct))
.Where(gateway => gateway.IsEnabled)
.OrderByDescending(gateway => gateway.IsDefault)
.ThenBy(gateway => gateway.DisplayName)
.ToList();
}
public async Task<ClientPayoutKycDto> SubmitKycAsync(
HttpContext httpContext,
SubmitGatewayOnboardingRequest request,
CancellationToken ct = default)
{
var userId = DataExtractor.GetUserIdFromContext(httpContext);
var clientId = await _userRoleHelper.GetClientId(userId);
var gateway = (await GetAvailableGatewaysAsync(ct))
.FirstOrDefault(item => item.Id == request.GatewayId)
?? throw new BadRequestException("Select an available payment gateway.");
var client = await _clientRepository.GetClientEntityByIdAsync(clientId)
?? throw new NotFoundException("Client not found.");
var current = await _paymentsRepository.GetKycByClientIdAsync(clientId, ct);
if (current?.KycStatus == KycStatus.Pending)
throw new BadRequestException("A gateway KYC request is already pending review.");
if (current?.KycStatus == KycStatus.Approved)
throw new BadRequestException("Gateway KYC is already approved for this client.");
await _uow.BeginAsync();
try
{
// Catalog gateways are platform-managed, so this follows the
// HexaFitness payment track rather than client-owned credentials.
client.PaymentTrack = PaymentTrack.HexaFitness;
var kyc = new ClientPayoutKyc
{
Id = Guid.NewGuid(),
FKClientId = clientId,
SelectedGatewayId = gateway.Id,
SelectedGatewayName = gateway.DisplayName,
KycStatus = KycStatus.Pending,
SubmittedAt = DateTime.UtcNow
};
await _paymentsRepository.CreateKycAsync(kyc, ct);
await _uow.CommitAsync();
return ToDto(kyc, client);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<GatewayOnboardingStatusDto> GetKycStatusAsync(HttpContext httpContext)
{
var userId = DataExtractor.GetUserIdFromContext(httpContext);
var clientId = await _userRoleHelper.GetClientId(userId);
var kyc = await _paymentsRepository.GetKycByClientIdAsync(clientId);
return kyc is null
? new GatewayOnboardingStatusDto("NotSubmitted", null, null, null)
: new GatewayOnboardingStatusDto(
kyc.KycStatus.ToString(),
kyc.SelectedGatewayId,
kyc.SelectedGatewayName,
kyc.RejectionReason);
}
public async Task<WalletDto> GetWalletAsync(HttpContext httpContext)
{
var userId = DataExtractor.GetUserIdFromContext(httpContext);
var clientId = await _userRoleHelper.GetClientId(userId);
var wallet = await _paymentsRepository.GetWalletByClientIdAsync(clientId)
?? throw new NotFoundException("Wallet not found for this client.");
var ledger = await _paymentsRepository.GetLedgerAsync(wallet.Id);
return new WalletDto(
wallet.Balance,
wallet.Currency,
ledger.Select(e => new WalletLedgerEntryDto(e.Id, e.EntryType.ToString(), e.Amount, e.Description, e.CreatedAt)).ToList(),
wallet.LastPayoutDate);
}
public async Task<PayoutScheduleDto> GetPayoutScheduleAsync(HttpContext httpContext)
{
var userId = DataExtractor.GetUserIdFromContext(httpContext);
var clientId = await _userRoleHelper.GetClientId(userId);
var wallet = await _paymentsRepository.GetWalletByClientIdAsync(clientId)
?? throw new NotFoundException("Wallet not found for this client.");
var today = DateTime.UtcNow.Date;
var daysUntilTuesday = ((int)DayOfWeek.Tuesday - (int)today.DayOfWeek + 7) % 7;
daysUntilTuesday = daysUntilTuesday == 0 ? 7 : daysUntilTuesday;
var nextPayoutDate = today.AddDays(daysUntilTuesday);
return new PayoutScheduleDto(nextPayoutDate, "Weekly (Tuesday)", wallet.Balance);
}
// NIC fields used to come from the multi-gym onboarding wizard's OwnerActivation record;
// that flow no longer exists for a single already-real club (see PayoutKycReviewService's
// ToDto for the same note) -- always null here now.
private static ClientPayoutKycDto ToDto(
ClientPayoutKyc kyc,
Client client)
=> new(
kyc.Id,
kyc.FKClientId,
client.ClientName,
kyc.SelectedGatewayId,
kyc.SelectedGatewayName,
kyc.KycStatus.ToString(),
kyc.SubmittedAt,
kyc.ReviewedAt,
kyc.RejectionReason,
null,
null,
null);
}
}
+114
View File
@@ -0,0 +1,114 @@
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.DTOs.PaymentsEnablement;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models.Enums;
namespace Fitness_core_BE.Services
{
public class PayoutKycReviewService : IPayoutKycReviewService
{
private readonly IPaymentsEnablementRepository _paymentsRepository;
private readonly UserRoleHelper _userRoleHelper;
private readonly IUnitOfWork _uow;
public PayoutKycReviewService(IPaymentsEnablementRepository paymentsRepository, UserRoleHelper userRoleHelper, IUnitOfWork uow)
{
_paymentsRepository = paymentsRepository;
_userRoleHelper = userRoleHelper;
_uow = uow;
}
public async Task<List<ClientPayoutKycDto>> ListAsync(string? status)
{
var (items, _) = await _paymentsRepository.ListKycAsync(null);
var latestPerClient = items
.GroupBy(item => item.FKClientId)
.Select(group => group
.OrderByDescending(item => item.SubmittedAt)
.ThenByDescending(item => item.Id)
.First());
if (!string.IsNullOrWhiteSpace(status))
{
if (!Enum.TryParse<KycStatus>(status, true, out var parsedStatus))
throw new BadRequestException($"Unknown KYC status '{status}'.");
latestPerClient = latestPerClient
.Where(item => item.KycStatus == parsedStatus);
}
return latestPerClient
.OrderByDescending(item => item.SubmittedAt)
.Select(ToDto)
.ToList();
}
public async Task<ClientPayoutKycDto> GetByClientIdAsync(Guid clientId)
{
var kyc = await _paymentsRepository.GetKycByClientIdAsync(clientId)
?? throw new NotFoundException("No KYC submission found for this client.");
return ToDto(kyc);
}
public async Task<ClientPayoutKycDto> ReviewAsync(Guid clientId, ReviewPayoutKycRequest request, HttpContext httpContext)
{
var kyc = await _paymentsRepository.GetKycForUpdateAsync(clientId)
?? throw new NotFoundException("No KYC submission found for this client.");
if (!Enum.TryParse<KycStatus>(request.Status, true, out var newStatus))
throw new BadRequestException($"Unknown KYC status '{request.Status}'.");
if (newStatus is not KycStatus.Approved and not KycStatus.Rejected)
throw new BadRequestException("KYC can only be approved or rejected.");
if (kyc.KycStatus != KycStatus.Pending)
throw new BadRequestException("Only pending KYC submissions can be reviewed.");
if (newStatus == KycStatus.Rejected && string.IsNullOrWhiteSpace(request.Reason))
throw new BadRequestException("A rejection reason is required.");
var adminUserId = DataExtractor.GetUserIdFromContext(httpContext);
var adminId = await _userRoleHelper.GetAdminId(adminUserId)
?? throw new ForbiddenException("Only admins may review payout KYC.");
await _uow.BeginAsync();
try
{
kyc.KycStatus = newStatus;
kyc.ReviewedAt = DateTime.UtcNow;
kyc.ReviewedByAdminId = adminId;
kyc.RejectionReason = newStatus == KycStatus.Rejected
? request.Reason!.Trim()
: null;
await _uow.CommitAsync();
return ToDto(kyc);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
// NIC fields used to come from the multi-gym onboarding wizard's OwnerActivation record
// (collected during signup); that flow no longer exists for a single already-real club,
// so these are always null here now. If gateway KYC needs NIC info going forward, collect
// it directly on the KYC submission instead of reintroducing that dependency.
private static ClientPayoutKycDto ToDto(Models.ClientPayoutKyc kyc)
{
return new ClientPayoutKycDto(
kyc.Id,
kyc.FKClientId,
kyc.Client?.ClientName,
kyc.SelectedGatewayId,
kyc.SelectedGatewayName,
kyc.KycStatus.ToString(),
kyc.SubmittedAt,
kyc.ReviewedAt,
kyc.RejectionReason,
null,
null,
null);
}
}
}
+63
View File
@@ -0,0 +1,63 @@
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Microsoft.AspNetCore.Http;
namespace Fitness_core_BE.Services
{
public class PermissionService : IPermissionService
{
private readonly UserRoleHelper _userRoleHelper;
private readonly IStaffRepository _staffRepository;
private readonly IGymRoleRepository _gymRoleRepository;
private readonly INavItemRepository _navItemRepository;
public PermissionService(
UserRoleHelper userRoleHelper,
IStaffRepository staffRepository,
IGymRoleRepository gymRoleRepository,
INavItemRepository navItemRepository)
{
_userRoleHelper = userRoleHelper;
_staffRepository = staffRepository;
_gymRoleRepository = gymRoleRepository;
_navItemRepository = navItemRepository;
}
public async Task<PermissionContext> GetPermissionContextAsync(HttpContext httpContext)
{
var userId = DataExtractor.GetUserIdFromContext(httpContext);
// Owner check first: the gym owner always has full access and never touches
// GymRole/GymRolePermission at all.
var clientId = await _userRoleHelper.GetOwnedClientId(userId);
if (clientId.HasValue)
{
var allKeys = (await _navItemRepository.GetActiveNavItemsOrdered())
.Select(n => n.Key)
.ToHashSet();
return new PermissionContext(IsOwner: true, ClientId: clientId, StaffId: null, AllowedNavKeys: allKeys);
}
var staffId = await _userRoleHelper.GetStaffId(userId);
if (staffId.HasValue)
{
var staff = await _staffRepository.GetStaffById(staffId.Value);
if (staff?.FKGymRoleId is Guid gymRoleId && staff.GymRole?.IsActive == true)
{
var keys = (await _gymRoleRepository.GetPermissionKeys(gymRoleId)).ToHashSet();
return new PermissionContext(IsOwner: false, ClientId: null, StaffId: staffId, AllowedNavKeys: keys);
}
// Staff with no role assigned yet: dashboard only, not zero -- so forgetting
// to assign a role (the create-staff flow makes it optional) leaves someone
// with a blank-but-working dashboard instead of a fully broken login.
return new PermissionContext(IsOwner: false, ClientId: null, StaffId: staffId, AllowedNavKeys: new HashSet<string> { "dashboard" });
}
// Neither owner nor staff (e.g. Customer/Admin token hitting a client-scoped endpoint).
return new PermissionContext(IsOwner: false, ClientId: null, StaffId: null, AllowedNavKeys: new HashSet<string>());
}
}
}
+175
View File
@@ -0,0 +1,175 @@
using Fitness_core_BE.DTOs.Place;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using System.Text.Json;
namespace Fitness_core_BE.Services
{
public class PlaceService : IPlaceService
{
private readonly IPlaceRepo _placeRepo;
private readonly IUnitOfWork _uow;
private readonly UserRoleHelper _userRoleHelper;
public PlaceService(IPlaceRepo placeRepo, IUnitOfWork uow, UserRoleHelper userRoleHelper)
{
_placeRepo = placeRepo;
_uow = uow;
_userRoleHelper = userRoleHelper;
}
public async Task<object> GetAllPlaces(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
bool includeInactive = data.ContainsKey("includeInactive") && data["includeInactive"].GetBoolean();
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var places = await _placeRepo.GetAllPlaces(clientId, includeInactive);
var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(places.Select(p => p.PlaceId).ToList());
return places.Select(p => MapToDto(p, devicesByPlace.GetValueOrDefault(p.PlaceId))).ToList();
}
public async Task<object> CreatePlace(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data.ContainsKey("name")) throw new KeyNotFoundException("name is required");
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var place = new Place
{
FKClientId = clientId,
Name = data["name"].GetString() ?? "",
Description = data.ContainsKey("description") ? data["description"].GetString() : null
};
var result = await _placeRepo.CreatePlace(place);
await _uow.CommitAsync();
return MapToDto(result, null);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> UpdatePlace(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data.ContainsKey("placeId")) throw new KeyNotFoundException("placeId is required");
var placeId = Guid.Parse(data["placeId"].GetString()!);
var existing = await _placeRepo.GetPlaceById(placeId)
?? throw new KeyNotFoundException("Place not found");
if (data.ContainsKey("name")) existing.Name = data["name"].GetString() ?? existing.Name;
if (data.ContainsKey("description")) existing.Description = data["description"].GetString();
if (data.ContainsKey("isActive")) existing.IsActive = data["isActive"].GetBoolean();
var result = await _placeRepo.UpdatePlace(existing);
await _uow.CommitAsync();
var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(new List<Guid> { result.PlaceId });
return MapToDto(result, devicesByPlace.GetValueOrDefault(result.PlaceId));
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> SetPlaceStatus(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var placeId = Guid.Parse(data["placeId"].GetString()!);
var isActive = data["isActive"].GetBoolean();
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var place = await _placeRepo.SetPlaceActive(placeId, clientId, isActive)
?? throw new KeyNotFoundException("Place not found");
await _uow.CommitAsync();
var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(new List<Guid> { place.PlaceId });
return MapToDto(place, devicesByPlace.GetValueOrDefault(place.PlaceId));
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> AssignDevice(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data.ContainsKey("placeId")) throw new KeyNotFoundException("placeId is required");
var placeId = Guid.Parse(data["placeId"].GetString()!);
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var place = await _placeRepo.GetPlaceById(placeId);
if (place == null || place.FKClientId != clientId)
throw new KeyNotFoundException("Place not found");
var hasDeviceId = data.ContainsKey("deviceId") && data["deviceId"].ValueKind != JsonValueKind.Null;
if (!hasDeviceId)
{
await _placeRepo.UnassignDeviceAsync(placeId);
}
else
{
var deviceId = Guid.Parse(data["deviceId"].GetString()!);
var device = await _placeRepo.GetDeviceByIdForClientAsync(deviceId, clientId)
?? throw new KeyNotFoundException("Device not found");
if (await _placeRepo.IsDeviceAssignedToAnotherPlaceAsync(deviceId, placeId))
throw new ArgumentException("This device is already assigned to another place.");
await _placeRepo.AssignDeviceAsync(placeId, deviceId);
}
await _uow.CommitAsync();
var devicesByPlace = await _placeRepo.GetDevicesByPlaceIdsAsync(new List<Guid> { placeId });
return MapToDto(place, devicesByPlace.GetValueOrDefault(placeId));
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
private static PlaceDto MapToDto(Place place, AccessDevice? device) => new()
{
PlaceId = place.PlaceId,
Name = place.Name,
Description = place.Description,
IsActive = place.IsActive,
CreatedAt = place.CreatedAt,
DeviceId = device?.DeviceId,
DeviceName = device?.DeviceName
};
}
}
+752
View File
@@ -0,0 +1,752 @@
using Fitness_core_BE.DTOs.PaymentGateways;
using Fitness_core_BE.DTOs.PlatformBilling;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Fitness_core_BE.Models.Enums;
namespace Fitness_core_BE.Services
{
public class PlatformBillingService : IPlatformBillingService
{
// The gateway a gym's saved ClientCard was onboarded through — see
// ClientPaymentMethodService, which uses the same constant for onboarding.
private const string GatewayName = "DirectPay";
private readonly IPlatformBillingRepository _repository;
private readonly IClientStatRepository _clientStatRepository;
private readonly IAccessDeviceRepo _accessDeviceRepo;
private readonly IThirdPartyService _thirdPartyService;
private readonly IPaymentGatewayClient _paymentGatewayClient;
private readonly IPaymentRepository _paymentRepository;
private readonly PlatformInvoicePaymentTokenHelper _paymentTokenHelper;
private readonly IConfiguration _config;
private readonly IUnitOfWork _uow;
public PlatformBillingService(
IPlatformBillingRepository repository,
IClientStatRepository clientStatRepository,
IAccessDeviceRepo accessDeviceRepo,
IThirdPartyService thirdPartyService,
IPaymentGatewayClient paymentGatewayClient,
IPaymentRepository paymentRepository,
PlatformInvoicePaymentTokenHelper paymentTokenHelper,
IConfiguration config,
IUnitOfWork uow)
{
_repository = repository;
_clientStatRepository = clientStatRepository;
_accessDeviceRepo = accessDeviceRepo;
_thirdPartyService = thirdPartyService;
_paymentGatewayClient = paymentGatewayClient;
_paymentRepository = paymentRepository;
_paymentTokenHelper = paymentTokenHelper;
_config = config;
_uow = uow;
}
public async Task<List<PricingPlanDto>> GetPricingPlansAsync(CancellationToken ct = default)
{
var plans = await _repository.GetPricingPlansAsync(ct);
return plans.Select(ToDto).ToList();
}
public async Task<PricingPlanDto> UpsertPricingPlanAsync(UpsertPricingPlanRequest request, CancellationToken ct = default)
{
await _uow.BeginAsync();
try
{
ClientPackage plan;
if (request.ClientPackageId.HasValue)
{
plan = await _repository.GetPackageByIdAsync(
request.ClientPackageId.Value, ct)
?? throw new NotFoundException("Pricing plan not found");
}
else
{
plan = new ClientPackage { ClientPackageId = Guid.NewGuid() };
await _repository.AddPackageAsync(plan, ct);
}
plan.PackageName = request.PackageName;
plan.PackageDescription = request.PackageDescription;
plan.PerMemberRate = request.PerMemberRate;
plan.MinimumMonthlyBill = request.MinimumMonthlyBill;
plan.MonthlyCap = request.MonthlyCap;
plan.SetupFee = request.SetupFee;
plan.IncludedMemberCount = request.IncludedMemberCount;
plan.MaxBillableMemberCount = request.MaxBillableMemberCount;
plan.OwnGatewayFeePercent = request.OwnGatewayFeePercent;
plan.OwnGatewayFeeFixed = request.OwnGatewayFeeFixed;
plan.OurGatewayFeePercent = request.OurGatewayFeePercent;
plan.OurGatewayFeeFixed = request.OurGatewayFeeFixed;
plan.PayoutDayOfMonth = request.PayoutDayOfMonth is >= 1 and <= 28 ? request.PayoutDayOfMonth : 1;
plan.PackagePrice = request.MinimumMonthlyBill;
if (request.IsDefault)
{
// Only one plan may be default at a time.
var currentDefaults = await _repository
.GetOtherDefaultPackagesAsync(plan.ClientPackageId, ct);
foreach (var d in currentDefaults)
d.IsDefault = false;
}
plan.IsDefault = request.IsDefault;
await _uow.CommitAsync();
return ToDto(plan);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<DevicePricingDto> GetDevicePricingAsync(CancellationToken ct = default)
{
var config = await GetDeviceConfigOrDefaultAsync(ct);
return new DevicePricingDto
{
DeviceRentalFeePerMonth = config.DeviceRentalFeePerMonth,
DeviceInstallationFee = config.DeviceInstallationFee,
DeviceConfigureExistingFee = config.DeviceConfigureExistingFee
};
}
public async Task<DevicePricingDto> UpdateDevicePricingAsync(DevicePricingDto request, CancellationToken ct = default)
{
await _uow.BeginAsync();
try
{
var config = await _repository.GetDevicePricingConfigAsync(ct);
if (config == null)
{
config = CreateDefaultDeviceConfig();
await _repository.AddDevicePricingConfigAsync(config, ct);
}
config.DeviceRentalFeePerMonth = request.DeviceRentalFeePerMonth;
config.DeviceInstallationFee = request.DeviceInstallationFee;
config.DeviceConfigureExistingFee = request.DeviceConfigureExistingFee;
config.UpdatedAt = DateTime.UtcNow;
await _uow.CommitAsync();
return request;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<Models.ClientInvoice> AssignPricingPlanAsync(Guid clientId, AssignPricingPlanRequest request, CancellationToken ct = default)
{
await _uow.BeginAsync();
try
{
var plan = await _repository.GetPackageByIdAsync(
request.ClientPackageId, ct)
?? throw new NotFoundException("Pricing plan not found");
var client = await _repository.GetClientByIdAsync(clientId, ct)
?? throw new NotFoundException("Client not found");
var existingActive = await _repository
.GetActiveClientSubscriptionsAsync(clientId, ct);
foreach (var s in existingActive)
s.IsActive = false;
await _repository.AddClientSubscriptionAsync(new ClientSubscription
{
SubscriptionId = Guid.NewGuid(),
FKClientId = clientId,
FKClientPackageId = request.ClientPackageId,
OnboardDate = DateTime.UtcNow,
IsActive = true
}, ct);
if (!string.IsNullOrWhiteSpace(request.DeviceBillingMode))
{
var existingDeviceBillings = await _repository
.GetActiveDeviceBillingsAsync(clientId, ct);
foreach (var d in existingDeviceBillings)
d.IsActive = false;
await _repository.AddDeviceBillingAsync(new ClientDeviceBilling
{
Id = Guid.NewGuid(),
FKClientId = clientId,
Mode = request.DeviceBillingMode,
AgreedAmount = request.DeviceAgreedAmount ?? 0,
SetupFee = request.DeviceSetupFee ?? 0,
IsActive = true,
AssignedAt = DateTime.UtcNow
}, ct);
}
await _uow.SaveChangesAsync(ct);
// Combined onboarding payment: (machine full price OR setup-only for rental) +
// setup fee + minimum-member-subscription fee + gateway fee (may be 0).
var minMemberFee = plan.MinimumMonthlyBill;
var gatewayFee = ComputeGatewayFee(client, plan, minMemberFee);
var extraItems = new List<(string Name, string Description, decimal Amount)>();
if (!string.IsNullOrWhiteSpace(request.DeviceBillingMode))
{
if (request.DeviceBillingMode == "FullPurchase")
{
extraItems.Add(("Access device (full purchase)", "One-time device purchase", request.DeviceAgreedAmount ?? 0));
}
if ((request.DeviceSetupFee ?? 0) > 0)
{
extraItems.Add(("Device setup fee", "One-time installation/setup", request.DeviceSetupFee ?? 0));
}
}
if (minMemberFee > 0)
{
extraItems.Add(("Minimum member subscription fee", $"{plan.PackageName ?? "Standard"} plan minimum bill", minMemberFee));
}
if (gatewayFee > 0)
{
extraItems.Add(("Gateway fee", client.PaymentTrack == Models.Enums.PaymentTrack.OwnGateway ? "Own gateway fee" : "HexaFitness gateway fee", gatewayFee));
}
var invoice = await CreatePlatformInvoiceAsync(clientId,
"Plan setup",
$"Setup for the {plan.PackageName ?? "Standard"} plan",
plan.SetupFee,
extraItems,
ct);
await _uow.CommitAsync();
return invoice;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<PlatformFeeBreakdownDto> ComputeMonthlyFeeAsync(Guid clientId, CancellationToken ct = default)
{
var client = await _repository.GetClientByIdAsync(clientId, ct)
?? throw new NotFoundException("Client not found");
var plan = await ResolvePlanForClientAsync(clientId, ct);
var activeMembers = await _clientStatRepository.GetTotalCustomerCountAsync(clientId, ct);
var billableMembers = plan.MaxBillableMemberCount.HasValue
? Math.Min(activeMembers, plan.MaxBillableMemberCount.Value)
: activeMembers;
var overageMembers = Math.Max(0, billableMembers - plan.IncludedMemberCount);
decimal baseFee = plan.MinimumMonthlyBill + overageMembers * plan.PerMemberRate;
if (plan.MonthlyCap.HasValue)
baseFee = Math.Min(baseFee, plan.MonthlyCap.Value);
var deviceFee = await ComputeDeviceFeeAsync(clientId, ct);
var gatewayFee = ComputeGatewayFee(client, plan, baseFee);
return new PlatformFeeBreakdownDto
{
ClientId = clientId,
PlanName = plan.PackageName,
ActiveMemberCount = activeMembers,
PerMemberRate = plan.PerMemberRate,
MinimumMonthlyBill = plan.MinimumMonthlyBill,
MonthlyCap = plan.MonthlyCap,
BaseFee = baseFee,
DeviceCount = deviceFee.DeviceCount,
DeviceFee = deviceFee.Fee,
GatewayFee = gatewayFee,
TotalFee = baseFee + deviceFee.Fee + gatewayFee
};
}
public async Task<Models.ClientInvoice> GenerateMonthlyInvoiceAsync(Guid clientId, CancellationToken ct = default)
{
var breakdown = await ComputeMonthlyFeeAsync(clientId, ct);
await _uow.BeginAsync();
try
{
var extraItems = new List<(string, string, decimal)>();
if (breakdown.DeviceCount > 0)
extraItems.Add(("Access device rental", $"{breakdown.DeviceCount} unit(s)", breakdown.DeviceFee));
if (breakdown.GatewayFee > 0)
extraItems.Add(("Gateway fee", "Platform gateway fee for this billing period", breakdown.GatewayFee));
var invoice = await CreatePlatformInvoiceAsync(clientId,
$"Platform fee — {breakdown.ActiveMemberCount} active member(s)",
$"{breakdown.PlanName ?? "Standard"} plan — Rs. {breakdown.PerMemberRate}/member (min Rs. {breakdown.MinimumMonthlyBill})",
breakdown.BaseFee,
extraItems,
ct);
await _uow.CommitAsync();
return invoice;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<Models.ClientInvoice> GenerateOnboardingInvoiceAsync(Guid clientId, CancellationToken ct = default)
{
var plan = await ResolvePlanForClientAsync(clientId, ct);
var devicePricing = await GetDeviceConfigOrDefaultAsync(ct);
var hasDevice = await _repository.HasActiveDeviceAsync(clientId, ct);
await _uow.BeginAsync();
try
{
var invoice = await CreatePlatformInvoiceAsync(clientId,
"Onboarding & setup fee",
$"One-time setup fee for the {plan.PackageName ?? "Standard"} plan",
plan.SetupFee,
hasDevice
? new[] { ("Access device installation", "One-time installation fee", devicePricing.DeviceInstallationFee) }
: Array.Empty<(string, string, decimal)>(),
ct);
await _uow.CommitAsync();
return invoice;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<List<Models.ClientInvoice>> RunMonthlyBillingForAllClientsAsync(CancellationToken ct = default)
{
var startOfMonth = new DateTime(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, DateTimeKind.Utc);
var clientIds = await _repository
.GetMonthlyBillingCandidatesAsync(startOfMonth, ct);
var results = new List<Models.ClientInvoice>();
foreach (var clientId in clientIds)
{
// Skip clients already billed for the current period, and clients with no card on file yet
// (activation not completed) — nothing meaningful to invoice for those.
results.Add(await GenerateMonthlyInvoiceAsync(clientId, ct));
}
return results;
}
// Closes the loop RunMonthlyBillingForAllClientsAsync leaves open: generates any
// invoices newly due this period, then attempts (or re-polls) a real Payment-Service
// charge for every still-outstanding platform invoice that has a card on file.
// AttemptPaymentAsync is idempotent per invoice (IdempotencyKey below), so simply
// re-calling it on a later run is a safe, correct way to both charge new invoices and
// poll ones still Processing from a prior run — no separate local "pending" bookkeeping.
public async Task<SubscriptionChargeRunResult> RunAndChargeSubscriptionBillingAsync(CancellationToken ct = default)
{
var result = new SubscriptionChargeRunResult();
var generated = await RunMonthlyBillingForAllClientsAsync(ct);
result.InvoicesGenerated = generated.Count;
var outstanding = await _repository.GetInvoicesByStatusAsync("Outstanding", newestFirst: false, ct);
foreach (var invoice in outstanding)
{
var balance = invoice.Amount - invoice.PaidAmount;
if (balance <= 0)
continue;
var clientId = invoice.ClientSubscription.FKClientId;
var card = await _repository.GetClientCardAsync(clientId, ct);
if (card is null || string.IsNullOrEmpty(card.CardToken))
{
result.SkippedNoCard++;
continue;
}
// The most recent charge attempt already recorded for this invoice, if any.
// Payment-Service ties AmountMinorUnits to the IdempotencyKey permanently (a
// mismatched amount on replay throws), so a fresh charge always uses the
// invoice's current balance, but re-polling an in-flight one must reuse the
// exact amount it was created with.
var existingTxn = (await _paymentRepository.GetPaymentsByClientInvoiceIdAsync(invoice.ClientInvoiceId))
.Where(t => t.RefType == "platform-subscription")
.OrderByDescending(t => t.PayDate)
.FirstOrDefault();
if (existingTxn?.PayStatus == "Failed")
{
// Payment-Service payments can never leave Failed — left Pending for the
// existing manual pay-link fallback (SendClientPaymentLinkAsync) rather than
// retried automatically forever.
result.SkippedPreviouslyFailed++;
continue;
}
decimal chargeAmount;
if (existingTxn?.PayStatus == "Processing")
{
if (existingTxn.PayAmount != balance)
{
// The balance moved since the in-flight attempt was created (e.g. a
// manual partial payment) — retrying would either overcharge or hit
// Payment-Service's idempotency-conflict check. Needs manual review.
result.SkippedAmountMismatch++;
continue;
}
chargeAmount = existingTxn.PayAmount;
}
else
{
chargeAmount = balance;
}
AttemptPaymentResult attempt;
try
{
attempt = await _paymentGatewayClient.AttemptPaymentAsync(clientId, new AttemptPaymentRequest
{
IdempotencyKey = $"platform-invoice-{invoice.ClientInvoiceId}",
GatewayName = GatewayName,
CardToken = card.CardToken,
AmountMinorUnits = chargeAmount * 100m,
Currency = "LKR",
Description = $"HexaFitness platform invoice {invoice.ClientInvoiceId}"
}, ct);
}
catch (Exception ex)
{
// Non-fatal for the rest of the run: this invoice is retried (idempotently)
// next run, but the caller needs to see the run wasn't fully clean.
Console.WriteLine($"Subscription charge attempt failed for invoice {invoice.ClientInvoiceId}: {ex.Message}");
result.Errors++;
continue;
}
result.ChargesAttempted++;
await _uow.BeginAsync();
try
{
if (existingTxn is null || existingTxn.PaymentReferenceId != attempt.PaymentId.ToString())
{
await _paymentRepository.CreatePaymentAttemptAsync(new PaymentTransaction
{
PayDate = attempt.CreatedAtUtc,
PayStatus = attempt.Status,
PayAmount = chargeAmount,
FK_ClientInvoiceId = invoice.ClientInvoiceId,
FK_ClientId = clientId,
BeneficiaryType = BeneficiaryType.Client,
RefType = "platform-subscription",
PaymentReferenceId = attempt.PaymentId.ToString(),
IsActive = true,
IsApproved = attempt.Status == "Completed"
});
}
else
{
existingTxn.PayStatus = attempt.Status;
existingTxn.IsApproved = attempt.Status == "Completed";
await _paymentRepository.UpdatePaymentAttemptAsync(existingTxn);
}
if (attempt.Status == "Completed")
{
invoice.PaidAmount += chargeAmount;
invoice.Status = invoice.PaidAmount >= invoice.Amount ? "Paid" : "Partial";
result.Paid++;
}
else if (attempt.Status == "Failed")
{
result.Failed++;
}
else
{
result.StillProcessing++;
}
await _uow.CommitAsync();
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
return result;
}
public async Task<List<PendingPlatformInvoiceDto>> GetPendingInvoicesAsync(CancellationToken ct = default)
{
var pending = await _repository
.GetInvoicesByStatusAsync("Outstanding", newestFirst: false, ct);
return pending.Select(ToPendingDto).ToList();
}
public async Task<List<PendingPlatformInvoiceDto>> GetCompletedInvoicesAsync(CancellationToken ct = default)
{
var completed = await _repository
.GetInvoicesByStatusAsync("Paid", newestFirst: true, ct);
return completed.Select(ToPendingDto).ToList();
}
public async Task<PendingPlatformInvoiceDto> RecordClientInvoicePaymentAsync(Guid clientInvoiceId, decimal amount, CancellationToken ct = default)
{
if (amount <= 0)
throw new BadRequestException("Payment amount must be greater than zero.");
await _uow.BeginAsync();
try
{
var invoice = await _repository.GetInvoiceWithClientAsync(
clientInvoiceId, includeBusinesses: true, ct)
?? throw new NotFoundException("Platform invoice not found");
var balance = invoice.Amount - invoice.PaidAmount;
if (balance <= 0)
throw new BadRequestException("This invoice is already fully paid.");
invoice.PaidAmount += Math.Min(amount, balance);
invoice.Status = invoice.PaidAmount >= invoice.Amount ? "Paid" : "Partial";
await _uow.CommitAsync();
var client = invoice.ClientSubscription.Client;
if (client != null)
await NotifyPaymentActivityAsync(client, invoice, sendPaymentLink: invoice.Status != "Paid");
return ToPendingDto(invoice);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> SendClientPaymentLinkAsync(Guid clientInvoiceId, CancellationToken ct = default)
{
var invoice = await _repository.GetInvoiceWithClientAsync(
clientInvoiceId, includeBusinesses: false, ct)
?? throw new NotFoundException("Platform invoice not found");
var client = invoice.ClientSubscription.Client
?? throw new NotFoundException("Client not found for this invoice");
if (invoice.Amount - invoice.PaidAmount <= 0)
throw new BadRequestException("This invoice is already fully paid.");
await NotifyPaymentActivityAsync(client, invoice, sendPaymentLink: true);
return new { sent = true };
}
private async Task NotifyPaymentActivityAsync(Client client, Models.ClientInvoice invoice, bool sendPaymentLink)
{
var balance = invoice.Amount - invoice.PaidAmount;
var message = invoice.Status == "Paid"
? $"HexaFitness: payment of Rs. {invoice.PaidAmount:N2} received. Thank you!"
: $"HexaFitness: Rs. {balance:N2} due on your account.";
if (sendPaymentLink && balance > 0)
{
var token = _paymentTokenHelper.Generate(invoice.ClientInvoiceId);
var baseUrl = (_config["App:FrontendBaseUrl"] ?? string.Empty).TrimEnd('/');
var link = $"{baseUrl}/pay-platform/{token}";
message += $" Pay online: {link}";
}
if (!string.IsNullOrWhiteSpace(client.ClientPhone))
{
try
{
await _thirdPartyService.SendSmsAsync(client.ClientPhone!, message: message);
}
catch (Exception ex)
{
Console.WriteLine($"Platform invoice SMS failed (non-critical): {ex.Message}");
}
}
}
private async Task<(int DeviceCount, decimal Fee)> ComputeDeviceFeeAsync(Guid clientId, CancellationToken ct)
{
var devices = await _accessDeviceRepo.GetDevicesByClientAsync(clientId);
var activeDeviceCount = devices.Count(d => d.IsActive);
var deviceBilling = await _repository
.GetLatestActiveDeviceBillingAsync(clientId, ct);
if (deviceBilling != null)
{
// Rental contributes a recurring monthly fee; a full purchase is one-time
// (already billed at assignment time) and adds nothing to the recurring fee.
var fee = deviceBilling.Mode == "Rental" ? deviceBilling.AgreedAmount : 0;
return (activeDeviceCount, fee);
}
// No per-client agreement recorded yet: fall back to the global default rate.
var devicePricing = await GetDeviceConfigOrDefaultAsync(ct);
return (activeDeviceCount, activeDeviceCount * devicePricing.DeviceRentalFeePerMonth);
}
private static decimal ComputeGatewayFee(Client client, ClientPackage plan, decimal baseAmount)
{
var isOwnGateway = client.PaymentTrack == Models.Enums.PaymentTrack.OwnGateway;
var percent = isOwnGateway ? plan.OwnGatewayFeePercent : plan.OurGatewayFeePercent;
var fixedFee = isOwnGateway ? plan.OwnGatewayFeeFixed : plan.OurGatewayFeeFixed;
return Math.Round(baseAmount * percent / 100m, 2) + fixedFee;
}
private async Task<Models.ClientInvoice> CreatePlatformInvoiceAsync(
Guid clientId,
string primaryItemName,
string primaryItemDescription,
decimal primaryItemAmount,
IEnumerable<(string Name, string Description, decimal Amount)> extraItems,
CancellationToken ct)
{
var client = await _repository.GetClientByIdAsync(clientId, ct)
?? throw new NotFoundException("Client not found");
var subscription = await _repository
.GetActiveClientSubscriptionAsync(clientId, ct);
if (subscription == null)
{
var defaultPlan = await _repository.GetDefaultPackageAsync(ct);
if (defaultPlan == null)
{
defaultPlan = CreateDefaultPlan();
await _repository.AddPackageAsync(defaultPlan, ct);
}
subscription = new ClientSubscription
{
SubscriptionId = Guid.NewGuid(),
FKClientId = clientId,
FKClientPackageId = defaultPlan.ClientPackageId,
OnboardDate = DateTime.UtcNow,
IsActive = true
};
await _repository.AddClientSubscriptionAsync(subscription, ct);
}
var clientCard = await _repository.GetClientCardAsync(clientId, ct)
?? throw new BadRequestException("No card on file for this gym yet — cannot generate an invoice.");
var items = new List<(string Name, string Description, decimal Amount)>
{
(primaryItemName, primaryItemDescription, primaryItemAmount)
};
items.AddRange(extraItems);
var invoice = new Models.ClientInvoice
{
ClientInvoiceId = Guid.NewGuid(),
FKClientSubscriptionId = subscription.SubscriptionId,
FKUserId = client.FKUserId,
FKClientCardId = clientCard.CardId,
Amount = items.Sum(i => i.Amount),
PaidAmount = 0,
Status = "Pending",
ClientInvoiceDate = DateTime.UtcNow
};
var invoiceItems = items
.Select(item => new ClientInvoiceItem
{
ClientInvoiceItemId = Guid.NewGuid(),
FKClientInvoiceId = invoice.ClientInvoiceId,
ItemName = item.Name,
ItemDescription = item.Description,
ItemPrice = item.Amount,
CreatedAt = DateTime.UtcNow
})
.ToList();
await _repository.AddInvoiceAsync(invoice, invoiceItems, ct);
return invoice;
}
private async Task<ClientPackage> ResolvePlanForClientAsync(Guid clientId, CancellationToken ct)
{
return await _repository.GetActivePackageForClientAsync(clientId, ct)
?? await _repository.GetDefaultPackageAsync(ct)
?? CreateDefaultPlan();
}
private static ClientPackage CreateDefaultPlan()
{
return new ClientPackage
{
ClientPackageId = Guid.NewGuid(),
PackageName = "Standard",
PackageDescription = "Default platform pricing plan",
PerMemberRate = 0,
MinimumMonthlyBill = 0,
MonthlyCap = null,
SetupFee = 0,
IsDefault = true,
IsActive = true
};
}
private async Task<DevicePricingConfig> GetDeviceConfigOrDefaultAsync(CancellationToken ct)
{
return await _repository.GetDevicePricingConfigAsync(ct)
?? CreateDefaultDeviceConfig();
}
private static DevicePricingConfig CreateDefaultDeviceConfig() =>
new() { DevicePricingConfigId = Guid.NewGuid() };
private static PricingPlanDto ToDto(ClientPackage plan) => new()
{
ClientPackageId = plan.ClientPackageId,
PackageName = plan.PackageName,
PackageDescription = plan.PackageDescription,
PerMemberRate = plan.PerMemberRate,
MinimumMonthlyBill = plan.MinimumMonthlyBill,
MonthlyCap = plan.MonthlyCap,
SetupFee = plan.SetupFee,
IncludedMemberCount = plan.IncludedMemberCount,
MaxBillableMemberCount = plan.MaxBillableMemberCount,
OwnGatewayFeePercent = plan.OwnGatewayFeePercent,
OwnGatewayFeeFixed = plan.OwnGatewayFeeFixed,
OurGatewayFeePercent = plan.OurGatewayFeePercent,
OurGatewayFeeFixed = plan.OurGatewayFeeFixed,
PayoutDayOfMonth = plan.PayoutDayOfMonth,
IsDefault = plan.IsDefault,
IsActive = plan.IsActive
};
private static PendingPlatformInvoiceDto ToPendingDto(Models.ClientInvoice invoice)
{
var now = DateTime.UtcNow;
return new PendingPlatformInvoiceDto
{
ClientInvoiceId = invoice.ClientInvoiceId,
ClientId = invoice.ClientSubscription.FKClientId,
ClientName = invoice.ClientSubscription.Client?.ClientName,
City = invoice.ClientSubscription.Client?.ClientBusinesses?.FirstOrDefault()?.City,
Amount = invoice.Amount,
PaidAmount = invoice.PaidAmount,
Status = invoice.Status,
ClientInvoiceDate = invoice.ClientInvoiceDate,
DaysOverdue = Math.Max(0, (int)(now - invoice.ClientInvoiceDate).TotalDays)
};
}
}
}
+161
View File
@@ -0,0 +1,161 @@
using Fitness_core_BE.DTOs.Ledger;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
namespace Fitness_core_BE.Services
{
public class ProfitLossService : IProfitLossService
{
private readonly IProfitLossRepository _repository;
private readonly UserRoleHelper _userRoleHelper;
private readonly IUnitOfWork _uow;
public ProfitLossService(
IProfitLossRepository repository,
UserRoleHelper userRoleHelper,
IUnitOfWork uow)
{
_repository = repository;
_userRoleHelper = userRoleHelper;
_uow = uow;
}
public async Task<ProfitLossDto> GetProfitLossAsync(DateTime from, DateTime to, HttpContext httpContext)
{
var clientId = await GetClientIdAsync(httpContext);
var (fromUtc, toUtc) = NormalizeRange(from, to);
var entries = await _repository.GetDerivedEntriesAsync(
clientId, fromUtc, toUtc);
// Manual entries.
var manualEntries = await GetManualEntriesRawAsync(clientId, fromUtc, toUtc);
entries.AddRange(manualEntries.Select(ToDto));
var totalIncome = entries.Where(e => e.EntryType == "Income").Sum(e => e.Amount);
var totalExpense = entries.Where(e => e.EntryType == "Expense").Sum(e => e.Amount);
return new ProfitLossDto
{
From = fromUtc,
To = toUtc,
TotalIncome = totalIncome,
TotalExpense = totalExpense,
NetProfit = totalIncome - totalExpense,
IncomeByCategory = entries.Where(e => e.EntryType == "Income")
.GroupBy(e => e.Category)
.Select(g => new CategoryTotalDto { Category = g.Key, Amount = g.Sum(e => e.Amount) })
.OrderByDescending(c => c.Amount)
.ToList(),
ExpenseByCategory = entries.Where(e => e.EntryType == "Expense")
.GroupBy(e => e.Category)
.Select(g => new CategoryTotalDto { Category = g.Key, Amount = g.Sum(e => e.Amount) })
.OrderByDescending(c => c.Amount)
.ToList(),
Entries = entries.OrderByDescending(e => e.EntryDate).ToList()
};
}
public async Task<List<LedgerEntryDto>> GetManualEntriesAsync(DateTime from, DateTime to, HttpContext httpContext)
{
var clientId = await GetClientIdAsync(httpContext);
var (fromUtc, toUtc) = NormalizeRange(from, to);
var entries = await GetManualEntriesRawAsync(clientId, fromUtc, toUtc);
return entries.Select(ToDto).OrderByDescending(e => e.EntryDate).ToList();
}
public async Task<LedgerEntryDto> AddManualEntryAsync(AddLedgerEntryRequest request, HttpContext httpContext)
{
var clientId = await GetClientIdAsync(httpContext);
if (request.EntryType != "Income" && request.EntryType != "Expense")
throw new ArgumentException("entryType must be 'Income' or 'Expense'.");
if (string.IsNullOrWhiteSpace(request.Category))
throw new ArgumentException("category is required.");
if (request.Amount <= 0)
throw new ArgumentException("amount must be greater than zero.");
await _uow.BeginAsync();
try
{
var entry = new LedgerEntry
{
Id = Guid.NewGuid(),
FKClientId = clientId,
EntryType = request.EntryType,
Category = request.Category,
Description = request.Description,
Amount = request.Amount,
EntryDate = request.EntryDate ?? DateTime.UtcNow,
CreatedAt = DateTime.UtcNow
};
await _repository.AddManualEntryAsync(entry);
await _uow.CommitAsync();
return ToDto(entry);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task DeleteManualEntryAsync(Guid id, HttpContext httpContext)
{
var clientId = await GetClientIdAsync(httpContext);
var entry = await _repository.GetManualEntryByIdAsync(id)
?? throw new NotFoundException("Ledger entry not found.");
if (entry.FKClientId != clientId)
throw new ForbiddenException("This ledger entry does not belong to your gym.");
await _uow.BeginAsync();
try
{
_repository.RemoveManualEntry(entry);
await _uow.CommitAsync();
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
private async Task<List<LedgerEntry>> GetManualEntriesRawAsync(Guid clientId, DateTime fromUtc, DateTime toUtc)
{
return await _repository.GetManualEntriesAsync(clientId, fromUtc, toUtc);
}
private async Task<Guid> GetClientIdAsync(HttpContext httpContext)
{
var userId = DataExtractor.GetUserIdFromContext(httpContext);
return await _userRoleHelper.GetClientId(userId);
}
private static (DateTime From, DateTime To) NormalizeRange(DateTime from, DateTime to)
{
var fromUtc = DateTime.SpecifyKind(from.Date, DateTimeKind.Utc);
// Treat `to` as inclusive of the whole day.
var toUtc = DateTime.SpecifyKind(to.Date.AddDays(1), DateTimeKind.Utc);
return (fromUtc, toUtc);
}
private static LedgerEntryDto ToDto(LedgerEntry entry) => new()
{
Id = entry.Id,
EntryType = entry.EntryType,
Category = entry.Category,
Description = entry.Description,
Amount = entry.Amount,
EntryDate = entry.EntryDate,
IsManual = true
};
}
}
+152
View File
@@ -0,0 +1,152 @@
using System.Net.Http.Json;
using System.Text.Json;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
namespace Fitness_core_BE.Services
{
// Sends real device pushes via Expo's push API and, independently, persists a
// CustomerNotification row so the mobile Notification tab always has a history even if the
// push itself never reached the device (permission denied, no token registered, etc.).
public class PushNotificationService : IPushNotificationService
{
private const string ExpoPushUrl = "https://exp.host/--/api/v2/push/send";
private const int ExpoBatchSize = 100;
private readonly HttpClient _httpClient;
private readonly ICustomerNotificationRepository _repository;
private readonly IUnitOfWork _uow;
public PushNotificationService(IHttpClientFactory httpClientFactory, ICustomerNotificationRepository repository, IUnitOfWork uow)
{
_httpClient = httpClientFactory.CreateClient();
_repository = repository;
_uow = uow;
}
public async Task NotifyCustomerAsync(
Guid customerId,
string title,
string body,
string notificationType,
string? routeName = null,
object? routeParams = null)
{
var notification = BuildNotification(customerId, title, body, notificationType, routeName, routeParams);
await _uow.BeginAsync();
try
{
await _repository.CreateAsync(notification);
await _uow.CommitAsync();
}
catch
{
await _uow.RollbackAsync();
throw;
}
var pushToken = await _repository.GetCustomerPushTokenAsync(customerId);
if (IsExpoPushToken(pushToken))
{
await SendExpoPushBatchAsync(new[] { pushToken! }, title, body, notificationType, routeName, routeParams);
}
}
public async Task NotifyCustomersAsync(
IEnumerable<Guid> customerIds,
string title,
string body,
string notificationType,
string? routeName = null,
object? routeParams = null)
{
var ids = customerIds.Distinct().ToList();
if (ids.Count == 0) return;
var notifications = ids
.Select(id => BuildNotification(id, title, body, notificationType, routeName, routeParams))
.ToList();
await _uow.BeginAsync();
try
{
await _repository.CreateManyAsync(notifications);
await _uow.CommitAsync();
}
catch
{
await _uow.RollbackAsync();
throw;
}
var tokens = await _repository.GetCustomerPushTokensAsync(ids);
var validTokens = tokens.Where(IsExpoPushToken).ToList();
for (var offset = 0; offset < validTokens.Count; offset += ExpoBatchSize)
{
var chunk = validTokens.Skip(offset).Take(ExpoBatchSize).ToList();
await SendExpoPushBatchAsync(chunk, title, body, notificationType, routeName, routeParams);
}
}
private static CustomerNotification BuildNotification(
Guid customerId, string title, string body, string notificationType, string? routeName, object? routeParams)
{
return new CustomerNotification
{
CustomerNotificationId = Guid.NewGuid(),
FKCustomerId = customerId,
Title = title,
Body = body,
NotificationType = notificationType,
RouteName = routeName,
RouteParamsJson = routeParams != null ? JsonSerializer.Serialize(routeParams) : null,
IsRead = false,
CreatedAt = DateTime.UtcNow
};
}
private static bool IsExpoPushToken(string? token) =>
!string.IsNullOrWhiteSpace(token) && token.StartsWith("ExponentPushToken", StringComparison.OrdinalIgnoreCase);
private async Task SendExpoPushBatchAsync(
IReadOnlyCollection<string> tokens, string title, string body, string notificationType, string? routeName, object? routeParams)
{
if (tokens.Count == 0) return;
var messages = tokens.Select(token => new
{
to = token,
title,
body,
sound = "default",
data = new
{
notificationType = routeName != null ? "navigate" : "normal",
routeName,
routeParams,
notificationKind = notificationType
}
});
try
{
var response = await _httpClient.PostAsJsonAsync(ExpoPushUrl, messages);
if (!response.IsSuccessStatusCode)
{
var responseBody = await response.Content.ReadAsStringAsync();
Console.WriteLine($"Expo push send failed ({(int)response.StatusCode}): {responseBody}");
}
}
catch (Exception ex)
{
// Push delivery is best-effort — the notification is already persisted above,
// so a network hiccup here must not fail the caller's larger operation.
Console.WriteLine($"Expo push send threw: {ex.Message}");
}
}
}
}
+233
View File
@@ -0,0 +1,233 @@
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Fitness_core_BE.Models.Receipt;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
namespace Fitness_core_BE.Services
{
public class ReceiptService : IReceiptService
{
private readonly ICustomerInvoiceRepository _customerInvoiceRepository;
private readonly ICustomerSubscriptionRepository _customerSubscriptionRepository;
private readonly IClientRepository _clientRepository;
private readonly IClientInvoiceRepository _clientInvoiceRepository;
private readonly IPaymentRepository _paymentRepository;
private readonly IThirdPartyService _thirdPartyService;
public ReceiptService(
ICustomerInvoiceRepository customerInvoiceRepository,
ICustomerSubscriptionRepository customerSubscriptionRepository,
IClientRepository clientRepository,
IClientInvoiceRepository clientInvoiceRepository,
IPaymentRepository paymentRepository,
IThirdPartyService thirdPartyService)
{
_customerInvoiceRepository = customerInvoiceRepository;
_customerSubscriptionRepository = customerSubscriptionRepository;
_clientRepository = clientRepository;
_clientInvoiceRepository = clientInvoiceRepository;
_paymentRepository = paymentRepository;
_thirdPartyService = thirdPartyService;
}
public async Task<ReceiptModel> BuildForInvoiceAsync(Guid customerInvoiceId, decimal? paymentAmount = null, Guid? requireClientId = null)
{
var invoice = await _customerInvoiceRepository.GetCustomerInvoiceById(customerInvoiceId)
?? throw new NotFoundException("Invoice not found");
if (requireClientId.HasValue && invoice.FKClientId != requireClientId.Value)
throw new UnauthorizedException("This invoice does not belong to your gym.");
var subscription = await _customerSubscriptionRepository.GetCustomerSubscriptionById(invoice.FKCustomerSubscriptionId);
string planName = subscription?.CustomerPlan?.PlanName ?? "Membership";
Guid? customerId = subscription?.FKCustomerId;
Customer? customer = customerId.HasValue ? await _clientRepository.GetCustomerById(customerId.Value) : null;
var client = await _clientRepository.GetClientByIdAsync(invoice.FKClientId);
string gymName = client?.ClientBusinesses?.FirstOrDefault()?.BusinessName
?? client?.ClientName
?? "GYM OS";
decimal paid = await _paymentRepository.GetApprovedPaidAmountAsync(customerInvoiceId);
return new ReceiptModel
{
ClientId = invoice.FKClientId,
ReceiptNo = $"GYMOS-{customerInvoiceId.ToString("N")[..8].ToUpperInvariant()}",
Date = DateTime.UtcNow,
GymName = gymName,
MemberName = customer?.CusName ?? "Member",
MemberPhone = customer?.PrimaryPH,
MemberEmail = customer?.Email,
PlanName = planName,
AmountPaid = paymentAmount ?? paid,
InvoiceAmount = invoice.Amount,
Balance = invoice.Amount - paid,
Currency = "LKR"
};
}
public async Task<ReceiptModel> BuildForClientInvoiceAsync(Guid clientInvoiceId, decimal? paymentAmount = null, Guid? requireClientId = null)
{
var invoice = await _clientInvoiceRepository.GetClientInvoiceById(clientInvoiceId)
?? throw new NotFoundException("Invoice not found");
var clientId = invoice.ClientSubscription?.FKClientId ?? Guid.Empty;
if (requireClientId.HasValue && clientId != requireClientId.Value)
throw new UnauthorizedException("This invoice does not belong to your gym.");
var client = invoice.ClientSubscription?.Client ?? await _clientRepository.GetClientByIdAsync(clientId);
string gymName = client?.ClientBusinesses?.FirstOrDefault()?.BusinessName
?? client?.ClientName
?? "Gym";
var payments = await _paymentRepository.GetPaymentsByClientInvoiceIdAsync(clientInvoiceId);
decimal paid = payments.Where(p => p.IsApproved == true).Sum(p => p.PayAmount);
string itemSummary = invoice.Items?.FirstOrDefault()?.ItemName ?? "Platform fee";
return new ReceiptModel
{
ClientId = clientId,
ReceiptNo = $"HEXA-{clientInvoiceId.ToString("N")[..8].ToUpperInvariant()}",
Date = DateTime.UtcNow,
GymName = "HexaFitness",
MemberName = gymName,
MemberPhone = client?.ClientPhone,
MemberEmail = client?.ClientEmail,
PlanName = itemSummary,
AmountPaid = paymentAmount ?? paid,
InvoiceAmount = invoice.Amount,
Balance = invoice.Amount - paid,
Currency = "LKR"
};
}
public byte[] GeneratePdf(ReceiptModel m)
{
string money(decimal v) => $"{m.Currency} {v:N2}";
var document = QuestPDF.Fluent.Document.Create(container =>
{
container.Page(page =>
{
page.Size(PageSizes.A5);
page.Margin(28);
page.DefaultTextStyle(x => x.FontSize(11).FontColor(Colors.Grey.Darken3));
page.Header().Column(col =>
{
col.Item().Text("GYM OS").FontSize(20).Bold().FontColor(Colors.Blue.Darken2);
col.Item().Text(m.GymName).FontSize(13).SemiBold();
col.Item().PaddingTop(2).Text("Payment Receipt").FontSize(10).FontColor(Colors.Grey.Medium);
col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
});
page.Content().PaddingVertical(12).Column(col =>
{
col.Spacing(6);
col.Item().Row(row =>
{
row.RelativeItem().Text($"Receipt No: {m.ReceiptNo}");
row.RelativeItem().AlignRight().Text($"Date: {m.Date:yyyy-MM-dd}");
});
col.Item().PaddingTop(4).Text("Billed to").FontColor(Colors.Grey.Medium).FontSize(9);
col.Item().Text(m.MemberName).SemiBold();
if (!string.IsNullOrWhiteSpace(m.MemberPhone))
col.Item().Text(m.MemberPhone!).FontSize(10);
if (!string.IsNullOrWhiteSpace(m.MemberEmail))
col.Item().Text(m.MemberEmail!).FontSize(10);
col.Item().PaddingTop(10).Table(table =>
{
table.ColumnsDefinition(c =>
{
c.RelativeColumn(3);
c.RelativeColumn(2);
});
table.Cell().Element(HeaderCell).Text("Description");
table.Cell().Element(HeaderCell).AlignRight().Text("Amount");
table.Cell().Element(BodyCell).Text(m.PlanName);
table.Cell().Element(BodyCell).AlignRight().Text(money(m.AmountPaid));
static IContainer HeaderCell(IContainer c) =>
c.Background(Colors.Grey.Lighten3).PaddingVertical(5).PaddingHorizontal(8).DefaultTextStyle(t => t.SemiBold());
static IContainer BodyCell(IContainer c) =>
c.BorderBottom(1).BorderColor(Colors.Grey.Lighten2).PaddingVertical(5).PaddingHorizontal(8);
});
col.Item().PaddingTop(10).AlignRight().Column(totals =>
{
totals.Item().Text($"Amount paid: {money(m.AmountPaid)}").SemiBold();
totals.Item().Text($"Invoice total: {money(m.InvoiceAmount)}").FontSize(10).FontColor(Colors.Grey.Medium);
totals.Item().Text($"Balance due: {money(m.Balance)}").FontSize(10)
.FontColor(m.Balance > 0 ? Colors.Red.Medium : Colors.Green.Darken1);
});
});
page.Footer().AlignCenter().Column(col =>
{
col.Item().PaddingTop(6).LineHorizontal(1).LineColor(Colors.Grey.Lighten2);
col.Item().PaddingTop(6).Text("Thank you for your payment.").FontSize(10);
col.Item().Text($"{m.GymName} · powered by GYM OS").FontSize(8).FontColor(Colors.Grey.Medium);
});
});
});
return document.GeneratePdf();
}
public async Task SendReceiptAsync(ReceiptModel m)
{
byte[] pdf;
try
{
pdf = GeneratePdf(m);
}
catch (Exception ex)
{
Console.WriteLine($"Receipt PDF generation failed (non-critical): {ex.Message}");
return;
}
if (!string.IsNullOrWhiteSpace(m.MemberEmail))
{
try
{
string html =
$"<p>Hi {m.MemberName},</p>" +
$"<p>Thank you for your payment of <b>{m.Currency} {m.AmountPaid:N2}</b> for <b>{m.PlanName}</b> at {m.GymName}.</p>" +
(m.Balance > 0 ? $"<p>Outstanding balance: {m.Currency} {m.Balance:N2}.</p>" : "") +
$"<p>Your receipt ({m.ReceiptNo}) is attached.</p>" +
$"<p>— {m.GymName}<br/><small>powered by GYM OS</small></p>";
await _thirdPartyService.SendEmailAsync(m.MemberEmail!, $"Receipt {m.ReceiptNo} — {m.GymName}", html, pdf, $"{m.ReceiptNo}.pdf");
}
catch (Exception ex)
{
Console.WriteLine($"Receipt email failed (non-critical): {ex.Message}");
}
}
if (!string.IsNullOrWhiteSpace(m.MemberPhone))
{
try
{
string sms = $"You paid {m.Currency} {m.AmountPaid:N2} for {m.PlanName} at {m.GymName}. Thank you! - GYM OS";
await _thirdPartyService.SendSmsAsync(m.MemberPhone!, null, sms);
}
catch (Exception ex)
{
Console.WriteLine($"Receipt SMS failed (non-critical): {ex.Message}");
}
}
}
}
}
+419
View File
@@ -0,0 +1,419 @@
using Fitness_core_BE.DTOs.Auth;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.DTOs.Staff;
using System.Text.Json;
namespace Fitness_core_BE.Services.Staff
{
public class StaffService : IStaffService
{
private readonly IStaffRepository _staffRepository;
private readonly IUnitOfWork _uow;
private readonly UserRoleHelper _userRoleHelper;
private readonly IAuthService _authService;
private readonly IAccessDeviceService _accessDeviceService;
private readonly IGymRoleRepository _gymRoleRepository;
public StaffService(IStaffRepository staffRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper, IAuthService authService, IAccessDeviceService accessDeviceService, IGymRoleRepository gymRoleRepository)
{
_staffRepository = staffRepository;
_uow = uow;
_userRoleHelper = userRoleHelper;
_authService = authService;
_accessDeviceService = accessDeviceService;
_gymRoleRepository = gymRoleRepository;
}
public async Task<object> GetAllStaff(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
bool includeInactive = data != null &&
data.ContainsKey("includeInactive") &&
data["includeInactive"].GetBoolean();
var staffList = await _staffRepository.GetAllStaff(includeInactive);
return await MapStaffListAsync(staffList);
}
public async Task<object> GetStaffById(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("staffId"))
{
throw new KeyNotFoundException("staffId is required");
}
var staffId = Guid.Parse(data["staffId"].GetString()!);
var staff = await _staffRepository.GetStaffById(staffId);
if (staff == null)
{
throw new KeyNotFoundException("Staff not found");
}
var callerClientId = await _userRoleHelper.GetClientId(
DataExtractor.GetUserIdFromContext(httpContext));
if (staff.FKClientId != callerClientId)
throw new ForbiddenException("This staff member does not belong to your gym.");
return await MapStaffAsync(staff);
}
public async Task<object> GetStaffByClientId(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("clientId"))
{
throw new KeyNotFoundException("clientId is required");
}
var clientId = Guid.Parse(data["clientId"].GetString()!);
var callerClientId = await _userRoleHelper.GetClientId(
DataExtractor.GetUserIdFromContext(httpContext));
if (clientId != callerClientId)
throw new ForbiddenException("You cannot view another gym's staff.");
bool includeInactive = data.ContainsKey("includeInactive") &&
data["includeInactive"].GetBoolean();
var staffList = await _staffRepository.GetStaffByClientId(clientId, includeInactive);
return await MapStaffListAsync(staffList);
}
public async Task<object> CreateStaff(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
if (!data!.ContainsKey("userName"))
{
throw new KeyNotFoundException("userName is required");
}
if (!data.ContainsKey("email"))
{
throw new KeyNotFoundException("email is required");
}
if (!data.ContainsKey("phoneNumber"))
{
throw new KeyNotFoundException("phoneNumber is required");
}
var user = new User
{
UserName = data["userName"].GetString()!,
Email = data["email"].GetString()!,
PhoneNumber = data["phoneNumber"].GetString()!
};
// Optional -- must belong to this same client, same check AssignStaffRole
// already does for the post-creation assignment path. Left unset, the new
// staff member has no dashboard access until a role is assigned later.
Guid? gymRoleId = null;
if (data.ContainsKey("gymRoleId") && data["gymRoleId"].ValueKind != JsonValueKind.Null
&& Guid.TryParse(data["gymRoleId"].GetString(), out var parsedRoleId))
{
var role = await _gymRoleRepository.GetRoleById(parsedRoleId);
if (role == null || role.FKClientId != clientId)
throw new BadRequestException("That role doesn't belong to your gym.");
gymRoleId = parsedRoleId;
}
var staff = new Models.Staff
{
FKUserId = userId,
FKClientId = clientId,
StaffPosition = data.ContainsKey("staffPosition") ? data["staffPosition"].GetString() : null,
Department = data.ContainsKey("department") ? data["department"].GetString() : null,
FKGymRoleId = gymRoleId,
};
var result = await _staffRepository.CreateStaff(staff, user);
await _uow.SaveChangesAsync();
await _accessDeviceService.EnsureAccessProfileAsync(
clientId, user.UserId, "Staff", user.UserName ?? string.Empty);
AuthResponse authResponse;
try
{
var authPayload = DeserializePayload.Deserialize(payload);
Guid roleId = Guid.Parse("08de6a11-9e9f-4401-8a10-6854567b41ed");
Guid userTypeId = Guid.Parse("00000000-0000-0000-0000-000000000003");
authPayload["userId"] = JsonSerializer.SerializeToElement(user.UserId);
authPayload["roleId"] = JsonSerializer.SerializeToElement(roleId);
authPayload["userTypeId"] = JsonSerializer.SerializeToElement(userTypeId);
// The client portal's request uses "phoneNumber", but Core-Hex-Auth's
// RegisterUser only reads "mobileNumber" — without this the auth User row
// was silently created with a null phone number.
authPayload["mobileNumber"] = JsonSerializer.SerializeToElement(user.PhoneNumber);
authResponse = await _authService.RegisterUser(authPayload, httpContext);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Auth registration failed: {ex.Message}");
}
if (authResponse.StatusCode != 200 || !authResponse.Success)
{
throw new InvalidOperationException($"Auth registration failed: {authResponse.Message}");
}
await _uow.CommitAsync();
// Newly created staff have no password of their own yet — email them a
// "set your password" link (reuses the auth service's reset-link flow).
// Non-critical: a failure here must not roll back the created staff member.
try
{
await _authService.ForgotPassword(
new { identifier = user.Email, useResetLink = true, welcome = true },
httpContext);
}
catch (Exception ex)
{
Console.WriteLine($"Staff set-password email failed (non-critical): {ex.Message}");
}
return new
{
staff = await MapStaffAsync(result, user),
auth = authResponse
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> UpdateStaff(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("staffId"))
{
throw new KeyNotFoundException("staffId is required");
}
var staffId = Guid.Parse(data["staffId"].GetString()!);
// Get existing staff first
var existingStaff = await _staffRepository.GetStaffById(staffId);
if (existingStaff == null)
{
throw new KeyNotFoundException("Staff not found");
}
var callerClientId = await _userRoleHelper.GetClientId(
DataExtractor.GetUserIdFromContext(httpContext));
if (existingStaff.FKClientId != callerClientId)
throw new ForbiddenException("This staff member does not belong to your gym.");
// Create update object with only provided fields
var updateStaff = new Models.Staff
{
StaffId = staffId,
StaffPosition = data.ContainsKey("staffPosition") ? data["staffPosition"].GetString() : null,
Department = data.ContainsKey("department") ? data["department"].GetString() : null,
IsActive = data.ContainsKey("isActive") ? data["isActive"].GetBoolean() : existingStaff.IsActive,
User = new User
{
UserName = data.ContainsKey("userName") ? data["userName"].GetString() : null,
Email = data.ContainsKey("email") ? data["email"].GetString() : null,
PhoneNumber = data.ContainsKey("phoneNumber") ? data["phoneNumber"].GetString() : null
}
};
var result = await _staffRepository.UpdateStaff(updateStaff);
await _uow.CommitAsync();
return await MapStaffAsync(result);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> DeleteStaff(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
if (!data!.ContainsKey("staffId"))
{
throw new KeyNotFoundException("staffId is required");
}
var staffId = Guid.Parse(data["staffId"].GetString()!);
await EnsureStaffBelongsToCallerAsync(staffId, httpContext);
// Check if hard delete is requested (default is soft delete)
bool softDelete = !data.ContainsKey("hardDelete") || !data["hardDelete"].GetBoolean();
var result = await _staffRepository.DeleteStaff(staffId, softDelete);
if (!result)
{
throw new KeyNotFoundException("Staff not found");
}
await _uow.CommitAsync();
return new
{
success = true,
message = softDelete
? "Staff deactivated successfully"
: "Staff deleted successfully",
softDelete = softDelete
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> GetPayRate(Guid staffId, HttpContext httpContext)
{
await EnsureStaffBelongsToCallerAsync(staffId, httpContext);
var rate = await _staffRepository.GetActivePayRateAsync(staffId);
return rate == null
? null!
: new StaffPayRateDto { Id = rate.Id, RateType = rate.RateType, Amount = rate.Amount, EffectiveFrom = rate.EffectiveFrom };
}
public async Task<object> UpsertPayRate(Guid staffId, string rateType, decimal amount, HttpContext httpContext)
{
await EnsureStaffBelongsToCallerAsync(staffId, httpContext);
if (rateType != "Monthly" && rateType != "Hourly")
throw new BadRequestException("rateType must be 'Monthly' or 'Hourly'.");
if (amount <= 0)
throw new BadRequestException("amount must be greater than zero.");
var currentRate = await _staffRepository.GetActivePayRateAsync(staffId);
if (currentRate != null)
currentRate.IsActive = false;
var rate = await _staffRepository.AddPayRateAsync(new Models.StaffPayRate
{
Id = Guid.NewGuid(),
FKStaffId = staffId,
RateType = rateType,
Amount = amount,
EffectiveFrom = DateTime.UtcNow,
IsActive = true
});
await _uow.SaveChangesAsync();
return new StaffPayRateDto { Id = rate.Id, RateType = rate.RateType, Amount = rate.Amount, EffectiveFrom = rate.EffectiveFrom };
}
public async Task<object> GetPayments(Guid staffId, HttpContext httpContext)
{
await EnsureStaffBelongsToCallerAsync(staffId, httpContext);
var payments = await _staffRepository.GetPaymentsAsync(staffId);
return payments.Select(p => new StaffPaymentDto { Id = p.Id, Amount = p.Amount, Note = p.Note, PaidAt = p.PaidAt }).ToList();
}
public async Task<object> RecordPayment(Guid staffId, decimal amount, string? note, HttpContext httpContext)
{
await EnsureStaffBelongsToCallerAsync(staffId, httpContext);
if (amount <= 0)
throw new BadRequestException("amount must be greater than zero.");
var payment = new Models.StaffPayment
{
Id = Guid.NewGuid(),
FKStaffId = staffId,
Amount = amount,
Note = note,
PaidAt = DateTime.UtcNow,
FKPaidByUserId = DataExtractor.GetUserIdFromContext(httpContext)
};
await _staffRepository.AddPaymentAsync(payment);
await _uow.SaveChangesAsync();
return new StaffPaymentDto { Id = payment.Id, Amount = payment.Amount, Note = payment.Note, PaidAt = payment.PaidAt };
}
private async Task EnsureStaffBelongsToCallerAsync(Guid staffId, HttpContext httpContext)
{
var userId = DataExtractor.GetUserIdFromContext(httpContext);
var clientId = await _userRoleHelper.GetClientId(userId);
var staff = await _staffRepository.GetStaffById(staffId)
?? throw new KeyNotFoundException("Staff not found");
if (staff.FKClientId != clientId)
throw new ForbiddenException("This staff member does not belong to your gym.");
}
private async Task<StaffDto> MapStaffAsync(Models.Staff staff, User? userOverride = null)
{
var user = userOverride ?? staff.User;
var access = await _accessDeviceService.GetAccessStatusAsync(
staff.FKClientId, staff.FKUserId);
return new StaffDto
{
StaffId = staff.StaffId,
UserId = staff.FKUserId,
UserName = user?.UserName,
Email = user?.Email,
PhoneNumber = user?.PhoneNumber,
ClientId = staff.FKClientId,
ClientName = staff.Client?.ClientName,
GymRoleId = staff.FKGymRoleId,
GymRoleName = staff.GymRole?.Name,
StaffPosition = staff.StaffPosition,
Department = staff.Department,
HireDate = staff.HireDate,
IsActive = staff.IsActive,
CreatedAt = staff.CreatedAt,
AccessProfileId = access?.AccessProfileId,
AccessNumber = access?.AccessNumber,
AccessProvisionStatus = access?.ProvisionStatus ?? "NotConfigured",
ActualAccessState = access?.ActualAccessState ?? "Unknown",
DesiredAccessState = access?.DesiredAccessState ?? "Enabled",
FingerprintCount = access?.FingerprintCount ?? 0,
AccessLastError = access?.LastError
};
}
private async Task<List<StaffDto>> MapStaffListAsync(IEnumerable<Models.Staff> staff)
{
var result = new List<StaffDto>();
foreach (var member in staff)
result.Add(await MapStaffAsync(member));
return result;
}
}
}
@@ -0,0 +1,256 @@
using Fitness_core_BE.Domain;
using Fitness_core_BE.Exceptions;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using Microsoft.AspNetCore.Http;
namespace Fitness_core_BE.Services
{
// Staff-facing approval workflow for the unapproved (IsActive=false) invoices the daily
// SubscriptionRenewalJob sweep creates -- approve/change-plan/reject are the only things
// that ever advance CustomerSubscription.RenewalDate or replace a subscription's plan; the
// daily sweep itself only ever creates unapproved invoices.
public class SubscriptionRenewalApprovalService : ISubscriptionRenewalApprovalService
{
private readonly ICustomerInvoiceRepository _invoiceRepository;
private readonly ICustomerSubscriptionRepository _subscriptionRepository;
private readonly IClientRepository _clientRepository;
private readonly IUnitOfWork _uow;
private readonly UserRoleHelper _userRoleHelper;
private readonly ILogger<SubscriptionRenewalApprovalService> _logger;
public SubscriptionRenewalApprovalService(
ICustomerInvoiceRepository invoiceRepository,
ICustomerSubscriptionRepository subscriptionRepository,
IClientRepository clientRepository,
IUnitOfWork uow,
UserRoleHelper userRoleHelper,
ILogger<SubscriptionRenewalApprovalService> logger)
{
_invoiceRepository = invoiceRepository;
_subscriptionRepository = subscriptionRepository;
_clientRepository = clientRepository;
_uow = uow;
_userRoleHelper = userRoleHelper;
_logger = logger;
}
public async Task<object> GetUnapprovedInvoicesAsync(HttpContext httpContext)
{
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var invoices = await _invoiceRepository.GetUnapprovedInvoicesByClientIdAsync(clientId);
// How much of `amount` is this cycle's own charge vs. debt carried over from a prior
// Pending/Partial invoice -- the approvals list shows the total already, but staff
// reviewing a renewal need to see the breakdown before approving it.
var carriedForward = await _invoiceRepository.GetCarriedForwardAmountsAsync(
invoices.Select(i => i.CustomerInvoiceId).ToList());
return invoices.Select(i =>
{
carriedForward.TryGetValue(i.CustomerInvoiceId, out var carriedForwardAmount);
return new
{
customerInvoiceId = i.CustomerInvoiceId,
fkCustomerSubscriptionId = i.FKCustomerSubscriptionId,
amount = i.Amount,
carriedForwardAmount,
status = i.Status,
customerInvoiceDate = i.CustomerInvoiceDate,
customerName = i.CustomerSubscription?.TenantCustomerLink?.DisplayName,
planName = i.CustomerSubscription?.CustomerPlan?.PlanName,
planPrice = i.CustomerSubscription?.CustomerPlan?.PlanPrice,
};
}).ToList();
}
public async Task<object> ApproveAsync(Guid customerInvoiceId, HttpContext httpContext)
{
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
await _uow.BeginAsync();
try
{
var invoice = await LoadUnapprovedInvoiceForClientAsync(customerInvoiceId, clientId);
var subscription = invoice.CustomerSubscription;
var plan = subscription.CustomerPlan
?? throw new BadRequestException("This subscription's plan could not be found.");
var now = DateTime.UtcNow;
// Installments are keyed by subscription, not by invoice/cycle -- the prior
// cycle's rows must be removed before this cycle's schedule is added (see
// DeactivatePriorInstallmentsAsync for why this is a hard delete).
await _subscriptionRepository.DeactivatePriorInstallmentsAsync(subscription.SubscriptionId);
var installments = CustomerSubscriptionFactory.BuildInstallmentSchedule(
subscription, plan, now, anchorDate: now);
await _subscriptionRepository.AddInstallmentsAsync(installments);
invoice.IsActive = true;
// Advance from the OLD RenewalDate, not from "now" -- a late approval shouldn't
// shift the cadence of future cycles.
var newRenewalDate = (subscription.RenewalDate ?? now).AddDays(plan.DurationInDays);
await _subscriptionRepository.AdvanceRenewalDateAsync(subscription.SubscriptionId, newRenewalDate);
// Session-count cycle reset (SessionsPerCycle plans only -- unlimited-access
// plans have nothing to reset). Whatever was left unused this ending cycle either
// rolls into the new one (GracePeriod, until RolloverGraceDays elapses) or is
// forfeited (NoRollover) -- see the fields' own comments on CustomerPlan/
// CustomerSubscription for the full reasoning.
if (plan.SessionsPerCycle.HasValue)
{
var allowanceThisCycle = plan.SessionsPerCycle.Value + subscription.SessionsCarriedOver;
var unusedThisCycle = Math.Max(0, allowanceThisCycle - subscription.SessionsUsedThisCycle);
if (plan.SessionRolloverPolicy == "GracePeriod" && unusedThisCycle > 0)
{
subscription.SessionsCarriedOver = unusedThisCycle;
subscription.SessionsCarriedOverExpiresAt = now.AddDays(plan.RolloverGraceDays);
}
else
{
subscription.SessionsCarriedOver = 0;
subscription.SessionsCarriedOverExpiresAt = null;
}
subscription.SessionsUsedThisCycle = 0;
}
await _uow.SaveChangesAsync();
await _uow.CommitAsync();
_logger.LogInformation(
"SubscriptionRenewalApproval: invoice {InvoiceId} approved for subscription {SubscriptionId}; RenewalDate advanced to {RenewalDate:u}.",
customerInvoiceId, subscription.SubscriptionId, newRenewalDate);
return new
{
success = true,
message = "Renewal approved",
customerInvoiceId,
subscriptionId = subscription.SubscriptionId,
renewalDate = newRenewalDate
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> ChangePlanAsync(Guid customerInvoiceId, Guid newCustomerPlanId, HttpContext httpContext)
{
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
await _uow.BeginAsync();
try
{
var invoice = await LoadUnapprovedInvoiceForClientAsync(customerInvoiceId, clientId);
var oldSubscription = invoice.CustomerSubscription;
var newPlan = await _clientRepository.GetCustomerPlanEntityForClientAsync(newCustomerPlanId, clientId)
?? throw new NotFoundException("Membership plan not found.");
if (newPlan.PlanScope != "Individual")
throw new BadRequestException("Change Membership only supports Individual-scope plans.");
var oldMembership = await _clientRepository.GetActiveMembershipForCustomerAsync(oldSubscription.FKCustomerId);
// Sibling namespace Fitness_core_BE.Services.CustomerSubscription
// (CustomerSubscriptionService.cs) outranks a bare `CustomerSubscription` here.
var newSubscription = new Fitness_core_BE.Models.CustomerSubscription
{
FKCustomerPlanId = newCustomerPlanId,
FKCustomerId = oldSubscription.FKCustomerId,
OnboardDate = DateTime.UtcNow,
IsActive = true
};
var initialSubItem = new CustomerSubItem
{
SubItemName = "Membership change (renewal approval)",
IsActive = true
};
var aggregate = CustomerSubscriptionFactory.Create(newSubscription, newPlan, initialSubItem);
await _clientRepository.ReplaceCustomerSubscriptionAsync(oldMembership, aggregate);
// RepointAndRecalculateInvoiceAsync re-queries the new subscription by id, so it
// must be persisted first.
await _uow.SaveChangesAsync();
await _invoiceRepository.RepointAndRecalculateInvoiceAsync(customerInvoiceId, newSubscription.SubscriptionId);
await _uow.SaveChangesAsync();
await _uow.CommitAsync();
_logger.LogInformation(
"SubscriptionRenewalApproval: invoice {InvoiceId} approved via plan change -- subscription {OldSubscriptionId} replaced by {NewSubscriptionId} on plan {NewPlanId}.",
customerInvoiceId, oldSubscription.SubscriptionId, newSubscription.SubscriptionId, newCustomerPlanId);
return new
{
success = true,
message = "Plan changed and renewal approved",
customerInvoiceId,
subscriptionId = newSubscription.SubscriptionId
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> RejectAsync(Guid customerInvoiceId, HttpContext httpContext)
{
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
await _uow.BeginAsync();
try
{
var invoice = await LoadUnapprovedInvoiceForClientAsync(customerInvoiceId, clientId);
// Already IsActive=false (that's what put it in the approval queue) -- Status
// also needs a terminal value, otherwise it still reads as an outstanding invoice
// everywhere that isn't IsActive-filtered (e.g. GetCustomerInvoicesByClientId).
invoice.Status = "Cancelled";
invoice.IsActive = false;
await _clientRepository.DeactivateSubscriptionAsync(invoice.FKCustomerSubscriptionId);
await _uow.SaveChangesAsync();
await _uow.CommitAsync();
_logger.LogInformation(
"SubscriptionRenewalApproval: invoice {InvoiceId} rejected; subscription {SubscriptionId} deactivated.",
customerInvoiceId, invoice.FKCustomerSubscriptionId);
return new { success = true, message = "Renewal rejected", customerInvoiceId };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
// Sibling namespace Fitness_core_BE.Services.CustomerInvoice (CustomerInvoiceService.cs)
// outranks a bare `CustomerInvoice` here, so the model type needs its full name.
private async Task<Fitness_core_BE.Models.CustomerInvoice> LoadUnapprovedInvoiceForClientAsync(Guid customerInvoiceId, Guid clientId)
{
var invoice = await _invoiceRepository.GetCustomerInvoiceById(customerInvoiceId)
?? throw new NotFoundException("Invoice not found.");
if (invoice.FKClientId != clientId)
throw new ForbiddenException("This invoice does not belong to your gym.");
if (invoice.IsActive)
throw new BadRequestException("This invoice has already been approved or is not awaiting approval.");
return invoice;
}
}
}
+154
View File
@@ -0,0 +1,154 @@
using Fitness_core_BE.Infra.Tenancy;
namespace Fitness_core_BE.Services
{
/// <summary>
/// Daily sweep (01:00 Sri Lanka time) that checks every provisioned tenant schema for
/// CustomerSubscriptions due for renewal (RenewalDate on or before today) and creates an
/// unapproved (IsActive=false) renewal invoice for each -- see SubscriptionRenewalRunner for
/// the actual per-subscription algorithm (Paid vs. Pending/Partial carry-forward). Nothing on
/// the subscription itself changes here; approving/changing-plan/rejecting the invoice
/// (SubscriptionRenewalApprovalService) is what advances RenewalDate or replaces the plan.
/// Own, independent loop from OverdueLockJob's 6-hour cadence -- deliberately not merged with it.
///
/// Same as OverdueLockJob, this is a background service with no HttpContext, so it can't
/// rely on TenantConnectionResolver's per-request tenant resolution -- it loops every
/// provisioned tenant schema directly via TenantSchemaIterator instead.
/// </summary>
public class SubscriptionRenewalJob : BackgroundService
{
private const string JobName = "SubscriptionRenewalJob";
// 01:00 Sri Lanka local time (UTC+5:30, no DST observed since 2006) -- the gyms this
// runs for are all Sri Lanka-based, so the trigger point is anchored to local time
// rather than UTC. The actual RenewalDate/candidate comparisons inside
// SubscriptionRenewalRunner still use DateTime.UtcNow, unaffected by this -- only the
// wake-up point shifts.
private static readonly TimeSpan RunAtLocal = TimeSpan.FromHours(1);
private static readonly TimeZoneInfo SriLankaTimeZone = ResolveSriLankaTimeZone();
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<SubscriptionRenewalJob> _logger;
public SubscriptionRenewalJob(IServiceScopeFactory scopeFactory, ILogger<SubscriptionRenewalJob> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var delay = ComputeDelayUntilNextRun(DateTime.UtcNow);
_logger.LogInformation(
"SubscriptionRenewalJob: next run scheduled at {NextRunUtc:u} UTC (in {Delay}).",
DateTime.UtcNow.Add(delay), delay);
try
{
await Task.Delay(delay, stoppingToken);
}
catch (TaskCanceledException)
{
// Shutting down.
break;
}
try
{
_logger.LogInformation("SubscriptionRenewalJob: daily run starting.");
await RunOnceAsync(stoppingToken);
_logger.LogInformation("SubscriptionRenewalJob: daily run finished.");
}
catch (Exception ex)
{
_logger.LogError(ex, "SubscriptionRenewalJob run failed.");
}
}
}
private static TimeZoneInfo ResolveSriLankaTimeZone()
{
try
{
return TimeZoneInfo.FindSystemTimeZoneById("Sri Lanka Standard Time"); // Windows ID
}
catch (TimeZoneNotFoundException)
{
try
{
return TimeZoneInfo.FindSystemTimeZoneById("Asia/Colombo"); // IANA ID (Linux/macOS)
}
catch (TimeZoneNotFoundException)
{
// Neither ID resolved (e.g. no tzdata on the host) -- Sri Lanka has kept a
// fixed UTC+5:30 offset with no DST since 2006, so a custom fixed-offset
// zone is a safe fallback rather than failing the whole job.
return TimeZoneInfo.CreateCustomTimeZone(
"Sri Lanka (fixed +05:30)", TimeSpan.FromMinutes(330), "Sri Lanka Time", "Sri Lanka Time");
}
}
}
// Delay-then-run, not run-then-delay: a fixed daily time trigger shouldn't fire an extra
// run just because the app happened to restart. Internal + static so it's unit-testable
// without spinning up the whole hosted service.
internal static TimeSpan ComputeDelayUntilNextRun(DateTime nowUtc)
{
var nowLocal = TimeZoneInfo.ConvertTimeFromUtc(nowUtc, SriLankaTimeZone);
var todayRunLocal = nowLocal.Date + RunAtLocal;
var nextRunLocal = nowLocal < todayRunLocal ? todayRunLocal : todayRunLocal.AddDays(1);
var nextRunUtc = TimeZoneInfo.ConvertTimeToUtc(
DateTime.SpecifyKind(nextRunLocal, DateTimeKind.Unspecified), SriLankaTimeZone);
return nextRunUtc - nowUtc;
}
private async Task RunOnceAsync(CancellationToken ct)
{
using var scope = _scopeFactory.CreateScope();
var iterator = scope.ServiceProvider.GetRequiredService<TenantSchemaIterator>();
var runner = scope.ServiceProvider.GetRequiredService<SubscriptionRenewalRunner>();
var history = scope.ServiceProvider.GetRequiredService<IJobRunHistory>();
var now = DateTime.UtcNow;
await foreach (var (clientId, schemaName) in iterator.GetProvisionedRegistrationsAsync(ct))
{
// Declared outside the try so the catch block can still use schemaCtx.Central to
// record the failure -- central is one fixed database regardless of tenant, so
// it's safe to reuse (or lazily open) here even though the schema-specific work failed.
TenantSchemaContext? schemaCtx = null;
try
{
schemaCtx = iterator.OpenSchema(clientId, schemaName);
var result = await runner.RunForSchemaAsync(schemaCtx, now, ct);
await history.RecordAsync(schemaCtx.Central, new JobRunRecord(
JobName, clientId, now,
result.Failed > 0 ? "PartialFailure" : "Success",
result.Renewed,
result.Failed > 0 ? string.Join("; ", result.Notes) : null), ct);
if (result.Renewed > 0 || result.Failed > 0)
{
_logger.LogInformation(
"SubscriptionRenewalJob: client {ClientId} ({Schema}) -- {Candidates} candidate(s), {Renewed} renewed, {Failed} failed.",
clientId, schemaName, result.Candidates, result.Renewed, result.Failed);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "SubscriptionRenewalJob failed for client {ClientId} schema {Schema}.", clientId, schemaName);
schemaCtx ??= iterator.OpenSchema(clientId, schemaName);
await history.RecordAsync(schemaCtx.Central, new JobRunRecord(JobName, clientId, now, "Failed", 0, ex.Message), ct);
}
finally
{
if (schemaCtx != null)
await schemaCtx.DisposeAsync();
}
}
}
}
}
+157
View File
@@ -0,0 +1,157 @@
using Fitness_core_BE.Infra.Tenancy;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Repos;
using Microsoft.EntityFrameworkCore;
namespace Fitness_core_BE.Services
{
public sealed record SubscriptionRenewalRunResult(
Guid ClientId, int Candidates, int Renewed, int Failed, IReadOnlyList<string> Notes);
// The per-schema renewal algorithm, extracted from SubscriptionRenewalJob's daily loop so
// the admin "trigger now" endpoint can call the exact same code path instead of duplicating it.
//
// Renewal-approval model: the daily sweep never touches the CustomerSubscription itself
// (IsActive/RenewalDate/members/installments) -- it only computes whether a new invoice is
// due and creates it as unapproved (IsActive=false). Advancing RenewalDate, and everything
// else that used to happen automatically (deactivate old subscription, create a new one),
// now only happens when staff approve/change-plan/reject via SubscriptionRenewalApprovalService.
public class SubscriptionRenewalRunner(ILogger<SubscriptionRenewalRunner> logger)
{
private readonly ILogger<SubscriptionRenewalRunner> _logger = logger;
public async Task<SubscriptionRenewalRunResult> RunForSchemaAsync(
TenantSchemaContext schemaCtx, DateTime now, CancellationToken ct = default)
{
var tenant = schemaCtx.Tenant;
var central = schemaCtx.Central;
var today = now.Date;
// DurationMode == "Rolling" is a hard requirement, not a preference: FixedRange
// plans are fixed-date, non-renewing by design. RenewalDate (not ExpiryDate) drives
// eligibility -- ExpiryDate is frozen at creation and never advances. The "no
// already-pending invoice" exclusion is what makes this idempotent: once a renewal
// invoice exists unapproved, the subscription is skipped on every subsequent run
// until an approval action (approve/change-plan/reject) resolves it.
var candidates = await tenant.CustomerSubscriptions
.Include(s => s.CustomerPlan)
.Where(s => s.IsActive
&& s.RenewalDate != null && s.RenewalDate < today.AddDays(1)
&& s.CustomerPlan.DurationMode == "Rolling"
&& s.CustomerPlan.IsActive
&& !tenant.CustomerInvoices.Any(i =>
i.FKCustomerSubscriptionId == s.SubscriptionId
&& i.IsActive == false
&& i.Status != "Cancelled"))
.ToListAsync(ct);
_logger.LogInformation(
"SubscriptionRenewalJob: client {ClientId} -- {Candidates} subscription(s) due for renewal.",
schemaCtx.ClientId, candidates.Count);
var notes = new List<string>();
int renewed = 0, failed = 0;
var uow = new EFUnitOfWork(central, tenant);
var subscriptionRepo = new CustomerSubscriptionRepository(tenant, central);
var invoiceRepo = new CustomerInvoiceRepository(tenant);
foreach (var oldSubscription in candidates)
{
try
{
await RenewOneAsync(oldSubscription, tenant, uow, subscriptionRepo, invoiceRepo, _logger, ct);
renewed++;
}
catch (Exception ex)
{
await uow.RollbackAsync(ct);
// RollbackAsync only rolls back the DB transaction -- it does not clear EF's
// change tracker. tenant/central are reused across every candidate in this
// loop, so a failed candidate's tracked-but-unsaved mutations (e.g. the
// "RolledOver" status flip) would otherwise still be flushed by the *next*
// candidate's SaveChanges/CommitAsync.
tenant.ChangeTracker.Clear();
central.ChangeTracker.Clear();
failed++;
var note = $"Renewal failed for subscription {oldSubscription.SubscriptionId} (customer {oldSubscription.FKCustomerId}): {ex.Message}";
notes.Add(note);
_logger.LogError(ex, "SubscriptionRenewalJob: {Note}", note);
}
}
_logger.LogInformation(
"SubscriptionRenewalJob: client {ClientId} done -- {Renewed} renewal invoice(s) created, {Failed} failed.",
schemaCtx.ClientId, renewed, failed);
return new SubscriptionRenewalRunResult(schemaCtx.ClientId, candidates.Count, renewed, failed, notes);
}
private static async Task RenewOneAsync(
Fitness_core_BE.Models.CustomerSubscription oldSubscription,
TenantDbContext tenant,
EFUnitOfWork uow,
CustomerSubscriptionRepository subscriptionRepo,
CustomerInvoiceRepository invoiceRepo,
ILogger logger,
CancellationToken ct)
{
await uow.BeginAsync(ct);
// By construction this is still the real, currently-collectible invoice: the
// candidate query above already excludes subscriptions with a pending-approval
// invoice, and nothing else sets CustomerInvoice.IsActive=false.
var oldInvoice = await subscriptionRepo.GetLatestInvoiceAsync(oldSubscription.SubscriptionId, ct);
if (oldInvoice == null)
{
throw new InvalidOperationException(
$"Subscription {oldSubscription.SubscriptionId} has no invoice to renew from.");
}
decimal carriedOverBalance;
switch (oldInvoice.Status)
{
case "Paid":
carriedOverBalance = 0m;
logger.LogInformation(
"SubscriptionRenewalJob: subscription {SubscriptionId} -- prior invoice {InvoiceId} was Paid, no balance carried forward.",
oldSubscription.SubscriptionId, oldInvoice.CustomerInvoiceId);
break;
case "Pending":
case "Partial":
var approvedPaid = await subscriptionRepo.GetApprovedPaymentTotalAsync(oldInvoice.CustomerInvoiceId, ct);
carriedOverBalance = Math.Max(0m, oldInvoice.Amount - approvedPaid);
// GetLatestInvoiceAsync reads AsNoTracking -- re-fetch tracked to mutate it.
var trackedOldInvoice = await tenant.CustomerInvoices.FindAsync(
new object[] { oldInvoice.CustomerInvoiceId }, ct);
trackedOldInvoice!.Status = "RolledOver";
logger.LogInformation(
"SubscriptionRenewalJob: subscription {SubscriptionId} -- prior invoice {InvoiceId} was {OldStatus}, rolling over unpaid balance {CarriedOverBalance} and marking it RolledOver.",
oldSubscription.SubscriptionId, oldInvoice.CustomerInvoiceId, oldInvoice.Status, carriedOverBalance);
break;
default:
// "Failed"/"Refunded" (or a stray "Cancelled"/"RolledOver" the candidate
// query's own invoice-state shouldn't produce) -- don't guess how to carry
// these forward; surfaces as a PartialFailure note for manual review.
throw new InvalidOperationException(
$"Subscription {oldSubscription.SubscriptionId}'s current invoice has unexpected status '{oldInvoice.Status}'.");
}
// Renewal invoices skip the onboarding fee (one-time, not a per-cycle charge) and
// start unapproved (IsActive=false) -- staff must approve/change-plan/reject before
// it counts. The subscription itself (IsActive, RenewalDate, members, installments)
// is untouched here; all of that is deferred to the approval action.
var newInvoice = await invoiceRepo.CreateRenewalInvoiceAsync(oldSubscription.SubscriptionId, carriedOverBalance, ct);
await uow.CommitAsync(ct);
logger.LogInformation(
"SubscriptionRenewalJob: subscription {SubscriptionId} -- created unapproved renewal invoice {InvoiceId} (amount {Amount}), awaiting staff approval.",
oldSubscription.SubscriptionId, newInvoice.CustomerInvoiceId, newInvoice.Amount);
}
}
}
+162
View File
@@ -0,0 +1,162 @@
using System.Net;
using System.Net.Mail;
using System.Text.Json;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models.IOs;
namespace Fitness_core_BE.Services
{
/// <summary>
/// Combines the two outbound notification channels — SMS (Introps) and email (Gmail SMTP) —
/// into one service, matching the pattern already used by Core-Hex-Auth's ThirdPartyService.
/// </summary>
public class ThirdPartyService : IThirdPartyService
{
private readonly HttpClient _httpClient;
private readonly IConfiguration _config;
// Email (Gmail SMTP). Use a Gmail App Password, not the account password.
private readonly string _gmailUser;
private readonly string _gmailPassword;
private readonly string _gmailHost;
private readonly int _gmailPort;
private readonly string _gmailFromName;
public ThirdPartyService(IHttpClientFactory httpClientFactory, IConfiguration config)
{
_httpClient = httpClientFactory.CreateClient();
_config = config;
_gmailUser = Environment.GetEnvironmentVariable("GMAIL_USER") ?? config["Gmail:User"] ?? "";
_gmailPassword = Environment.GetEnvironmentVariable("GMAIL_APP_PASSWORD") ?? config["Gmail:AppPassword"] ?? "";
_gmailHost = config["Gmail:Host"] ?? "smtp.gmail.com";
_gmailPort = int.TryParse(config["Gmail:Port"], out var p) ? p : 587;
_gmailFromName = config["Gmail:FromName"] ?? "GYM OS";
}
// ---------- SMS ----------
public async Task<OtpResponse> SendSmsAsync(string mobileNumber, string? otpCode = null, string? message = null)
{
try
{
if (string.IsNullOrWhiteSpace(mobileNumber))
{
return new OtpResponse
{
StatusCode = 400,
Message = "Mobile number is required"
};
}
if (string.IsNullOrWhiteSpace(otpCode) && string.IsNullOrWhiteSpace(message))
{
return new OtpResponse
{
StatusCode = 400,
Message = "Either an OTP code or a message is required"
};
}
string contactNumber = mobileNumber;
string apiUrl = _config["Sms:ApiUrl"] ?? "https://backup.introps.com/Sms_common/send_sms";
string apiKey = Environment.GetEnvironmentVariable("SMS_API_KEY") ?? _config["Sms:ApiKey"] ?? "8989898989";
var msgText = string.IsNullOrWhiteSpace(message) ? $"Your otp code is: {otpCode}" : message;
var smsRequest = new SmsRequest
{
api_key = apiKey,
phone_number = contactNumber,
msg_text = msgText
};
var formContent = new FormUrlEncodedContent(new[]
{
new KeyValuePair<string, string>("api_key", smsRequest.api_key),
new KeyValuePair<string, string>("phone_number", smsRequest.phone_number),
new KeyValuePair<string, string>("msg_text", smsRequest.msg_text)
});
var response = await _httpClient.PostAsync(apiUrl, formContent);
var responseContent = await response.Content.ReadAsStringAsync();
if (response.IsSuccessStatusCode && responseContent.Contains("agent:d"))
{
string jsonString = responseContent.Split(new[] { "agent:d" }, StringSplitOptions.None)[1];
var responseJson = JsonSerializer.Deserialize<Dictionary<string, object>>(jsonString);
if (responseJson is not null &&
responseJson.TryGetValue("stt", out var statusValue) &&
statusValue?.ToString() == "ok")
{
Console.WriteLine($"SMS sent to {contactNumber}");
return new OtpResponse
{
StatusCode = 200,
Message = "SMS sent successfully."
};
}
}
Console.WriteLine($"Failed to send SMS to {contactNumber}. Response: {responseContent}");
return new OtpResponse
{
StatusCode = 500,
Message = "Failed to send SMS.",
ApiResponse = responseContent
};
}
catch (Exception ex)
{
Console.WriteLine($"Exception sending SMS: {ex.Message}");
return new OtpResponse
{
StatusCode = 500,
Message = "Exception occurred while sending SMS.",
Error = ex.Message
};
}
}
// ---------- Email ----------
public async Task SendEmailAsync(string toEmail, string subject, string htmlBody, byte[]? attachment = null, string? attachmentName = null)
{
if (string.IsNullOrWhiteSpace(_gmailUser) || string.IsNullOrWhiteSpace(_gmailPassword))
throw new InvalidOperationException("Gmail SMTP is not configured (set GMAIL_USER / GMAIL_APP_PASSWORD).");
using var mail = new MailMessage
{
From = new MailAddress(_gmailUser, _gmailFromName),
Subject = subject,
Body = htmlBody,
IsBodyHtml = true
};
mail.To.Add(toEmail);
MemoryStream? attachmentStream = null;
if (attachment != null)
{
attachmentStream = new MemoryStream(attachment);
mail.Attachments.Add(new Attachment(attachmentStream, attachmentName ?? "receipt.pdf", "application/pdf"));
}
try
{
using var client = new SmtpClient(_gmailHost, _gmailPort)
{
EnableSsl = true,
Credentials = new NetworkCredential(_gmailUser, _gmailPassword)
};
await client.SendMailAsync(mail);
}
finally
{
attachmentStream?.Dispose();
}
}
}
}
+327
View File
@@ -0,0 +1,327 @@
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using System.Net.Sockets;
namespace Fitness_core_BE.Services.Ticket
{
public class TicketService : ITicketService
{
private readonly ITicketRepository _repository;
private readonly UserRoleHelper _userRoleHelper;
private readonly IUnitOfWork _uow;
private readonly IActivityLogService _activityLogService;
public TicketService(ITicketRepository repository, UserRoleHelper userRoleHelper, IUnitOfWork uow, IActivityLogService activityLogService)
{
_repository = repository;
_userRoleHelper = userRoleHelper;
_uow = uow;
_activityLogService = activityLogService;
}
public async Task<object> GetTicketList(object payload, HttpContext httpContext)
{
try
{
var tickets = await _repository.GetTicketListAsync();
return tickets;
}
catch
{
throw;
}
}
public async Task<object> GetTicketByUserId(object payload, HttpContext httpContext)
{
try
{
// Guid userId = Guid.TryParse(httpContext.Items["UserId"]?.ToString(), out var parsedUserId)
// ? parsedUserId
// : throw new UnauthorizedAccessException("User ID not found in context");
// Guid userId = Guid.Parse("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"); // Testing purposes
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
var tickets = await _repository.GetTicketByUserIdAsync(userId);
return tickets;
}
catch
{
throw;
}
}
public async Task<object> GetTicketById(object payload, HttpContext httpContext)
{
try
{
var data = DeserializePayload.Deserialize(payload);
Guid ticketId = Guid.Parse(data["ticketId"].GetString()!);
var ticket = await _repository.GetByTicketIdAsync(ticketId);
if (ticket == null)
throw new KeyNotFoundException($"Ticket with ID {ticketId} not found");
if (ticket.UserId != DataExtractor.GetUserIdFromContext(httpContext))
throw new UnauthorizedAccessException("You do not have permission to view this ticket.");
return ticket;
}
catch
{
throw;
}
}
public async Task<object> GetTicketCategoryList(object payload, HttpContext httpContext)
{
try
{
var categories = await _repository.GetTicketCategoryListAsync();
return categories;
}
catch
{
throw;
}
}
public async Task<object> CreateTicket(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
// Parse userId from context
// Guid userId = Guid.TryParse(httpContext.Items["UserId"]?.ToString(), out var parsedUserId)
// ? parsedUserId
// : throw new UnauthorizedAccessException("User ID not found in context");
// Guid userId = Guid.Parse("a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"); // Testing purposes
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
var ticket = new Fitness_core_BE.Models.Ticket
{
MainReason = data["mainReason"].GetString()!,
FKTicketCategoryId = data.ContainsKey("ticketCategoryId") && !string.IsNullOrEmpty(data["ticketCategoryId"].GetString())
? Guid.Parse(data["ticketCategoryId"].GetString()!)
: (Guid?)null,
TicketStatus = data.ContainsKey("ticketStatus") && !string.IsNullOrEmpty(data["ticketStatus"].GetString())
? Enum.Parse<Fitness_core_BE.Models.Enums.TicketStatusEnum>(data["ticketStatus"].GetString()!)
: Fitness_core_BE.Models.Enums.TicketStatusEnum.Pending,
FKUserId = userId,
CreateDate = DateTime.UtcNow
};
await _repository.CreateTicketAsync(ticket);
await _uow.CommitAsync();
// Log activity - non-blocking, failures won't crash the operation
try
{
await _activityLogService.LogActivity(new { description = $"Ticket created with ID {ticket.TicketId}" }, httpContext);
}
catch (Exception logEx)
{
Console.WriteLine($"Activity log failed (non-critical): {logEx.Message}");
}
return ticket;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> UpdateTicket(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid ticketId = Guid.Parse(data["ticketId"].GetString()!);
var ticket = await _repository.GetByTicketIdAsync(ticketId) ?? throw new KeyNotFoundException($"Ticket with ID {ticketId} not found.");
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
if (ticket.UserId != userId)
throw new UnauthorizedAccessException("You do not have permission to update this ticket.");
ticket.MainReason = data.ContainsKey("mainReason") && !string.IsNullOrEmpty(data["mainReason"].GetString())
? data["mainReason"].GetString()!
: ticket.MainReason;
ticket.TicketCategoryId = data.ContainsKey("ticketCategoryId") && !string.IsNullOrEmpty(data["ticketCategoryId"].GetString())
? Guid.Parse(data["ticketCategoryId"].GetString()!)
: ticket.TicketCategoryId;
await _repository.UpdateTicketAsync(ticket);
await _uow.CommitAsync();
return ticket;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> SendMessage(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
// Parse userId from context
// Guid userId = Guid.TryParse(httpContext.Items["UserId"]?.ToString(), out var parsedUserId)
// ? parsedUserId
// : throw new UnauthorizedAccessException("User ID not found in context");
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid? adminId = await _userRoleHelper.GetAdminId(userId);
bool isAdmin = adminId.HasValue;
Guid ticketId = Guid.Parse(data["ticketId"].GetString()!);
var ticket = await _repository.GetByTicketIdAsync(ticketId)
?? throw new KeyNotFoundException($"Ticket with ID {ticketId} not found.");
if (!isAdmin && ticket.UserId != userId)
throw new UnauthorizedAccessException("You do not have permission to message this ticket.");
var ticketMessage = new TicketMessage
{
Message = data["message"].GetString()!,
FKTicketId = ticketId,
IsSentByAdmin = isAdmin,
AttachmentURL = data.ContainsKey("attachmentURL") ? data["attachmentURL"].GetString() : null,
FKDocumentId = DataExtractor.GetNullableInt(data, "documentId"),
CreateDate = DateTime.UtcNow
};
// First admin reply moves a ticket from Pending to Open — nothing previously
// set this transition, so tickets sat in Pending forever once created.
if (isAdmin)
{
if (ticket.TicketStatus == Models.Enums.TicketStatusEnum.Pending)
{
await _repository.UpdateTicketStatusAsync(ticketId, Models.Enums.TicketStatusEnum.Open);
}
}
//Guid ticCatId = DataExtractor.GetRequiredGuid(data, "ticketCategoryId");
//Guid? TcatId = await _repository.GetTicketCatId("Chat");
//if (ticCatId == TcatId)
//{
//}
await _repository.SendMessageAsync(ticketMessage);
await _uow.CommitAsync();
return ticketMessage;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> SubmitTicketFeedback(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid ticketId = Guid.Parse(data["ticketId"].GetString()!);
int rating = DataExtractor.GetRequiredInt(data, "rating");
string? feedback = data.TryGetValue("feedback", out var feedbackContent) && !string.IsNullOrEmpty(feedbackContent.GetString())
? DataExtractor.GetOptionalString(data, "feedback")
: null;
if (rating < 1 || rating > 5)
throw new ArgumentException("Rating must be between 1 and 5.");
var ticket = await _repository.GetByTicketIdAsync(ticketId) ?? throw new KeyNotFoundException($"Ticket with ID {ticketId} not found.");
if (ticket.UserId != DataExtractor.GetUserIdFromContext(httpContext))
throw new UnauthorizedAccessException("You do not have permission to submit feedback for this ticket.");
if (ticket.TicketStatus != Models.Enums.TicketStatusEnum.Closed)
throw new InvalidOperationException("Feedback can only be submitted after the ticket is closed.");
await _repository.SubmitTicketFeedbackAsync(ticketId, rating, feedback);
await _uow.CommitAsync();
return new { message = $"The feedback for ticket with ID {ticketId} submitted successfully." };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> CloseTicket(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid ticketId = Guid.Parse(data["ticketId"].GetString()!);
var ticket = await _repository.GetByTicketIdAsync(ticketId)
?? throw new KeyNotFoundException($"Ticket with ID {ticketId} not found.");
if (ticket.UserId != DataExtractor.GetUserIdFromContext(httpContext))
throw new UnauthorizedAccessException("You do not have permission to close this ticket.");
await _repository.UpdateTicketStatusAsync(ticketId, Fitness_core_BE.Models.Enums.TicketStatusEnum.Closed);
await _uow.CommitAsync();
var updatedTicket = await _repository.GetByTicketIdAsync(ticketId);
if (updatedTicket == null)
throw new KeyNotFoundException($"Ticket with ID {ticketId} not found after update");
return updatedTicket;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> DeleteTicket(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid ticketId = Guid.Parse(data["ticketId"].GetString()!);
var ticket = await _repository.GetByTicketIdAsync(ticketId) ?? throw new KeyNotFoundException($"Ticket with ID {ticketId} not found.");
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
if (ticket.UserId != userId)
throw new UnauthorizedAccessException("You do not have permission to delete this ticket.");
await _repository.DeleteAsync(ticketId);
await _uow.CommitAsync();
var deletedTicket = await _repository.GetByTicketIdAsync(ticketId);
if (deletedTicket == null)
return true;
throw new ArgumentException($"Ticket with ID {ticketId} has not been deleted");
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
}
}
+164
View File
@@ -0,0 +1,164 @@
using Fitness_core_BE.DTOs.TimeBankCard;
using Fitness_core_BE.Helpers;
using Fitness_core_BE.Infra.UoW;
using Fitness_core_BE.Interfaces;
using Fitness_core_BE.Interfaces.Services;
using Fitness_core_BE.Models;
using System.Text.Json;
namespace Fitness_core_BE.Services
{
public class TimeBankCardService : ITimeBankCardService
{
private readonly ITimeBankCardRepository _timeBankCardRepository;
private readonly IUnitOfWork _uow;
private readonly UserRoleHelper _userRoleHelper;
public TimeBankCardService(ITimeBankCardRepository timeBankCardRepository, IUnitOfWork uow, UserRoleHelper userRoleHelper)
{
_timeBankCardRepository = timeBankCardRepository;
_uow = uow;
_userRoleHelper = userRoleHelper;
}
public async Task<object> IssueCard(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var rfidTagId = data["rfidTagId"].GetString() ?? throw new ArgumentException("rfidTagId is required");
var totalMinutes = data["totalMinutesPurchased"].GetInt32();
if (totalMinutes < 1) throw new ArgumentException("totalMinutesPurchased must be at least 1");
if (await _timeBankCardRepository.GetByRfidTagId(rfidTagId) != null)
throw new ArgumentException("This RFID tag already has an active time-bank card");
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
var card = new TimeBankCard
{
FKClientId = clientId,
RfidTagId = rfidTagId,
TotalMinutesPurchased = totalMinutes,
MinutesRemaining = totalMinutes,
AmountPaid = data.ContainsKey("amountPaid") ? data["amountPaid"].GetDecimal() : 0m,
FKCustomerId = data.ContainsKey("customerId") && data["customerId"].ValueKind != JsonValueKind.Null
? Guid.Parse(data["customerId"].GetString()!)
: null,
ExpiresAt = data.ContainsKey("expiresAt") && data["expiresAt"].ValueKind != JsonValueKind.Null
? DateTime.Parse(data["expiresAt"].GetString()!).ToUniversalTime()
: null
};
var result = await _timeBankCardRepository.CreateTimeBankCard(card);
await _uow.CommitAsync();
return MapToDto(result);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> Swipe(object payload)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
var rfidTagId = data["rfidTagId"].GetString() ?? throw new ArgumentException("rfidTagId is required");
var facilityId = Guid.Parse(data["facilityId"].GetString()!);
var card = await _timeBankCardRepository.GetByRfidTagId(rfidTagId)
?? throw new KeyNotFoundException("No active time-bank card found for this RFID tag");
if (card.ExpiresAt.HasValue && card.ExpiresAt.Value < DateTime.UtcNow)
throw new ArgumentException("This card has expired");
int? minutesDeducted = null;
if (!card.ActiveSessionStartedAt.HasValue)
{
// Tap-in: just open the session. Reject outright if there's no balance left
// at all, so a spent card doesn't get to "start the clock" on a visit it
// can never pay for.
if (card.MinutesRemaining < 1)
throw new ArgumentException("This card has no time remaining");
card.ActiveSessionStartedAt = DateTime.UtcNow;
card.ActiveSessionFacilityId = facilityId;
}
else
{
// Tap-out: bill for actual elapsed time, rounded up to the next whole minute
// (a 61-second visit still costs 2 minutes, not 1) -- capped at whatever
// balance remains rather than letting a forgotten tap-out run the balance
// negative.
var elapsed = DateTime.UtcNow - card.ActiveSessionStartedAt.Value;
var elapsedMinutes = Math.Max(1, (int)Math.Ceiling(elapsed.TotalMinutes));
minutesDeducted = Math.Min(elapsedMinutes, card.MinutesRemaining);
card.MinutesRemaining -= minutesDeducted.Value;
await _timeBankCardRepository.AddUsageLog(new TimeBankCardUsageLog
{
FKTimeBankCardId = card.TimeBankCardId,
FKClientId = card.FKClientId,
FKFacilityId = card.ActiveSessionFacilityId ?? facilityId,
MinutesDeducted = minutesDeducted.Value,
MinutesRemainingAfter = card.MinutesRemaining
});
card.ActiveSessionStartedAt = null;
card.ActiveSessionFacilityId = null;
}
await _uow.CommitAsync();
return MapToDto(card, minutesDeducted);
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> GetBalance(object payload)
{
var data = DeserializePayload.Deserialize(payload);
var rfidTagId = data["rfidTagId"].GetString() ?? throw new ArgumentException("rfidTagId is required");
var card = await _timeBankCardRepository.GetByRfidTagId(rfidTagId)
?? throw new KeyNotFoundException("No active time-bank card found for this RFID tag");
return MapToDto(card);
}
public async Task<object> GetMyCards(object payload, HttpContext httpContext)
{
var cusId = await _userRoleHelper.GetCusId(DataExtractor.GetUserIdFromContext(httpContext));
var cards = await _timeBankCardRepository.GetByCustomerId(cusId);
return cards.Select(c => MapToDto(c)).ToList();
}
private static TimeBankCardDto MapToDto(TimeBankCard card, int? minutesDeductedThisSwipe = null) => new()
{
TimeBankCardId = card.TimeBankCardId,
RfidTagId = card.RfidTagId,
CustomerId = card.FKCustomerId,
TotalMinutesPurchased = card.TotalMinutesPurchased,
MinutesRemaining = card.MinutesRemaining,
AmountPaid = card.AmountPaid,
PurchasedAt = card.PurchasedAt,
ExpiresAt = card.ExpiresAt,
IsActive = card.IsActive,
SessionOpen = card.ActiveSessionStartedAt.HasValue,
SessionStartedAt = card.ActiveSessionStartedAt,
SessionFacilityName = card.ActiveSessionFacility?.Name,
MinutesDeductedThisSwipe = minutesDeductedThisSwipe
};
}
}
File diff suppressed because it is too large Load Diff