Files
Core_Be/Services/ClientService.cs
T
Dhananjaya99 bffc9b1686 first commit
2026-09-28 11:44:26 +05:30

1343 lines
61 KiB
C#

using Fitness_core_BE.Domain;
using Fitness_core_BE.DTOs.Auth;
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;
using System.Text.Json;
namespace Fitness_core_BE.Services
{
public class ClientService : IClientService
{
private readonly IClientRepository _clientRepository;
private readonly ICustomerRepository _customerRepository;
private readonly IUnitOfWork _uow;
private readonly IAuthService _authService;
private readonly ICustomerService _customerService;
private readonly ICustomerSubscriptionService _customerSubscriptionService;
private readonly IMealService _mealService;
private readonly IWorkoutService _workoutService;
private readonly UserRoleHelper _userRoleHelper;
private readonly IThirdPartyService _thirdPartyService;
private readonly IConfiguration _config;
private readonly ITermsRepository _termsRepository;
private readonly IPaymentService _paymentService;
private readonly IAccessDeviceService _accessDeviceService;
private readonly ICustomerInvoiceRepository _customerInvoiceRepository;
public ClientService(IClientRepository _clientRepository, ICustomerRepository customerRepository, IUnitOfWork uow, IAuthService authService, ICustomerService customerService, ICustomerSubscriptionService customerSubscriptionService, IMealService mealService, IWorkoutService workoutService, UserRoleHelper userRoleHelper, IThirdPartyService thirdPartyService, IConfiguration config, ITermsRepository termsRepository, IPaymentService paymentService, IAccessDeviceService accessDeviceService, ICustomerInvoiceRepository customerInvoiceRepository)
{
this._clientRepository = _clientRepository;
_customerRepository = customerRepository;
_uow = uow;
_authService = authService;
_customerService = customerService;
_customerSubscriptionService = customerSubscriptionService;
_mealService = mealService;
_workoutService = workoutService;
_userRoleHelper = userRoleHelper;
_thirdPartyService = thirdPartyService;
_config = config;
_termsRepository = termsRepository;
_paymentService = paymentService;
_accessDeviceService = accessDeviceService;
_customerInvoiceRepository = customerInvoiceRepository;
}
//V1 implementation
public async Task<object> GetAllCustomers(object payload, HttpContext httpContext)
{
return await _clientRepository.GetAllCustomers();
}
public async Task<object> GetCustomerDetailsById(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
Guid customerId = Guid.Parse(data["customerId"].GetString()!);
await EnsureCustomerBelongsToCallerClient(customerId, httpContext);
var customer = await _clientRepository.GetCustomerById(customerId);
if (customer == null)
throw new KeyNotFoundException($"Customer with ID {customerId} not found");
customer.CustomerSubscriptions = null;
// SubscriptionMembers-based: also finds subscriptions where this customer
// is a group secondary (customer.CustomerSubscriptions only sees primaries).
var subscriptions = await _customerSubscriptionService.BuildSummariesForCustomerAsync(customerId);
// Legacy `plans` shape retained for backward compatibility.
var plans = subscriptions
.Select(s => new
{
customerPlanId = s.CustomerPlanId,
planName = s.PlanName,
planPrice = s.PlanPrice,
durationInDays = 0,
planScope = s.PlanScope,
isActive = s.IsActive
})
.ToList();
return new
{
customer,
plans,
subscriptions
};
}
// Aggregated member profile for the "More Info" view: basic details + targets +
// weight progress (already loaded on the customer) plus the plans they follow.
// Medical details are deliberately excluded — they require a password (GetCustomerMedical).
public async Task<object> GetCustomerFullDetails(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
Guid customerId = Guid.Parse(data["customerId"].GetString()!);
await EnsureCustomerBelongsToCallerClient(customerId, httpContext);
var customer = await _clientRepository.GetCustomerById(customerId)
?? throw new KeyNotFoundException($"Customer with ID {customerId} not found");
customer.CustomerSubscriptions = null;
var subscriptions = await _customerSubscriptionService.BuildSummariesForCustomerAsync(customerId);
var workoutPlans = await _clientRepository.GetFollowedWorkoutPlansAsync(customerId);
var mealPlans = await _clientRepository.GetFollowedMealPlansAsync(customerId);
return new { customer, subscriptions, workoutPlans, mealPlans };
}
// Password-gated medical details. The owner re-enters their account password, which is
// verified against the auth service; only on success are BloodGroup/Disabilities returned.
public async Task<object> GetCustomerMedical(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
Guid customerId = Guid.Parse(data["customerId"].GetString()!);
string password = DataExtractor.GetRequiredString(data, "password");
await EnsureCustomerBelongsToCallerClient(customerId, httpContext);
var verify = await _authService.VerifyPassword(new { password }, httpContext);
if (verify.StatusCode != 200 || !ExtractValid(verify.Data))
throw new UnauthorizedException("Password is incorrect.");
var health = await _clientRepository.GetCustomerHealthAsync(customerId);
return new
{
bloodGroup = health?.BloodGroup,
disabilities = health?.Disabilities
};
}
// The auth VerifyPassword returns { valid } (possibly wrapped in a data envelope).
private static bool ExtractValid(object? data)
{
if (data is not JsonElement el || el.ValueKind != JsonValueKind.Object)
return false;
if (el.TryGetProperty("valid", out var v) &&
(v.ValueKind == JsonValueKind.True || v.ValueKind == JsonValueKind.False))
return v.GetBoolean();
if (el.TryGetProperty("data", out var d) && d.ValueKind == JsonValueKind.Object &&
d.TryGetProperty("valid", out var v2) &&
(v2.ValueKind == JsonValueKind.True || v2.ValueKind == JsonValueKind.False))
return v2.GetBoolean();
return false;
}
//V1 implementation
public async Task<object> DeleteCustomerByClient (object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid customerId = Guid.Parse(data["customerId"].GetString()!);
await EnsureCustomerBelongsToCallerClient(customerId, httpContext);
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
// Revoke physical access at this gym first, while the membership is still active
// in the DB (SetCustomerAccessAsync requires an active membership at clientId to
// resolve which AccessProfile to touch). No profile exists if the member never
// completed device enrollment — nothing to revoke in that case.
try
{
await _accessDeviceService.SetCustomerAccessAsync(clientId, customerId, enabled: false, reason: "Removed from gym");
}
catch (NotFoundException)
{
}
// Client-initiated delete only deactivates this customer's subscriptions/plan
// assignments at THIS gym — the shared central Customer record (and any
// membership at another gym) must never be touched by a single client's delete.
var result = await _customerRepository.DeactivateCustomerForClient(customerId, clientId);
if (!result)
{
throw new KeyNotFoundException("Customer Not Found");
}
await _uow.CommitAsync();
return new { success = true, message = "Customer deleted successfully" };
}
catch
{
await _uow.RollbackAsync();
throw new KeyNotFoundException("Error");
}
}
public async Task<object> ClientLogOut (object payload, HttpContext httpContext)
{
try
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
var authPayload = new { userId = userId };
AuthResponse authResponse = await _authService.LogOut(authPayload, httpContext);
if (!authResponse.Success)
{
throw new InvalidOperationException(authResponse.Message);
}
return new { success = true, message = "Logged out successfully", auth = authResponse };
}
catch
{
throw;
}
}
public async Task<object> GetClientList(object payload, HttpContext httpContext)
{
try
{
var clients = await _clientRepository.GetAllClientsAsync();
return clients;
}
catch
{
throw;
}
}
public async Task<object> GetClientById(object payload, HttpContext httpContext)
{
try
{
var data = DeserializePayload.Deserialize(payload);
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var client = await _clientRepository.GetClientByIdAsync(clientId);
if (client == null)
throw new KeyNotFoundException("Client not found");
return client;
}
catch
{
throw;
}
}
public async Task<object> UpdateGymProfile(
UpdateGymProfileRequest request, HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
await _uow.BeginAsync();
try
{
var business = await _clientRepository.GetPrimaryBusinessByClientIdAsync(clientId)
?? throw new KeyNotFoundException("Gym business profile not found");
if (request.BusinessName != null) business.BusinessName = request.BusinessName.Trim();
if (request.Address != null) business.Address = request.Address.Trim();
if (request.City != null) business.City = request.City.Trim();
if (request.Description != null) business.Description = request.Description.Trim();
// AC + equipment live as JSON in Facilities; preserve whichever isn't sent.
if (request.HasAc.HasValue || request.Equipment != null)
{
var current = GymFacilities.Parse(business.Facilities);
var hasAc = request.HasAc ?? current.HasAc;
var equipment = request.Equipment == null
? current.Equipment
: request.Equipment
.Select(item => new EquipmentCount(item.Name, item.Count))
.ToList();
business.Facilities = GymFacilities.Serialize(hasAc, equipment);
}
await _uow.CommitAsync();
var savedFacilities = GymFacilities.Parse(business.Facilities);
return new
{
success = true,
message = "Gym profile updated",
facilities = new
{
hasAc = savedFacilities.HasAc,
equipment = savedFacilities.Equipment
}
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
// Gym-owner view of their own gym's active Terms & Conditions (for editing).
public async Task<object> GetMyGymTerms(object payload, HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var terms = await _termsRepository.GetActiveByClientIdAsync(clientId);
if (terms == null)
return new { hasTerms = false };
return new { hasTerms = true, terms.Version, terms.Content, terms.CreatedAt };
}
// Publishes a new Terms version for the gym owner's own gym; deactivates the old one.
public async Task<object> PublishGymTerms(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
string content = DataExtractor.GetRequiredString(data, "content");
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var currentTerms = await _termsRepository.GetActiveForUpdateAsync(clientId);
foreach (var current in currentTerms)
current.IsActive = false;
var latestVersion = await _termsRepository.GetLatestVersionAsync(clientId);
var terms = new ClientTerms
{
ClientTermsId = Guid.NewGuid(),
FKClientId = clientId,
Version = latestVersion + 1,
Content = content,
IsActive = true,
CreatedAt = DateTime.UtcNow
};
await _termsRepository.AddTermsAsync(terms);
await _uow.SaveChangesAsync();
return new { success = true, terms.Version, terms.CreatedAt };
}
public async Task<object> UploadGymLogo(IFormFile logo, HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
if (logo == null || logo.Length == 0)
throw new BadRequestException("No logo file provided.");
string extension = Path.GetExtension(logo.FileName).ToLowerInvariant();
string[] allowed = { ".jpg", ".jpeg", ".png", ".webp" };
if (!allowed.Contains(extension))
throw new BadRequestException("Logo must be an image (jpg, png, webp).");
if (logo.Length > 5 * 1024 * 1024)
throw new BadRequestException("Logo too large (max 5MB).");
string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), "..", "Uploads", "gym-logos", clientId.ToString()));
if (!Directory.Exists(uploadsRoot))
Directory.CreateDirectory(uploadsRoot);
string filename = $"logo_{DateTime.UtcNow:yyyyMMdd_HHmmss}{extension}";
string filePath = Path.Combine(uploadsRoot, filename);
using (var stream = new FileStream(filePath, FileMode.Create))
{
await logo.CopyToAsync(stream);
}
await _uow.BeginAsync();
try
{
var business = await _clientRepository.GetPrimaryBusinessByClientIdAsync(clientId)
?? throw new KeyNotFoundException("Gym business profile not found");
business.BusinessLogoId = $"/Uploads/gym-logos/{clientId}/{Uri.EscapeDataString(filename)}";
await _uow.CommitAsync();
return new { businessLogoId = business.BusinessLogoId };
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> GetGymLogo(HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var business = await _clientRepository.GetPrimaryBusinessByClientIdAsync(clientId);
var logo = await BusinessLogoHelper.ReadAsDataUrlAsync(business?.BusinessLogoId);
return new { logo };
}
// Sends an OTP to the owner's new email/phone before it replaces the current one.
public async Task<object> SendContactOtp(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
string identifier = DataExtractor.GetRequiredString(data, "identifier");
var authResponse = await _authService.SendOtp(new { identifier, newUser = true }, httpContext);
if (!authResponse.Success)
throw new BadRequestException($"Failed to send OTP: {authResponse.Message}");
return AuthOtpResponseParser.ToSendOtpResult(authResponse);
}
// Verifies the OTP, writes the new value to the auth DB (UpdateUser), then mirrors it
// onto the local Client record so the dashboard shows the change immediately.
public async Task<object> VerifyContactOtp(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
string type = DataExtractor.GetRequiredString(data, "type").ToLowerInvariant();
string identifier = DataExtractor.GetRequiredString(data, "identifier");
string referenceNumber = DataExtractor.GetRequiredString(data, "referenceNumber");
string otpCode = DataExtractor.GetRequiredString(data, "otpCode");
if (type != "email" && type != "phone")
throw new BadRequestException("Contact type must be 'email' or 'phone'.");
var verify = await _authService.VerifyOTP(new { referenceNumber, otpCode, newUser = true }, httpContext);
if (!verify.Success)
throw new BadRequestException($"OTP verification failed: {verify.Message}");
object updatePayload = type == "email" ? new { email = identifier } : new { mobileNumber = identifier };
var update = await _authService.UpdateUser(updatePayload, httpContext);
if (!update.Success)
throw new BadRequestException($"Failed to update account: {update.Message}");
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
await _uow.BeginAsync();
try
{
var client = await _clientRepository.GetClientEntityByIdAsync(clientId)
?? throw new KeyNotFoundException("Client not found");
if (type == "email") client.ClientEmail = identifier;
else client.ClientPhone = identifier;
await _uow.CommitAsync();
}
catch
{
await _uow.RollbackAsync();
throw;
}
return new { success = true, message = type == "email" ? "Email updated" : "Phone number updated" };
}
public async Task<object> CreateCustomerWithPlans(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
var clientId = await _userRoleHelper.GetClientId(
DataExtractor.GetUserIdFromContext(httpContext));
var mobileNumber = DataExtractor.GetRequiredString(data, "mobileNumber");
var email = DataExtractor.GetOptionalString(data, "email");
var confirmLink = DataExtractor.GetOptionalBool(data, "confirmLink") ?? false;
// Reject outright if this gym already has this phone/email as an active member --
// re-adding them here would otherwise silently stack a duplicate subscription.
var ownGymDuplicate = await _customerRepository.FindActiveLinkByIdentifierAsync(mobileNumber) ??
(!string.IsNullOrWhiteSpace(email)
? await _customerRepository.FindActiveLinkByIdentifierAsync(email)
: null);
if (ownGymDuplicate != null)
throw new ArgumentException("This phone number or email is already used by another customer at your gym.");
var existingCustomer =
await _customerRepository.GetCustomerByIdentifier(mobileNumber) ??
(!string.IsNullOrWhiteSpace(email)
? await _customerRepository.GetCustomerByIdentifier(email)
: null);
// Not at this gym, but already a platform customer elsewhere -- don't auto-link;
// let staff confirm this is really the same person first. Nothing written yet.
if (existingCustomer != null && !confirmLink)
{
return new
{
success = true,
requiresConfirmation = true,
message = "This phone number or email already belongs to an existing HexaFitness customer. Add them as your member?",
existingCustomer = new
{
existingCustomer.CusId,
existingCustomer.CusName,
existingCustomer.PrimaryPH,
existingCustomer.Email
}
};
}
await _uow.BeginAsync();
try
{
if (existingCustomer != null)
{
existingCustomer.IsActive = true;
if (existingCustomer.User != null)
{
existingCustomer.User.IsActive = true;
existingCustomer.User.UserName = DataExtractor.GetRequiredString(data, "userName");
existingCustomer.User.PhoneNumber = mobileNumber;
if (!string.IsNullOrWhiteSpace(email))
existingCustomer.User.Email = email;
}
existingCustomer.CusName = DataExtractor.GetRequiredString(data, "fullname");
existingCustomer.Gender = DataExtractor.GetRequiredString(data, "gender");
existingCustomer.BoD = DataExtractor.GetOptionalDateOfBirth(data, "birthday");
existingCustomer.Email = email;
existingCustomer.PrimaryPH = mobileNumber;
existingCustomer.NIC = DataExtractor.GetOptionalString(data, "nic");
existingCustomer.Address = DataExtractor.GetOptionalString(data, "address");
await _uow.SaveChangesAsync();
// The central Customer may predate this gym entirely (same person already
// a member elsewhere), in which case this tenant DB has no link row yet.
await _customerRepository.EnsureTenantCustomerLink(existingCustomer);
await _uow.SaveChangesAsync();
await _accessDeviceService.EnsureAccessProfileAsync(
clientId,
existingCustomer.User?.UserId ?? Guid.Empty,
"Customer",
existingCustomer.CusName ?? existingCustomer.User?.UserName ?? string.Empty);
Guid? existingSubscriptionId = null;
Guid? existingInvoiceId = null;
decimal? existingInvoiceAmount = null;
if ((data.ContainsKey("workoutPlanIds") && data["workoutPlanIds"].ValueKind == JsonValueKind.Array) ||
(data.ContainsKey("mealPlanIds") && data["mealPlanIds"].ValueKind == JsonValueKind.Array) ||
data.ContainsKey("customerPlanId"))
{
var assignResult = await _customerService.AssignPlansToCustomer(existingCustomer.CusId, payload, httpContext);
var subscriptionIdProp = assignResult?.GetType().GetProperty("subscriptionId");
existingSubscriptionId = subscriptionIdProp?.GetValue(assignResult) as Guid?;
var invoiceIdProp = assignResult?.GetType().GetProperty("invoiceId");
existingInvoiceId = invoiceIdProp?.GetValue(assignResult) as Guid?;
var invoiceAmountProp = assignResult?.GetType().GetProperty("invoiceAmount");
existingInvoiceAmount = invoiceAmountProp?.GetValue(assignResult) as decimal?;
}
await _uow.CommitAsync();
return new
{
success = true,
message = "Customer already existed in core DB and was reactivated.",
customer = existingCustomer,
subscriptionId = existingSubscriptionId,
invoiceId = existingInvoiceId,
invoiceAmount = existingInvoiceAmount,
paymentLinkSent = false,
paymentLinkError = (string?)null,
auth = new
{
skipped = true
}
};
}
var user = new User
{
UserName = DataExtractor.GetRequiredString(data, "userName"),
PhoneNumber = mobileNumber,
Email = email,
IsActive = true,
CreateDate = DateTime.UtcNow
};
var customer = new Customer
{
CusName = DataExtractor.GetRequiredString(data, "fullname"),
Gender = DataExtractor.GetRequiredString(data, "gender"),
BoD = DataExtractor.GetOptionalDateOfBirth(data, "birthday"),
Email = email,
PrimaryPH = mobileNumber,
NIC = DataExtractor.GetOptionalString(data, "nic"),
Address = DataExtractor.GetOptionalString(data, "address"),
IsPrimaryPHVerfied = false,
User = user
};
var result = await _customerRepository.CreateCustomer(customer);
await _uow.SaveChangesAsync();
// Must precede AssignPlansToCustomer: the tenant-side subscription FKs point
// at this link row, so it has to be persisted before any of them are inserted.
await _customerRepository.EnsureTenantCustomerLink(result);
await _uow.SaveChangesAsync();
await _accessDeviceService.EnsureAccessProfileAsync(
clientId, user.UserId, "Customer", customer.CusName ?? user.UserName ?? string.Empty);
var tempPassword = Guid.NewGuid().ToString("N").Substring(0, 8);
AuthResponse authResponse;
try
{
Guid customerRoleId = Guid.Parse("08de6a11-9e9f-4401-8a10-685cc57b41ed");
Guid customerUserTypeId = Guid.Parse("00000000-0000-0000-0000-000000000001");
var authPayload = DeserializePayload.Deserialize(payload);
authPayload["userId"] = JsonSerializer.SerializeToElement(user.UserId);
authPayload["roleId"] = JsonSerializer.SerializeToElement(customerRoleId);
authPayload["userTypeId"] = JsonSerializer.SerializeToElement(customerUserTypeId);
authPayload["password"] = JsonSerializer.SerializeToElement(tempPassword);
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}");
}
Guid? subscriptionId = null;
Guid? invoiceId = null;
decimal? invoiceAmount = null;
if ((data.ContainsKey("workoutPlanIds") && data["workoutPlanIds"].ValueKind == JsonValueKind.Array) ||
(data.ContainsKey("mealPlanIds") && data["mealPlanIds"].ValueKind == JsonValueKind.Array) ||
data.ContainsKey("customerPlanId"))
{
var assignResult = await _customerService.AssignPlansToCustomer(result.CusId, payload, httpContext);
// The FE opens the "add group members" step right after creating a
// member on a Group plan, so surface the new subscription's id.
var subscriptionIdProp = assignResult?.GetType().GetProperty("subscriptionId");
subscriptionId = subscriptionIdProp?.GetValue(assignResult) as Guid?;
var invoiceIdProp = assignResult?.GetType().GetProperty("invoiceId");
invoiceId = invoiceIdProp?.GetValue(assignResult) as Guid?;
var invoiceAmountProp = assignResult?.GetType().GetProperty("invoiceAmount");
invoiceAmount = invoiceAmountProp?.GetValue(assignResult) as decimal?;
}
await _uow.CommitAsync();
// Send the member their app login (Gmail email + SMS with the mobile-app
// link, username and password). Non-critical: delivery failures must not
// roll back the created customer.
await SendCustomerWelcomeAsync(user.UserName, user.Email, user.PhoneNumber, tempPassword);
bool paymentLinkSent = false;
string? paymentLinkError = null;
if (invoiceId.HasValue && invoiceAmount is > 0)
{
try
{
await _paymentService.SendPaymentLink(invoiceId.Value, httpContext);
paymentLinkSent = true;
}
catch (Exception ex)
{
// Account/subscription creation is complete. Delivery can be retried
// from the member invoice without rolling the member back.
paymentLinkError = ex.Message;
Console.WriteLine($"Automatic customer payment link failed (non-critical): {ex.Message}");
}
}
return new
{
success = true,
message = "Customer created and plans assigned successfully",
customer = result,
subscriptionId,
invoiceId,
paymentLinkSent,
paymentLinkError,
auth = authResponse
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
// Delivers a newly created member's app login over both channels. Each channel is
// isolated so one failing (e.g. no email configured) still lets the other through,
// and any failure is swallowed — the member already exists at this point.
private async Task SendCustomerWelcomeAsync(string? userName, string? email, string? phoneNumber, string password)
{
var appLink = _config["App:MobileAppLink"] ?? "";
if (!string.IsNullOrWhiteSpace(email))
{
try
{
string html =
$"<p>Hi {userName},</p>" +
"<p>Your gym membership account has been created. Use the credentials below to log in to the Hexa Fitness mobile app:</p>" +
$"<p><b>Username:</b> {userName}<br/><b>Password:</b> {password}</p>" +
(string.IsNullOrWhiteSpace(appLink) ? "" : $"<p>Download the app: <a href='{appLink}'>{appLink}</a></p>") +
"<p>We recommend changing your password after your first login.</p>" +
"<p>— Hexa Fitness</p>";
await _thirdPartyService.SendEmailAsync(email!, "Your Hexa Fitness app login", html);
}
catch (Exception ex)
{
Console.WriteLine($"Customer welcome email failed (non-critical): {ex.Message}");
}
}
if (!string.IsNullOrWhiteSpace(phoneNumber))
{
try
{
string sms =
$"Welcome to Hexa Fitness!" +
(string.IsNullOrWhiteSpace(appLink) ? "" : $" Download: {appLink}");
await _thirdPartyService.SendSmsAsync(phoneNumber!, null, sms);
}
catch (Exception ex)
{
Console.WriteLine($"Customer welcome SMS failed (non-critical): {ex.Message}");
}
}
}
public async Task<object> UpdateCustomerByClient(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
Guid customerId = DataExtractor.GetRequiredGuid(data, "customerId");
await EnsureCustomerBelongsToCallerClient(customerId, httpContext);
var customer = await _customerRepository.GetCustomerById(customerId);
if (customer == null)
throw new NotFoundException("Customer not found");
customer.CusName = DataExtractor.GetRequiredString(data, "fullname");
customer.Gender = DataExtractor.GetRequiredString(data, "gender");
customer.BoD = DataExtractor.GetOptionalDateOfBirth(data, "birthday");
customer.Email = DataExtractor.GetOptionalString(data, "email");
customer.PrimaryPH = DataExtractor.GetOptionalString(data, "phoneNumber");
customer.NIC = DataExtractor.GetOptionalString(data, "nic");
customer.Address = DataExtractor.GetOptionalString(data, "address");
var userName = DataExtractor.GetRequiredString(data, "userName");
if (customer.User != null)
{
customer.User.UserName = userName;
customer.User.Email = customer.Email;
customer.User.PhoneNumber = customer.PrimaryPH;
}
var result = await _customerRepository.UpdateCustomer(customerId, customer);
if (result == null)
throw new KeyNotFoundException("Customer not found");
// This gym's own TenantCustomerLink caches name/phone/email for list and payment
// views -- without this it keeps showing whatever was cached at link time, drifting
// from the central edit above. EnsureTenantCustomerLink is idempotent, so this is
// just a refresh when the link already exists.
await _customerRepository.EnsureTenantCustomerLink(result);
// A client-side profile edit must not silently drift from what the customer logs
// in with — push the same name/contact fields to their Auth login credential.
// Failure here throws (caught below), rolling back the local edit too, so the
// two never end up out of sync in either direction.
var authResponse = await _authService.AdminUpdateUser(new
{
userId = customer.FKUserId,
userName,
email = customer.Email,
mobileNumber = customer.PrimaryPH
}, httpContext);
if (authResponse.StatusCode != 200 || !authResponse.Success)
throw new InvalidOperationException($"Auth profile sync failed: {authResponse.Message}");
if ((data.ContainsKey("workoutPlanIds") && data["workoutPlanIds"].ValueKind == JsonValueKind.Array) ||
(data.ContainsKey("mealPlanIds") && data["mealPlanIds"].ValueKind == JsonValueKind.Array) ||
data.ContainsKey("customerPlanId"))
{
await _customerService.AssignPlansToCustomer(customerId, payload, httpContext);
}
await _uow.CommitAsync();
return result;
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> AssignPlanToExistingCustomer(Guid customerId, object payload, HttpContext httpContext)
{
await EnsureCustomerBelongsToCallerClient(customerId, httpContext);
var data = DeserializePayload.Deserialize(payload);
if (!data.ContainsKey("customerPlanId") || data["customerPlanId"].ValueKind == JsonValueKind.Null)
throw new ArgumentException("customerPlanId is required.");
return await _customerService.AssignPlansToCustomer(customerId, payload, httpContext);
}
// Switches a customer already on an Individual-scope plan to a different Individual-scope
// plan: deactivates their current subscription (and its members/subitems/installments)
// and starts a fresh one via CustomerSubscriptionFactory, mirroring how AssignPlansToCustomer
// builds a first subscription — but self-contained here rather than reusing that method,
// since it assumes the customer has no existing active subscription.
//
// Settles the old plan's billing rather than abandoning it:
// - old invoice still Pending/Partial -> mark it RolledOver and carry the unpaid
// balance onto a full-price invoice for the new plan (same mechanism the renewal
// sweep already uses for exactly this).
// - old invoice Paid, new plan costs more -> a single settlement invoice for just the
// difference (not the new plan's full price plus the difference).
// - old invoice Paid, new plan costs less -> the same settlement invoice, but negative:
// a "Credit" invoice the customer's next bill (any kind) will automatically deduct.
public async Task<object> ChangeCustomerMembershipPlan(Guid customerId, Guid newCustomerPlanId, HttpContext httpContext)
{
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
await EnsureCustomerBelongsToCallerClient(customerId, httpContext);
await _uow.BeginAsync();
try
{
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(customerId);
if (oldMembership?.Subscription.FKCustomerPlanId == newCustomerPlanId)
throw new BadRequestException("This member is already on that plan.");
// Read the old subscription's billing state before ReplaceCustomerSubscriptionAsync
// deactivates it -- GetInvoiceBySubscriptionIdAsync still resolves correctly on an
// active subscription regardless of what happens to it a moment later.
var oldInvoice = oldMembership != null
? await _customerInvoiceRepository.GetInvoiceBySubscriptionIdAsync(oldMembership.FKSubscriptionId)
: null;
var subscription = new Models.CustomerSubscription
{
FKCustomerPlanId = newCustomerPlanId,
FKCustomerId = customerId,
OnboardDate = DateTime.UtcNow,
IsActive = true
};
var initialSubItem = new Models.CustomerSubItem
{
SubItemName = "Membership change",
IsActive = true
};
var aggregate = CustomerSubscriptionFactory.Create(subscription, newPlan, initialSubItem);
await _clientRepository.ReplaceCustomerSubscriptionAsync(oldMembership, aggregate);
await _uow.SaveChangesAsync();
// Sibling namespace Fitness_core_BE.Services.CustomerInvoice (CustomerInvoiceService.cs)
// outranks a bare `CustomerInvoice` here, so the model type needs its full name.
Fitness_core_BE.Models.CustomerInvoice invoice;
if (oldInvoice == null || oldInvoice.Status == "Pending" || oldInvoice.Status == "Partial")
{
decimal carriedOverBalance = 0m;
if (oldInvoice != null)
{
var approvedPaid = await _customerInvoiceRepository.GetApprovedPaymentTotalAsync(oldInvoice.CustomerInvoiceId);
carriedOverBalance = Math.Max(0m, oldInvoice.Amount - approvedPaid);
await _customerInvoiceRepository.UpdateCustomerInvoiceStatus(oldInvoice.CustomerInvoiceId, "RolledOver");
}
invoice = await _customerInvoiceRepository.CreateCustomerInvoiceFromSubscription(
subscription.SubscriptionId, chargePayment: true, carriedOverBalance);
}
else
{
// oldInvoice is Paid (any other status would mean the old subscription was
// never properly billed, which "shouldn't happen" for an active membership --
// treated the same as Paid here rather than adding a third branch for it).
var diff = newPlan.PlanPrice - oldInvoice.Amount;
invoice = await _customerInvoiceRepository.CreatePlanChangeInvoiceAsync(
subscription.SubscriptionId, "Membership change", diff);
}
await _uow.CommitAsync();
return new
{
success = true,
message = "Membership plan changed",
subscriptionId = subscription.SubscriptionId,
invoiceId = invoice.CustomerInvoiceId,
invoiceAmount = invoice.Amount,
invoiceStatus = invoice.Status
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
// Drops the member back to "no plan": if they're the primary member of a subscription,
// the whole subscription (and every other member on it) is deactivated; otherwise only
// their own membership row is. No new subscription/invoice is created.
public async Task<object> UnassignCustomerMembership(Guid customerId, HttpContext httpContext)
{
await EnsureCustomerBelongsToCallerClient(customerId, httpContext);
await _uow.BeginAsync();
try
{
var membership = await _clientRepository.GetMembershipForCustomerAsync(customerId)
?? throw new BadRequestException("This member doesn't have a plan to unassign.");
// Toggle: the same button unassigns an assigned member and re-assigns them
// to the plan they were already on. The subscription itself is left active
// either way, so nothing has to be rebuilt to put them back.
var assigned = !membership.IsPlanAssigned;
// Unassigning the primary takes the whole group off the shared plan (it is one
// entitlement); a secondary only unassigns themselves.
await _clientRepository.SetMembershipPlanAssignedAsync(
membership.FKSubscriptionId,
assigned,
wholeSubscription: membership.IsPrimary,
membership.SubscriptionMemberId);
await _uow.SaveChangesAsync();
await _uow.CommitAsync();
return new
{
success = true,
isPlanAssigned = assigned,
message = assigned ? "Membership assigned" : "Membership unassigned"
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> GetCustomersByClientId(object payload, HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var subscriptions = await _clientRepository.GetCustomersByClientIdAsync(clientId);
return subscriptions;
}
private async Task EnsureCustomerBelongsToCallerClient(Guid customerId, HttpContext httpContext)
{
var clientId = await _userRoleHelper.GetClientId(DataExtractor.GetUserIdFromContext(httpContext));
if (!await _clientRepository.CustomerBelongsToClientAsync(customerId, clientId))
throw new ForbiddenException("This customer belongs to another gym.");
}
public async Task<object> GetCustomerPlansByClientId(object payload, HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var plans = await _clientRepository.GetCustomerPlansByClientIdAsync(clientId);
return plans;
}
public async Task<object> RegisterClient(object payload, HttpContext httpContext)
{
await _uow.BeginAsync();
try
{
var data = DeserializePayload.Deserialize(payload);
// Helper to extract double values safely
double? GetDouble(string key)
{
if (data.ContainsKey(key) && data[key].ValueKind == JsonValueKind.Number)
return data[key].GetDouble();
if (data.ContainsKey(key) && data[key].ValueKind == JsonValueKind.String &&
double.TryParse(data[key].GetString(), out var result))
return result;
return null;
}
// Create User first
var user = new User
{
UserName = data.ContainsKey("ClientName") ? data["ClientName"].GetString() : "unknown",
Email = data.ContainsKey("ClientEmail") ? data["ClientEmail"].GetString() : null,
PhoneNumber = data.ContainsKey("ClientPhone") ? data["ClientPhone"].GetString() : null,
IsActive = true,
CreateDate = DateTime.UtcNow
};
var client = new Client
{
ClientName = data.ContainsKey("ClientName") ? data["ClientName"].GetString() : null,
ClientPhone = data.ContainsKey("ClientPhone") ? data["ClientPhone"].GetString() : null,
ClientEmail = data.ContainsKey("ClientEmail") ? data["ClientEmail"].GetString() : null,
User = user
};
await _clientRepository.RegisterClientAsync(client);
var clientBussiness = new ClientBusiness
{
FKClientId = client.ClientId,
BusinessType = data.ContainsKey("BusinessType") ? (Fitness_core_BE.Models.Enums.BusinessType)Enum.Parse(typeof(Fitness_core_BE.Models.Enums.BusinessType), data["BusinessType"].GetString() ?? "Individual") : Fitness_core_BE.Models.Enums.BusinessType.institute,
BusinessName = data.ContainsKey("BusinessName") ? data["BusinessName"].GetString() : null,
BusinessLogoId = data.ContainsKey("BusinessLogoId") ? data["BusinessLogoId"].GetString() : null,
Description = data.ContainsKey("Description") ? data["Description"].GetString() : null,
IsPysicalLocation = data.ContainsKey("IsPysicalLocation") ? data["IsPysicalLocation"].GetBoolean() : true,
Address = data.ContainsKey("Address") ? data["Address"].GetString() : null,
City = data.ContainsKey("City") ? data["City"].GetString() : null,
Facilities = data.ContainsKey("facilities") ? data["facilities"].GetString() : null,
CoachingMode = data.ContainsKey("CoachingMode") ? data["CoachingMode"].GetString() : null,
Expertise = data.ContainsKey("Expertise") ? data["Expertise"].GetString() : null,
Gender = data.ContainsKey("Gender") ? data["Gender"].GetString() : null,
ExperienceYears = data.ContainsKey("ExperienceYears") ? data["ExperienceYears"].GetString() : null,
SessionType = data.ContainsKey("SessionType") ? data["SessionType"].GetString() : null,
StoreType = data.ContainsKey("StoreType") ? data["StoreType"].GetString() : null,
ProductCategory = data.ContainsKey("ProductCategory") ? data["ProductCategory"].GetString() : null,
DeliveryAvailable = data.ContainsKey("DeliveryAvailable") ? data["DeliveryAvailable"].GetString() : null,
Latitude = GetDouble("latitude"),
Longitude = GetDouble("longitude"),
};
Guid userTypeId = Guid.Parse("00000000-0000-0000-0000-000000000002");
Guid roleId = Guid.Parse("08de6a11-9e9f-4401-8a10-6859860b41ed");
Guid userId = user.UserId;
await _clientRepository.RegisterBussinessDetailAsync(clientBussiness);
await _uow.CommitAsync();
var authPayload = new { email = client.ClientEmail, userTypeId = userTypeId, roleId = roleId, userId};
AuthResponse authResponse;
try
{
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}");
}
return new
{
client,
auth = authResponse
};
}
catch
{
await _uow.RollbackAsync();
throw;
}
}
public async Task<object> GetPackageList(object payload, HttpContext httpContext)
{
try
{
var packages = await _clientRepository.GetPackageListAsync();
return packages;
}
catch
{
throw;
}
}
public async Task<object> GetAllCustomerPlans(object payload, HttpContext httpContext)
{
try
{
var data = DeserializePayload.Deserialize(payload);
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var plans = await _clientRepository.GetCustomerPlansByClientIdAsync(clientId);
return plans;
}
catch
{
throw;
}
}
public async Task<object> GetCustomerPlanById(object payload, HttpContext httpContext)
{
try
{
var data = DeserializePayload.Deserialize(payload);
Guid customerPlanId = DataExtractor.GetRequiredGuid(data, "customerPlanId");
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var plan = await _clientRepository.GetCustomerPlanByIdForClientAsync(customerPlanId, clientId);
if (plan == null)
throw new NotFoundException($"Customer plan with ID {customerPlanId} not found");
return plan;
}
catch
{
throw;
}
}
public async Task<object> GetMealPlansByClientId(object payload, HttpContext httpContext)
{
try
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var plans = await _clientRepository.GetMealPlansByClientIdAsync(clientId);
return plans;
}
catch
{
throw;
}
}
public async Task<object> GetMealPlanById(object payload, HttpContext httpContext)
{
try
{
var data = DeserializePayload.Deserialize(payload);
Guid mealPlanId = Guid.Parse(data["mealPlanId"].GetString()!);
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var plan = await _clientRepository.GetMealPlanByIdForClientAsync(mealPlanId, clientId);
if (plan == null)
throw new NotFoundException($"Meal plan with ID {mealPlanId} not found");
if (plan.MealItems != null)
{
foreach (var item in plan.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 plan;
}
catch
{
throw;
}
}
public async Task<object> GetWorkoutPlansByClientId(object payload, HttpContext httpContext)
{
try
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var plans = await _clientRepository.GetWorkoutPlansByClientIdAsync(clientId);
return plans;
}
catch
{
throw;
}
}
public async Task<object> GetWorkoutPlanById(object payload, HttpContext httpContext)
{
try
{
var data = DeserializePayload.Deserialize(payload);
Guid workoutPlanId = Guid.Parse(data["workoutPlanId"].GetString()!);
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var plan = await _clientRepository.GetWorkoutPlanByIdForClientAsync(workoutPlanId, clientId);
if (plan == null)
throw new Exceptions.NotFoundException($"Workout plan with ID {workoutPlanId} not found");
var transformedWorkoutItems = plan.WorkoutItems?.Select(item =>
{
object? exercisesObj = null;
if (!string.IsNullOrEmpty(item.Exercises))
{
try
{
exercisesObj = System.Text.Json.JsonSerializer.Deserialize<object>(item.Exercises);
}
catch
{
exercisesObj = item.Exercises;
}
}
return new
{
item.WorkoutItemId,
item.ExerciseDay,
item.Description,
Exercises = exercisesObj,
item.HowToDo,
item.DoURL,
item.WorkoutPlanId,
item.IsActive,
item.CreateDate,
item.UpdatedAt
};
}).ToList();
return new
{
plan.WorkoutPlanId,
plan.Name,
plan.Description,
plan.IsTemplate,
plan.WorkoutCategoryId,
plan.WorkoutCategoryName,
plan.ClientId,
plan.ClientName,
plan.IsActive,
plan.CreateDate,
WorkoutItems = transformedWorkoutItems,
AssignedCustomers = plan.AssignedCustomers
};
}
catch
{
throw;
}
}
public async Task<object> GetCardDetails(object payload, HttpContext httpContext)
{
try
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var cards = await _clientRepository.GetCardsByClientIdAsync(clientId);
return cards;
}
catch
{
throw;
}
}
public async Task<object> GetCustomerCards(object payload, HttpContext httpContext)
{
var data = DeserializePayload.Deserialize(payload);
Guid customerId = DataExtractor.GetRequiredGuid(data, "customerId");
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var cards = await _clientRepository.GetCardsByCustomerForClientAsync(customerId, clientId);
return cards.Select(c => new
{
jobId = c.JobId,
cardId = c.CardId,
cardNumber = c.CardNumber,
cardType = c.CardType,
nickName = c.NickName,
isPrimary = c.IsPrimary,
consentGiven = c.ConsentGiven
});
}
public async Task<object> GetLockPolicy(HttpContext httpContext)
{
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var client = await _clientRepository.GetClientEntityByIdAsync(clientId)
?? throw new KeyNotFoundException("Client not found");
return new { lockAfterDaysOverdue = client.LockAfterDaysOverdue };
}
public async Task<object> UpdateLockPolicy(int? lockAfterDaysOverdue, HttpContext httpContext)
{
if (lockAfterDaysOverdue is < 0)
throw new ArgumentException("lockAfterDaysOverdue cannot be negative.");
Guid userId = DataExtractor.GetUserIdFromContext(httpContext);
Guid clientId = await _userRoleHelper.GetClientId(userId);
var client = await _clientRepository.GetClientEntityByIdAsync(clientId)
?? throw new KeyNotFoundException("Client not found");
client.LockAfterDaysOverdue = lockAfterDaysOverdue;
await _uow.SaveChangesAsync();
return new { lockAfterDaysOverdue = client.LockAfterDaysOverdue };
}
}
}