first commit
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Generates and hashes agent API keys. The raw key is high-entropy random bytes,
|
||||
/// shown to the agent exactly ONCE at enrollment. Only the SHA-256 hash is persisted
|
||||
/// in Agent.ApiKeyHash, so a leaked database cannot be used to recover usable keys.
|
||||
/// </summary>
|
||||
public static class ApiKeyHelper
|
||||
{
|
||||
// 32 bytes = 256 bits of entropy.
|
||||
private const int KeyByteLength = 32;
|
||||
|
||||
/// <summary>
|
||||
/// Generates a new random API key encoded as base64url (URL/header safe, no padding).
|
||||
/// </summary>
|
||||
public static string GenerateApiKey()
|
||||
{
|
||||
byte[] bytes = RandomNumberGenerator.GetBytes(KeyByteLength);
|
||||
return ToBase64Url(bytes);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Hashes a raw API key for storage/lookup. The key is high-entropy so an
|
||||
/// unsalted SHA-256 is sufficient (and deterministic, so we can index/look it up).
|
||||
/// </summary>
|
||||
public static string HashApiKey(string apiKey)
|
||||
{
|
||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(apiKey));
|
||||
return Convert.ToBase64String(hash);
|
||||
}
|
||||
|
||||
private static string ToBase64Url(byte[] bytes)
|
||||
{
|
||||
return Convert.ToBase64String(bytes)
|
||||
.TrimEnd('=')
|
||||
.Replace('+', '-')
|
||||
.Replace('/', '_');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
public static class ApiResponseHelper
|
||||
{
|
||||
public static ActionResult<ApiResponse<T>> Response<T>(T data, int statusCode = 200, string message = "OK")
|
||||
=> new ObjectResult(new ApiResponse<T> { StatusCode = statusCode, Message = message, Data = data }) { StatusCode = statusCode };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using Fitness_core_BE.DTOs.Auth;
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
// Parses AuthHex's real /api/alt|/api/user response shapes. The AuthHex gateway wraps
|
||||
// whatever the underlying handler returns in its own {success,message,data} envelope, so
|
||||
// authResponse.Data (already unwrapped once by AuthService) can itself be a SECOND envelope:
|
||||
// { "success": true, "message": "...", "data": { "referenceNumber": "REC...", "expiresAt": "2026-07-03T18:49:38Z", "recoveryType": "..." } }
|
||||
// referenceNumber is an opaque string, not a Guid, and there is no "expiresInSeconds" field -
|
||||
// it's an absolute "expiresAt" timestamp that we convert to a countdown.
|
||||
public static class AuthOtpResponseParser
|
||||
{
|
||||
public static SendOtpResult ToSendOtpResult(AuthResponse authResponse, int resendAllowedInSeconds = 60)
|
||||
{
|
||||
if (authResponse.Data == null)
|
||||
throw new BadRequestException("Auth service did not return OTP data.");
|
||||
|
||||
var data = UnwrapToField(DeserializePayload.Deserialize(authResponse.Data), "referenceNumber", "ReferenceNumber");
|
||||
var referenceNumber = DataExtractor.GetRequiredStringByKeys(data, "referenceNumber");
|
||||
|
||||
var expiresAt = DataExtractor.GetOptionalDateTime(data, "expiresAt");
|
||||
var expiresInSeconds = expiresAt.HasValue
|
||||
? Math.Max(0, (int)(expiresAt.Value.ToUniversalTime() - DateTime.UtcNow).TotalSeconds)
|
||||
: DataExtractor.GetNullableInt(data, "expiresInSeconds") ?? 600;
|
||||
|
||||
return new SendOtpResult(referenceNumber, expiresInSeconds, resendAllowedInSeconds);
|
||||
}
|
||||
|
||||
// If the payload doesn't have any of the fields we're after but looks like another
|
||||
// {success,message,data} envelope, drop down into its "data" and try again.
|
||||
public static Dictionary<string, JsonElement> UnwrapToField(Dictionary<string, JsonElement> data, params string[] anyOfKeys)
|
||||
{
|
||||
for (var depth = 0; depth < 3; depth++)
|
||||
{
|
||||
if (anyOfKeys.Any(k => data.ContainsKey(k)))
|
||||
return data;
|
||||
|
||||
if (!data.TryGetValue("data", out var nested) || nested.ValueKind != JsonValueKind.Object)
|
||||
return data;
|
||||
|
||||
data = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(nested.GetRawText())!;
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Fitness_core_BE.DTOs.Booking;
|
||||
using System.Net;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
// Minimal, dependency-free HTML for the public booking confirmation page (BookingController.
|
||||
// ViewBooking). No admin dashboard or mobile app exists yet for this to link into instead --
|
||||
// this is a self-contained page so "share the booking link" already works today.
|
||||
public static class BookingViewHtml
|
||||
{
|
||||
public static string Render(BookingDto booking)
|
||||
{
|
||||
var who = booking.IsGuest ? Encode(booking.GuestName) : "Member booking";
|
||||
var when = booking.SessionStartTime.HasValue
|
||||
? $"{booking.SessionStartTime:dddd, dd MMM yyyy}, {booking.SessionStartTime:h:mm tt} - {booking.SessionEndTime:h:mm tt}"
|
||||
: "-";
|
||||
|
||||
var statusColor = booking.Status switch
|
||||
{
|
||||
"CheckedIn" => "#16a34a",
|
||||
"Cancelled" => "#dc2626",
|
||||
"NoShow" => "#dc2626",
|
||||
_ => "#2563eb"
|
||||
};
|
||||
|
||||
var otpBlock = !string.IsNullOrEmpty(booking.OtpCode)
|
||||
? $$"""
|
||||
<div class="otp-box">
|
||||
<div class="otp-label">Show this code at the door</div>
|
||||
<div class="otp-code">{{Encode(booking.OtpCode)}}</div>
|
||||
</div>
|
||||
"""
|
||||
: booking.OtpVerified
|
||||
? "<p class=\"muted\">Checked in -- code already used.</p>"
|
||||
: "";
|
||||
|
||||
return $$"""
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Booking Confirmation - Vijaya Sport Club</title>
|
||||
<style>
|
||||
body { font-family: -apple-system, Segoe UI, Roboto, Arial, sans-serif; background: #f3f4f6; margin: 0; padding: 24px 16px; color: #111827; }
|
||||
.card { max-width: 420px; margin: 0 auto; background: #fff; border-radius: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); overflow: hidden; }
|
||||
.header { background: #0f172a; color: #fff; padding: 20px; }
|
||||
.header h1 { margin: 0; font-size: 18px; }
|
||||
.body { padding: 20px; }
|
||||
.row { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #f1f5f9; }
|
||||
.row .label { color: #6b7280; font-size: 13px; }
|
||||
.row .value { font-weight: 600; font-size: 13px; text-align: right; }
|
||||
.status { display: inline-block; padding: 3px 10px; border-radius: 999px; color: #fff; font-size: 12px; font-weight: 600; background: {{statusColor}}; }
|
||||
.otp-box { margin-top: 16px; text-align: center; background: #f8fafc; border: 1px dashed #cbd5e1; border-radius: 8px; padding: 16px; }
|
||||
.otp-label { font-size: 12px; color: #6b7280; margin-bottom: 6px; }
|
||||
.otp-code { font-size: 32px; font-weight: 700; letter-spacing: 6px; color: #0f172a; }
|
||||
.muted { color: #6b7280; font-size: 13px; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="header"><h1>Vijaya Sport Club</h1></div>
|
||||
<div class="body">
|
||||
<div class="row"><span class="label">Facility</span><span class="value">{{Encode(booking.FacilityName)}}</span></div>
|
||||
<div class="row"><span class="label">Session</span><span class="value">{{Encode(when)}}</span></div>
|
||||
<div class="row"><span class="label">Booked for</span><span class="value">{{who}}</span></div>
|
||||
<div class="row"><span class="label">Payment</span><span class="value">{{Encode(booking.PaymentStatus)}}</span></div>
|
||||
<div class="row"><span class="label">Status</span><span class="value"><span class="status">{{Encode(booking.Status)}}</span></span></div>
|
||||
{{otpBlock}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
|
||||
public static string NotFound() => """
|
||||
<!DOCTYPE html>
|
||||
<html><head><meta charset="utf-8" /><title>Booking not found</title></head>
|
||||
<body style="font-family: sans-serif; text-align: center; padding: 60px 20px; color: #6b7280;">
|
||||
<h2>Booking not found</h2>
|
||||
<p>This link may have expired or the booking may have been cancelled.</p>
|
||||
</body></html>
|
||||
""";
|
||||
|
||||
private static string Encode(string? value) => WebUtility.HtmlEncode(value ?? "");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
// BusinessLogoId (ClientBusiness) holds a relative /Uploads path, not a public URL — there's
|
||||
// no static-file serving configured for it, so the only way to hand it to a client (web or
|
||||
// mobile) is to read the bytes server-side and inline them as a base64 data URL.
|
||||
public static class BusinessLogoHelper
|
||||
{
|
||||
public static async Task<string?> ReadAsDataUrlAsync(string? businessLogoId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(businessLogoId)) return null;
|
||||
|
||||
string uploadsRoot = Path.GetFullPath(Path.Combine(Directory.GetCurrentDirectory(), ".."));
|
||||
string relativePath = Uri.UnescapeDataString(businessLogoId).Replace("/", Path.DirectorySeparatorChar.ToString());
|
||||
string filePath = uploadsRoot + relativePath;
|
||||
if (!File.Exists(filePath)) return null;
|
||||
|
||||
byte[] bytes = await File.ReadAllBytesAsync(filePath);
|
||||
string ext = Path.GetExtension(filePath).TrimStart('.').ToLowerInvariant();
|
||||
string mime = ext == "png" ? "image/png" : ext == "webp" ? "image/webp" : "image/jpeg";
|
||||
return $"data:{mime};base64,{Convert.ToBase64String(bytes)}";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Models;
|
||||
using Fitness_core_BE.Models.Enums;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
public static class CustomerProgressHelper
|
||||
{
|
||||
public static CustomerTarget BuildCustomerTarget(
|
||||
Guid cusId,
|
||||
decimal currentWeight,
|
||||
decimal currentHeight,
|
||||
decimal? targetWeight,
|
||||
string? goal,
|
||||
decimal timePeriodMonths,
|
||||
ProgressPrediction predictions,
|
||||
CustomerTarget? latestTarget,
|
||||
DateTime createdAt)
|
||||
{
|
||||
var timePeriod = timePeriodMonths.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
|
||||
return new CustomerTarget
|
||||
{
|
||||
FKCusId = cusId,
|
||||
CurrentWeight = currentWeight,
|
||||
CurrentHeight = currentHeight,
|
||||
TargetWeight = targetWeight,
|
||||
Goal = goal,
|
||||
TimePeriod = timePeriod,
|
||||
PredictedNutritionsJson = JsonSerializer.Serialize(predictions.PredictedNutritions),
|
||||
PredictedWeightsJson = JsonSerializer.Serialize(predictions.PredictedWeights),
|
||||
IsChanged = latestTarget != null &&
|
||||
(latestTarget.CurrentWeight != currentWeight ||
|
||||
latestTarget.CurrentHeight != currentHeight ||
|
||||
latestTarget.TargetWeight != targetWeight ||
|
||||
latestTarget.Goal != goal ||
|
||||
latestTarget.TimePeriod != timePeriod),
|
||||
CreatedAt = createdAt,
|
||||
IsActive = true
|
||||
};
|
||||
}
|
||||
|
||||
public static object BuildCustomerProgressResponse(CustomerTarget customerTarget)
|
||||
{
|
||||
return new
|
||||
{
|
||||
ProgressId = customerTarget.CustomerTargetId,
|
||||
customerTarget.CustomerTargetId,
|
||||
customerTarget.CurrentWeight,
|
||||
customerTarget.CurrentHeight,
|
||||
customerTarget.TargetWeight,
|
||||
customerTarget.Goal,
|
||||
customerTarget.TimePeriod,
|
||||
customerTarget.CreatedAt,
|
||||
customerTarget.FKCusId,
|
||||
customerTarget.IsActive,
|
||||
TargetIsChanged = customerTarget.IsChanged,
|
||||
TargetCreatedAt = customerTarget.CreatedAt,
|
||||
PredictedNutritions = DeserializePayload.DeserializeJson<Dictionary<string, string>>(customerTarget.PredictedNutritionsJson),
|
||||
PredictedWeights = DeserializePayload.DeserializeJson<Dictionary<string, decimal>>(customerTarget.PredictedWeightsJson)
|
||||
};
|
||||
}
|
||||
|
||||
public static ProgressPrediction BuildProgressPredictions(
|
||||
decimal weight,
|
||||
decimal targetWeight,
|
||||
decimal height,
|
||||
string? goal,
|
||||
decimal timePeriodMonths,
|
||||
int age,
|
||||
string? gender,
|
||||
string? activityLevel)
|
||||
{
|
||||
ValidatePredictionInput(weight, targetWeight, height, timePeriodMonths, age);
|
||||
|
||||
// Normalize user choices before using them in scientific formulas.
|
||||
var goalType = GetGoalType(goal);
|
||||
var sex = GetSex(gender);
|
||||
var activity = GetActivityLevel(activityLevel);
|
||||
ValidateGoalDirection(goalType, weight, targetWeight);
|
||||
|
||||
// Convert the target period into weeks and calculate the requested weekly pace.
|
||||
var weeksAvailable = (double)timePeriodMonths * 4.33;
|
||||
var totalChange = goalType == ProgressGoalType.Maintain
|
||||
? 0.0
|
||||
: Math.Abs((double)(weight - targetWeight));
|
||||
var requiredWeeklyChange = weeksAvailable > 0
|
||||
? totalChange / weeksAvailable
|
||||
: 0.0;
|
||||
|
||||
// BMR uses Mifflin-St Jeor; TDEE applies the selected activity multiplier.
|
||||
var bmr = CalculateBmr(weight, height, age, sex);
|
||||
var tdee = bmr * activity.Multiplier;
|
||||
var dailyCalorieAdjustment = GetDailyCalorieAdjustment(goalType, requiredWeeklyChange);
|
||||
var direction = GetDirection(goalType);
|
||||
|
||||
// Apply calorie deficit, surplus, or maintenance based on the goal.
|
||||
var targetCalories = goalType switch
|
||||
{
|
||||
ProgressGoalType.Loss => tdee - dailyCalorieAdjustment,
|
||||
ProgressGoalType.Gain => tdee + dailyCalorieAdjustment,
|
||||
_ => tdee
|
||||
};
|
||||
|
||||
// Estimate weekly weight change using the 7700 kcal per kg approximation.
|
||||
var predictedWeeklyChange = dailyCalorieAdjustment <= 0
|
||||
? 0.0
|
||||
: (dailyCalorieAdjustment * 7.0) / 7700.0;
|
||||
|
||||
// Protein depends on goal; fat is 25% of calories; carbs use remaining calories.
|
||||
var proteinPerKg = GetProteinFactor(goalType, goal);
|
||||
var protein = Math.Round((double)weight * proteinPerKg, 2);
|
||||
var fat = Math.Round((targetCalories * 0.25) / 9.0, 2);
|
||||
var remainingCalories = targetCalories - (protein * 4.0 + fat * 9.0);
|
||||
var carbs = Math.Round(Math.Max(remainingCalories / 4.0, 0.0), 2);
|
||||
var bmi = Math.Round(CalculateBmi(weight, height), 2);
|
||||
var difficulty = DetermineDifficulty(requiredWeeklyChange);
|
||||
|
||||
// Build a week-by-week projection and prevent values from passing the target weight.
|
||||
var totalWeeks = Math.Max(1, (int)Math.Ceiling(weeksAvailable));
|
||||
var predictedWeights = new Dictionary<string, decimal>();
|
||||
|
||||
for (var week = 0; week <= totalWeeks; week++)
|
||||
{
|
||||
var predictedWeight = (double)weight + (direction * predictedWeeklyChange * week);
|
||||
predictedWeight = KeepPredictionInsideTarget(goalType, predictedWeight, (double)targetWeight);
|
||||
predictedWeights[$"w{week + 1}"] = (decimal)Math.Round(predictedWeight, 1);
|
||||
}
|
||||
|
||||
var signedCalorieAdjustment = direction * dailyCalorieAdjustment;
|
||||
|
||||
// Keep output as strings because this JSON is stored and returned directly to clients.
|
||||
var predictedNutritions = new Dictionary<string, string>
|
||||
{
|
||||
["calories"] = $"{FormatNumber(targetCalories)} kcal/day",
|
||||
["targetCalories"] = $"{FormatNumber(targetCalories)} kcal/day",
|
||||
["bmr"] = $"{FormatNumber(bmr)} kcal/day",
|
||||
["tdee"] = $"{FormatNumber(tdee)} kcal/day",
|
||||
["protein"] = $"{FormatNumber(protein)}g",
|
||||
["fat"] = $"{FormatNumber(fat)}g",
|
||||
["carbs"] = $"{FormatNumber(carbs)}g",
|
||||
["bmi"] = FormatNumber(bmi),
|
||||
["activityLevel"] = activity.Label,
|
||||
["activityMultiplier"] = FormatNumber(activity.Multiplier),
|
||||
["dailyCalorieChange"] = $"{FormatSignedNumber(signedCalorieAdjustment)} kcal/day",
|
||||
["requiredWeeklyWeightChange"] = $"{FormatNumber(requiredWeeklyChange)} kg/week",
|
||||
["predictedWeeklyWeightChange"] = $"{FormatNumber(predictedWeeklyChange)} kg/week",
|
||||
["difficulty"] = difficulty,
|
||||
["intensity"] = difficulty
|
||||
};
|
||||
|
||||
return new ProgressPrediction
|
||||
{
|
||||
PredictedNutritions = predictedNutritions,
|
||||
PredictedWeights = predictedWeights,
|
||||
Intensity = difficulty
|
||||
};
|
||||
}
|
||||
|
||||
// Re-projects just the weight trajectory (not the nutrition targets, which need
|
||||
// activityLevel — a one-time input never persisted on CustomerTarget) from the
|
||||
// customer's latest logged weight to their existing target, over however much of
|
||||
// the original plan window is left. Called whenever a weight update changes
|
||||
// CurrentWeight, so the "goal" line reflects real progress instead of staying
|
||||
// fixed to the weight recorded at onboarding.
|
||||
public static Dictionary<string, decimal> RebuildPredictedWeights(
|
||||
decimal currentWeight,
|
||||
decimal targetWeight,
|
||||
string? goal,
|
||||
double remainingWeeks)
|
||||
{
|
||||
var goalType = GetGoalType(goal);
|
||||
var direction = GetDirection(goalType);
|
||||
|
||||
var totalChange = goalType == ProgressGoalType.Maintain
|
||||
? 0.0
|
||||
: Math.Abs((double)(currentWeight - targetWeight));
|
||||
var weeklyChange = remainingWeeks > 0 ? totalChange / remainingWeeks : 0.0;
|
||||
|
||||
var totalWeeks = Math.Max(1, (int)Math.Ceiling(remainingWeeks));
|
||||
var predictedWeights = new Dictionary<string, decimal>();
|
||||
for (var week = 0; week <= totalWeeks; week++)
|
||||
{
|
||||
var predictedWeight = (double)currentWeight + (direction * weeklyChange * week);
|
||||
predictedWeight = KeepPredictionInsideTarget(goalType, predictedWeight, (double)targetWeight);
|
||||
predictedWeights[$"w{week + 1}"] = (decimal)Math.Round(predictedWeight, 1);
|
||||
}
|
||||
return predictedWeights;
|
||||
}
|
||||
|
||||
public static int GetProgressPredictionAge(Dictionary<string, JsonElement> data, Customer customer, DateTime asOf)
|
||||
{
|
||||
var age = DataExtractor.GetNullableInt(data, "age");
|
||||
|
||||
if (age.HasValue)
|
||||
{
|
||||
if (age.Value <= 0)
|
||||
throw new BadRequestException("Field 'age' must be greater than zero.");
|
||||
|
||||
return age.Value;
|
||||
}
|
||||
|
||||
if (!customer.BoD.HasValue)
|
||||
throw new BadRequestException("Field 'age' is required when customer date of birth is not available.");
|
||||
|
||||
var calculatedAge = CalculateAge(customer.BoD.Value, asOf);
|
||||
if (calculatedAge <= 0)
|
||||
throw new BadRequestException(
|
||||
$"The date of birth on this profile ({customer.BoD.Value:yyyy-MM-dd}) is today or in " +
|
||||
"the future, so an age can't be worked out. Update the date of birth on the profile, " +
|
||||
"or send an explicit 'age' with this request.");
|
||||
|
||||
return calculatedAge;
|
||||
}
|
||||
|
||||
public static string GetProgressPredictionGender(Dictionary<string, JsonElement> data, Customer customer)
|
||||
{
|
||||
var gender = DataExtractor.GetOptionalStringByKeys(data, "gender", "Gender") ?? customer.Gender;
|
||||
if (!string.IsNullOrWhiteSpace(gender))
|
||||
return gender;
|
||||
|
||||
throw new BadRequestException("Field 'gender' is required when customer gender is not available.");
|
||||
}
|
||||
|
||||
public static int CalculateAge(DateOnly birthDate, DateTime asOf)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(asOf);
|
||||
var age = today.Year - birthDate.Year;
|
||||
|
||||
if (birthDate > today.AddYears(-age))
|
||||
age--;
|
||||
|
||||
return age;
|
||||
}
|
||||
|
||||
public static bool IsMaintainGoal(string goal)
|
||||
{
|
||||
var normalizedGoal = NormalizeText(goal);
|
||||
return normalizedGoal is "maintain" or "maintain weight";
|
||||
}
|
||||
|
||||
private static void ValidatePredictionInput(
|
||||
decimal weight,
|
||||
decimal targetWeight,
|
||||
decimal height,
|
||||
decimal timePeriodMonths,
|
||||
int age)
|
||||
{
|
||||
if (weight <= 0)
|
||||
throw new ArgumentException("Field 'currentWeight' must be greater than zero.");
|
||||
|
||||
if (targetWeight <= 0)
|
||||
throw new ArgumentException("Field 'targetWeight' must be greater than zero.");
|
||||
|
||||
if (height <= 0)
|
||||
throw new ArgumentException("Field 'currentHeight' must be greater than zero.");
|
||||
|
||||
if (timePeriodMonths <= 0)
|
||||
throw new ArgumentException("Field 'timePeriod' must be greater than zero.");
|
||||
|
||||
if (age <= 0)
|
||||
throw new ArgumentException("Field 'age' must be greater than zero.");
|
||||
}
|
||||
|
||||
private static ProgressGoalType GetGoalType(string? goal)
|
||||
{
|
||||
var normalizedGoal = NormalizeText(goal);
|
||||
|
||||
return normalizedGoal switch
|
||||
{
|
||||
"lose weight" => ProgressGoalType.Loss,
|
||||
"gain muscle" => ProgressGoalType.Gain,
|
||||
"body recomposition" => ProgressGoalType.Maintain,
|
||||
"strength" => ProgressGoalType.Strength,
|
||||
"endurance" => ProgressGoalType.Endurance,
|
||||
"athletic performance" => ProgressGoalType.AthleticPerformance,
|
||||
"stay fit" => ProgressGoalType.StayFit,
|
||||
"flexibility" => ProgressGoalType.StayFit,
|
||||
"beginner" => ProgressGoalType.Beginner,
|
||||
|
||||
_ => throw new ArgumentException("Field 'goal' must be one of these")
|
||||
};
|
||||
}
|
||||
|
||||
private static Sex GetSex(string? gender)
|
||||
{
|
||||
var normalizedGender = NormalizeText(gender);
|
||||
|
||||
return normalizedGender switch
|
||||
{
|
||||
"male" or "m" => Sex.Male,
|
||||
"female" or "f" => Sex.Female,
|
||||
_ => throw new ArgumentException("Field 'gender' must be either 'Male' or 'Female' for BMR calculation.")
|
||||
};
|
||||
}
|
||||
|
||||
private static ActivityLevelInfo GetActivityLevel(string? activityLevel)
|
||||
{
|
||||
var normalizedActivityLevel = NormalizeText(activityLevel);
|
||||
|
||||
return normalizedActivityLevel switch
|
||||
{
|
||||
"low" => new ActivityLevelInfo("Lightly Active", 1.375),
|
||||
"moderate" => new ActivityLevelInfo("Moderately Active", 1.55),
|
||||
"high" => new ActivityLevelInfo("Very Active", 1.725),
|
||||
_ => throw new ArgumentException("Field 'activityLevel' must be one of: Lightly Active, Moderately Active, Very Active")
|
||||
};
|
||||
}
|
||||
|
||||
private static void ValidateGoalDirection(ProgressGoalType goalType, decimal weight, decimal targetWeight)
|
||||
{
|
||||
if (goalType == ProgressGoalType.Loss && targetWeight > weight)
|
||||
throw new ArgumentException("Field 'targetWeight' must be less than or equal to current weight for weight loss or fat loss goals.");
|
||||
|
||||
if (goalType == ProgressGoalType.Gain && targetWeight < weight)
|
||||
throw new ArgumentException("Field 'targetWeight' must be greater than or equal to current weight for weight gain or muscle gain goals.");
|
||||
}
|
||||
|
||||
private static double CalculateBmr(decimal weight, decimal height, int age, Sex sex)
|
||||
{
|
||||
var baseBmr = (10.0 * (double)weight) + (6.25 * (double)height) - (5.0 * age);
|
||||
return sex == Sex.Male
|
||||
? baseBmr + 5.0
|
||||
: baseBmr - 161.0;
|
||||
}
|
||||
|
||||
private static double CalculateBmi(decimal weight, decimal height)
|
||||
{
|
||||
var heightMeters = (double)height / 100.0;
|
||||
return (double)weight / (heightMeters * heightMeters);
|
||||
}
|
||||
|
||||
private static double GetDailyCalorieAdjustment(ProgressGoalType goalType, double requiredWeeklyChange)
|
||||
{
|
||||
var requestedDailyChange = (requiredWeeklyChange * 7700.0) / 7.0;
|
||||
|
||||
return goalType switch
|
||||
{
|
||||
ProgressGoalType.Loss => Math.Min(requestedDailyChange, 700.0),
|
||||
ProgressGoalType.Gain => Math.Min(requestedDailyChange, 400.0),
|
||||
_ => 0.0
|
||||
};
|
||||
}
|
||||
|
||||
private static double GetProteinFactor(ProgressGoalType goalType, string? goal)
|
||||
{
|
||||
var normalizedGoal = NormalizeText(goal);
|
||||
|
||||
if (normalizedGoal == "muscle gain")
|
||||
return 2.0;
|
||||
|
||||
return goalType switch
|
||||
{
|
||||
ProgressGoalType.Loss => 2.0,
|
||||
ProgressGoalType.Gain => 1.8,
|
||||
_ => 1.6
|
||||
};
|
||||
}
|
||||
|
||||
private static double GetDirection(ProgressGoalType goalType)
|
||||
{
|
||||
return goalType switch
|
||||
{
|
||||
ProgressGoalType.Loss => -1.0,
|
||||
ProgressGoalType.Gain => 1.0,
|
||||
_ => 0.0
|
||||
};
|
||||
}
|
||||
|
||||
private static double KeepPredictionInsideTarget(ProgressGoalType goalType, double predictedWeight, double targetWeight)
|
||||
{
|
||||
return goalType switch
|
||||
{
|
||||
ProgressGoalType.Loss => Math.Max(predictedWeight, targetWeight),
|
||||
ProgressGoalType.Gain => Math.Min(predictedWeight, targetWeight),
|
||||
_ => predictedWeight
|
||||
};
|
||||
}
|
||||
|
||||
private static string DetermineDifficulty(double requiredWeeklyChange)
|
||||
{
|
||||
return requiredWeeklyChange switch
|
||||
{
|
||||
<= 0.5 => "Easy",
|
||||
<= 0.75 => "Moderate",
|
||||
<= 1.0 => "Hard",
|
||||
_ => "Extreme / Unsafe"
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeText(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty)
|
||||
.Trim()
|
||||
.ToLowerInvariant()
|
||||
.Replace("-", " ")
|
||||
.Replace("_", " ");
|
||||
|
||||
return string.Join(' ', normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries));
|
||||
}
|
||||
|
||||
private static string FormatNumber(double value)
|
||||
{
|
||||
return value.ToString("0.##", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static string FormatSignedNumber(double value)
|
||||
{
|
||||
var sign = value > 0 ? "+" : string.Empty;
|
||||
return sign + FormatNumber(value);
|
||||
}
|
||||
|
||||
private readonly record struct ActivityLevelInfo(string Label, double Multiplier);
|
||||
|
||||
public static string GetPredictedNutritionsJson(Dictionary<string, JsonElement> data)
|
||||
{
|
||||
var nutritions = data.TryGetValue("predictedNutritions", out var element)
|
||||
? BuildNutritionDictionary(element)
|
||||
: BuildNutritionDictionary(data);
|
||||
|
||||
return JsonSerializer.Serialize(nutritions);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildNutritionDictionary(Dictionary<string, JsonElement> data)
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["calories"] = GetRequiredNutritionValueByKeys(data, "calories"),
|
||||
["protein"] = GetRequiredNutritionValue(data, "protein"),
|
||||
["fat"] = GetRequiredNutritionValue(data, "fat"),
|
||||
["carbs"] = GetRequiredNutritionValue(data, "carbs"),
|
||||
["intensity"] = GetRequiredNutritionValue(data, "intensity")
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BuildNutritionDictionary(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
throw new BadRequestException("Field 'predictedNutritions' must be a JSON object.");
|
||||
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["calories"] = GetRequiredNutritionValueByKeys(element, "calories"),
|
||||
["protein"] = GetRequiredNutritionValue(element, "protein"),
|
||||
["fat"] = GetRequiredNutritionValue(element, "fat"),
|
||||
["carbs"] = GetRequiredNutritionValue(element, "carbs"),
|
||||
["intensity"] = GetRequiredNutritionValue(element, "intensity")
|
||||
};
|
||||
}
|
||||
|
||||
private static string GetRequiredNutritionValueByKeys(Dictionary<string, JsonElement> data, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (data.TryGetValue(key, out var element))
|
||||
return ReadNutritionValue(element, key);
|
||||
}
|
||||
|
||||
throw new BadRequestException($"Missing required field '{keys[0]}'.");
|
||||
}
|
||||
|
||||
private static string GetRequiredNutritionValueByKeys(JsonElement source, params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (source.TryGetProperty(key, out var element))
|
||||
return ReadNutritionValue(element, key);
|
||||
}
|
||||
|
||||
throw new BadRequestException($"Missing required field '{keys[0]}'.");
|
||||
}
|
||||
|
||||
private static string GetRequiredNutritionValue(Dictionary<string, JsonElement> data, string key)
|
||||
{
|
||||
if (!data.TryGetValue(key, out var element))
|
||||
throw new BadRequestException($"Missing required field '{key}'.");
|
||||
|
||||
return ReadNutritionValue(element, key);
|
||||
}
|
||||
|
||||
private static string GetRequiredNutritionValue(JsonElement source, string key)
|
||||
{
|
||||
if (!source.TryGetProperty(key, out var element))
|
||||
throw new BadRequestException($"Missing required field '{key}'.");
|
||||
|
||||
return ReadNutritionValue(element, key);
|
||||
}
|
||||
|
||||
private static string ReadNutritionValue(JsonElement element, string key)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = element.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
}
|
||||
|
||||
if (element.ValueKind == JsonValueKind.Number)
|
||||
return element.GetRawText();
|
||||
|
||||
throw new BadRequestException($"Field '{key}' must be a non-empty string or number.");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public sealed class ProgressPrediction
|
||||
{
|
||||
public Dictionary<string, string> PredictedNutritions { get; set; } = new();
|
||||
public Dictionary<string, decimal> PredictedWeights { get; set; } = new();
|
||||
public string Intensity { get; set; } = "Moderate";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,407 @@
|
||||
using Fitness_core_BE.DTOs.Auth;
|
||||
using System.Text.Json;
|
||||
using Fitness_core_BE.Exceptions;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
public class DataExtractor
|
||||
{
|
||||
public static string GetRequiredString(
|
||||
Dictionary<string, JsonElement> data,
|
||||
string key)
|
||||
{
|
||||
if (!data.TryGetValue(key, out var element))
|
||||
throw new BadRequestException($"Missing required field '{key}'.");
|
||||
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
throw new BadRequestException($"Field '{key}' must be a string.");
|
||||
|
||||
var value = element.GetString();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return "";
|
||||
//throw new BadRequestException($"Field '{key}' cannot be empty.");
|
||||
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
public static string? GetOptionalString(
|
||||
Dictionary<string, JsonElement> data,
|
||||
string key)
|
||||
{
|
||||
if (!data.TryGetValue(key, out var element) || element.ValueKind == JsonValueKind.Null)
|
||||
return null;
|
||||
|
||||
if (element.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var value = element.GetString();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
|
||||
if (element.ValueKind == JsonValueKind.Number)
|
||||
return element.GetRawText();
|
||||
|
||||
throw new ArgumentException($"Field '{key}' must be a string.");
|
||||
}
|
||||
|
||||
public static string? GetOptionalStringByKeys(
|
||||
Dictionary<string, JsonElement> data,
|
||||
params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (data.ContainsKey(key))
|
||||
return GetOptionalString(data, key);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static string GetRequiredStringByKeys(
|
||||
Dictionary<string, JsonElement> data,
|
||||
params string[] keys)
|
||||
{
|
||||
var value = GetOptionalStringByKeys(data, keys);
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
|
||||
throw new BadRequestException($"Missing required field '{keys[0]}'.");
|
||||
}
|
||||
|
||||
// ===================== INT =====================
|
||||
|
||||
public static int GetRequiredInt(
|
||||
Dictionary<string, JsonElement> data,
|
||||
string key)
|
||||
{
|
||||
if (!data.TryGetValue(key, out var element))
|
||||
throw new BadRequestException($"Missing required field '{key}'.");
|
||||
|
||||
if (element.TryGetInt32(out var value))
|
||||
return value;
|
||||
|
||||
if (element.ValueKind == JsonValueKind.String &&
|
||||
int.TryParse(element.GetString(), out value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new BadRequestException($"Field '{key}' must be a valid integer.");
|
||||
}
|
||||
|
||||
public static int? GetNullableInt(
|
||||
Dictionary<string, JsonElement> data,
|
||||
string key)
|
||||
{
|
||||
if (!data.TryGetValue(key, out var element) || element.ValueKind == JsonValueKind.Null)
|
||||
return null;
|
||||
|
||||
if (element.TryGetInt32(out var value))
|
||||
return value;
|
||||
|
||||
if (element.ValueKind == JsonValueKind.String &&
|
||||
int.TryParse(element.GetString(), out value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new BadRequestException($"Field '{key}' must be a valid integer.");
|
||||
}
|
||||
|
||||
// ===================== FLOAT =====================
|
||||
|
||||
public static float GetRequiredFloat(
|
||||
Dictionary<string, JsonElement> data,
|
||||
string key)
|
||||
{
|
||||
if (!data.TryGetValue(key, out var element))
|
||||
throw new BadRequestException($"Missing required field '{key}'.");
|
||||
|
||||
if (element.TryGetSingle(out var value))
|
||||
return value;
|
||||
|
||||
// Allow numeric strings: "12.5"
|
||||
if (element.ValueKind == JsonValueKind.String &&
|
||||
float.TryParse(element.GetString(), out value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new BadRequestException($"Field '{key}' must be a valid float.");
|
||||
}
|
||||
|
||||
// ===================== DECIMAL =====================
|
||||
public static decimal GetRequiredDecimal(
|
||||
Dictionary<string, JsonElement> data,
|
||||
string key)
|
||||
{
|
||||
if (!data.TryGetValue(key, out var element))
|
||||
throw new ArgumentException($"Missing required field '{key}'.");
|
||||
if (element.TryGetDecimal(out var value))
|
||||
return value;
|
||||
// Allow numeric strings: "12.5"
|
||||
if (element.ValueKind == JsonValueKind.String &&
|
||||
decimal.TryParse(element.GetString(), out value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
throw new ArgumentException($"Field '{key}' must be a valid decimal.");
|
||||
}
|
||||
|
||||
public static decimal? GetOptionalDecimal(
|
||||
Dictionary<string, JsonElement> data,
|
||||
string key)
|
||||
{
|
||||
if (!data.TryGetValue(key, out var element) || element.ValueKind == JsonValueKind.Null)
|
||||
return null;
|
||||
|
||||
if (element.TryGetDecimal(out var value))
|
||||
return value;
|
||||
|
||||
if (element.ValueKind == JsonValueKind.String &&
|
||||
decimal.TryParse(element.GetString(), out value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new ArgumentException($"Field '{key}' must be a valid decimal.");
|
||||
}
|
||||
|
||||
public static decimal? GetOptionalDecimalByKeys(
|
||||
Dictionary<string, JsonElement> data,
|
||||
params string[] keys)
|
||||
{
|
||||
foreach (var key in keys)
|
||||
{
|
||||
if (data.ContainsKey(key))
|
||||
return GetOptionalDecimal(data, key);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===================== BOOLEAN =====================
|
||||
|
||||
public static bool GetRequiredBool(
|
||||
Dictionary<string, JsonElement> data,
|
||||
string key)
|
||||
{
|
||||
if (!data.TryGetValue(key, out var element))
|
||||
throw new BadRequestException($"Missing required field '{key}'.");
|
||||
|
||||
if (element.ValueKind == JsonValueKind.True) return true;
|
||||
if (element.ValueKind == JsonValueKind.False) return false;
|
||||
|
||||
// Allow string values: "true" / "false"
|
||||
if (element.ValueKind == JsonValueKind.String &&
|
||||
bool.TryParse(element.GetString(), out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new BadRequestException($"Field '{key}' must be a valid boolean.");
|
||||
}
|
||||
|
||||
public static bool? GetOptionalBool(
|
||||
Dictionary<string, JsonElement> data,
|
||||
string key)
|
||||
{
|
||||
if (!data.TryGetValue(key, out var element) || element.ValueKind == JsonValueKind.Null)
|
||||
return null;
|
||||
|
||||
if (element.ValueKind == JsonValueKind.True) return true;
|
||||
if (element.ValueKind == JsonValueKind.False) return false;
|
||||
|
||||
if (element.ValueKind == JsonValueKind.String &&
|
||||
bool.TryParse(element.GetString(), out var value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
throw new BadRequestException($"Field '{key}' must be a valid boolean.");
|
||||
}
|
||||
|
||||
// ===================== GUID =====================
|
||||
|
||||
public static Guid GetRequiredGuid(
|
||||
Dictionary<string, JsonElement> data,
|
||||
string key)
|
||||
{
|
||||
var value = GetRequiredString(data, key);
|
||||
|
||||
if (!Guid.TryParse(value, out var guid))
|
||||
throw new BadRequestException($"Field '{key}' must be a valid GUID.");
|
||||
|
||||
return guid;
|
||||
}
|
||||
|
||||
/// <summary>A JSON array of GUID strings, or null if the key is absent/null. An
|
||||
/// empty array is returned as an empty list (distinct from "field not sent"),
|
||||
/// which matters for "clear this plan's places" updates.</summary>
|
||||
public static List<Guid>? GetOptionalGuidList(
|
||||
Dictionary<string, JsonElement> data,
|
||||
string key)
|
||||
{
|
||||
if (!data.TryGetValue(key, out var element) || element.ValueKind == JsonValueKind.Null)
|
||||
return null;
|
||||
|
||||
if (element.ValueKind != JsonValueKind.Array)
|
||||
throw new BadRequestException($"Field '{key}' must be an array.");
|
||||
|
||||
var result = new List<Guid>();
|
||||
foreach (var item in element.EnumerateArray())
|
||||
{
|
||||
if (item.ValueKind != JsonValueKind.String || !Guid.TryParse(item.GetString(), out var guid))
|
||||
throw new BadRequestException($"Field '{key}' must contain only GUID strings.");
|
||||
result.Add(guid);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ===================== DATETIME =====================
|
||||
|
||||
public static DateTime GetRequiredDateTime(
|
||||
Dictionary<string, JsonElement> data,
|
||||
string key)
|
||||
{
|
||||
if (!data.TryGetValue(key, out var element))
|
||||
throw new BadRequestException($"Missing required field '{key}'.");
|
||||
|
||||
if (element.ValueKind == JsonValueKind.Null)
|
||||
throw new BadRequestException($"Field '{key}' cannot be null.");
|
||||
|
||||
// ISO 8601 string
|
||||
if (element.ValueKind == JsonValueKind.String &&
|
||||
DateTime.TryParse(element.GetString(), out var date))
|
||||
{
|
||||
return date;
|
||||
}
|
||||
|
||||
// Native JSON DateTime
|
||||
if (element.TryGetDateTime(out date))
|
||||
{
|
||||
return date;
|
||||
}
|
||||
|
||||
throw new BadRequestException($"Field '{key}' must be a valid DateTime.");
|
||||
}
|
||||
|
||||
public static DateTime? GetOptionalDateTime(
|
||||
Dictionary<string, JsonElement> data,
|
||||
string key)
|
||||
{
|
||||
if (!data.TryGetValue(key, out var element) || element.ValueKind == JsonValueKind.Null)
|
||||
return null;
|
||||
|
||||
if (element.ValueKind == JsonValueKind.String &&
|
||||
DateTime.TryParse(element.GetString(), out var date))
|
||||
{
|
||||
return date;
|
||||
}
|
||||
|
||||
if (element.TryGetDateTime(out date))
|
||||
return date;
|
||||
|
||||
throw new ArgumentException($"Field '{key}' must be a valid DateTime.");
|
||||
}
|
||||
|
||||
// ===================== DATEONLY =====================
|
||||
|
||||
public static DateOnly GetRequiredDateOnly(
|
||||
Dictionary<string, JsonElement> data,
|
||||
string key)
|
||||
{
|
||||
if (!data.TryGetValue(key, out var element))
|
||||
throw new BadRequestException($"Missing required field '{key}'.");
|
||||
|
||||
if (element.ValueKind == JsonValueKind.Null)
|
||||
throw new BadRequestException($"Field '{key}' cannot be null.");
|
||||
|
||||
DateTime date;
|
||||
|
||||
// ISO 8601 string (e.g., "2025-02-25")
|
||||
if (element.ValueKind == JsonValueKind.String &&
|
||||
DateTime.TryParse(element.GetString(), out date))
|
||||
{
|
||||
return DateOnly.FromDateTime(date);
|
||||
}
|
||||
|
||||
|
||||
if (element.TryGetDateTime(out date))
|
||||
{
|
||||
return DateOnly.FromDateTime(date);
|
||||
}
|
||||
|
||||
throw new BadRequestException($"Field '{key}' must be a valid date.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A date of birth, rejected if it is today or in the future. Progress predictions
|
||||
/// divide by the member's age, so a zero age has to be stopped at the write rather
|
||||
/// than surfacing later as a failure in an unrelated screen.
|
||||
/// </summary>
|
||||
public static DateOnly GetRequiredDateOfBirth(
|
||||
Dictionary<string, JsonElement> data,
|
||||
string key)
|
||||
{
|
||||
var birthDate = GetRequiredDateOnly(data, key);
|
||||
|
||||
if (birthDate >= DateOnly.FromDateTime(DateTime.UtcNow))
|
||||
throw new BadRequestException(
|
||||
$"Field '{key}' must be a date in the past.");
|
||||
|
||||
return birthDate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Same as GetRequiredDateOfBirth, but missing/null/blank is allowed and returns null
|
||||
/// instead of throwing -- a birthday isn't needed to run a subscription, so gyms that
|
||||
/// don't collect it at signup shouldn't be blocked from adding a member.
|
||||
/// </summary>
|
||||
public static DateOnly? GetOptionalDateOfBirth(
|
||||
Dictionary<string, JsonElement> data,
|
||||
string key)
|
||||
{
|
||||
if (!data.TryGetValue(key, out var element) || element.ValueKind == JsonValueKind.Null)
|
||||
return null;
|
||||
|
||||
if (element.ValueKind == JsonValueKind.String && string.IsNullOrWhiteSpace(element.GetString()))
|
||||
return null;
|
||||
|
||||
return GetRequiredDateOfBirth(data, key);
|
||||
}
|
||||
|
||||
// ===================== USER ID FROM HttpContext =====================
|
||||
public static Guid GetUserIdFromContext(HttpContext httpContext)
|
||||
{
|
||||
|
||||
if (httpContext == null) throw new ArgumentNullException(nameof(httpContext));
|
||||
|
||||
var userIdFromContext = httpContext.Items["UserId"]?.ToString()
|
||||
?? httpContext.User?.FindFirst("UserId")?.Value;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(userIdFromContext) || !Guid.TryParse(userIdFromContext, out var userId))
|
||||
{
|
||||
throw new UnauthorizedAccessException("User is not authenticated. Please login and send cookie.");
|
||||
}
|
||||
|
||||
return userId;
|
||||
}
|
||||
|
||||
//============================AuthResponce EXtractor============
|
||||
public static AuthResponse GetRequiredAuthResponse(Dictionary<string, JsonElement> data)
|
||||
{
|
||||
AuthResponse authResponse = new AuthResponse
|
||||
{
|
||||
StatusCode = GetRequiredInt(data, "StatusCode"),
|
||||
Success = GetRequiredBool(data, "success"),
|
||||
Message = GetRequiredString(data, "message"),
|
||||
Data = data
|
||||
};
|
||||
|
||||
return authResponse;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System.Text.Json;
|
||||
using Fitness_core_BE.Exceptions;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
public static class DeserializePayload
|
||||
{
|
||||
public static Dictionary<string, JsonElement> Deserialize(object payload)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(payload);
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
return JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json)
|
||||
?? throw new BadRequestException("Failed to deserialize payload.");
|
||||
}
|
||||
|
||||
public static T? DeserializeJson<T>(string? json) where T : class
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
public static class EncryptionHelper
|
||||
{
|
||||
private static readonly byte[] Key = Convert.FromBase64String("6pY8vR2Nf+Xz9WkLm4TqQzB7vA9uS1nE5jR3hG6fM8o=");
|
||||
|
||||
public static string Encrypt(string plainText)
|
||||
{
|
||||
using Aes aes = Aes.Create();
|
||||
aes.Key = Key;
|
||||
aes.GenerateIV();
|
||||
|
||||
using var encryptor = aes.CreateEncryptor();
|
||||
byte[] plainBytes = Encoding.UTF8.GetBytes(plainText);
|
||||
byte[] cipherBytes = encryptor.TransformFinalBlock(plainBytes, 0, plainBytes.Length);
|
||||
|
||||
return Convert.ToBase64String(aes.IV.Concat(cipherBytes).ToArray());
|
||||
}
|
||||
|
||||
public static string Decrypt(string combinedText)
|
||||
{
|
||||
byte[] combinedBytes = Convert.FromBase64String(combinedText);
|
||||
using Aes aes = Aes.Create();
|
||||
aes.Key = Key;
|
||||
|
||||
byte[] iv = combinedBytes.Take(16).ToArray();
|
||||
byte[] cipherBytes = combinedBytes.Skip(16).ToArray();
|
||||
|
||||
using var decryptor = aes.CreateDecryptor(aes.Key, iv);
|
||||
byte[] plainBytes = decryptor.TransformFinalBlock(cipherBytes, 0, cipherBytes.Length);
|
||||
|
||||
return Encoding.UTF8.GetString(plainBytes);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using Fitness_core_BE.Exceptions;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Server-side guard for face photos headed to a Hikvision face library.
|
||||
///
|
||||
/// The browser already resizes and re-encodes before upload, so this does not decode or
|
||||
/// re-encode -- it validates. That keeps the project free of an imaging package (and of
|
||||
/// ImageSharp's commercial-licence requirement) while still refusing anything a terminal
|
||||
/// would reject, including calls that bypass the web client entirely.
|
||||
///
|
||||
/// Limits follow the face-library constraints rather than the ISAPI transport ceiling:
|
||||
/// a 2.5 MB photo uploads happily and then fails enrolment, which is the worst outcome
|
||||
/// because it looks like it worked.
|
||||
/// </summary>
|
||||
public static class FacePhotoValidator
|
||||
{
|
||||
/// <summary>Face-library ceiling. Well under the ~3 MB ISAPI transport limit.</summary>
|
||||
public const int MaxBytes = 200 * 1024;
|
||||
|
||||
/// <summary>Below this the image is too small to hold a usable face.</summary>
|
||||
public const int MinDimension = 160;
|
||||
|
||||
/// <summary>Terminals reject oversized frames outright.</summary>
|
||||
public const int MaxDimension = 1920;
|
||||
|
||||
public sealed record FacePhotoInfo(byte[] Bytes, int Width, int Height);
|
||||
|
||||
/// <summary>
|
||||
/// Decodes the base64 payload (with or without a data: prefix) and enforces the
|
||||
/// Hikvision face-library rules. Throws BadRequestException with a message the
|
||||
/// front end can show directly.
|
||||
/// </summary>
|
||||
public static FacePhotoInfo ValidateBase64(string? base64)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(base64))
|
||||
throw new BadRequestException("A face photo is required.");
|
||||
|
||||
// Accept "data:image/jpeg;base64,..." as well as a bare payload.
|
||||
var payload = base64;
|
||||
var comma = payload.IndexOf(',');
|
||||
if (payload.StartsWith("data:", StringComparison.OrdinalIgnoreCase) && comma > 0)
|
||||
payload = payload[(comma + 1)..];
|
||||
|
||||
byte[] bytes;
|
||||
try
|
||||
{
|
||||
bytes = Convert.FromBase64String(payload.Trim());
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
throw new BadRequestException("The face photo is not valid base64 data.");
|
||||
}
|
||||
|
||||
if (bytes.Length == 0)
|
||||
throw new BadRequestException("The face photo is empty.");
|
||||
|
||||
if (bytes.Length > MaxBytes)
|
||||
throw new BadRequestException(
|
||||
$"The face photo is {bytes.Length / 1024} KB. The access terminal accepts " +
|
||||
$"at most {MaxBytes / 1024} KB -- retake or choose a smaller photo.");
|
||||
|
||||
// Trust the bytes, not the file name: JPEG starts FF D8 FF and ends FF D9.
|
||||
if (!IsJpeg(bytes))
|
||||
throw new BadRequestException(
|
||||
"The face photo must be a JPEG image. PNG, HEIC and other formats are " +
|
||||
"not accepted by the access terminal.");
|
||||
|
||||
var size = ReadJpegSize(bytes)
|
||||
?? throw new BadRequestException("The face photo is not a readable JPEG image.");
|
||||
|
||||
var (width, height) = size;
|
||||
|
||||
if (width < MinDimension || height < MinDimension)
|
||||
throw new BadRequestException(
|
||||
$"The face photo is {width}x{height}. It must be at least " +
|
||||
$"{MinDimension}x{MinDimension} pixels.");
|
||||
|
||||
if (width > MaxDimension || height > MaxDimension)
|
||||
throw new BadRequestException(
|
||||
$"The face photo is {width}x{height}. It must be no larger than " +
|
||||
$"{MaxDimension}x{MaxDimension} pixels.");
|
||||
|
||||
return new FacePhotoInfo(bytes, width, height);
|
||||
}
|
||||
|
||||
private static bool IsJpeg(byte[] bytes) =>
|
||||
bytes.Length >= 4 &&
|
||||
bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
|
||||
|
||||
/// <summary>
|
||||
/// Walks the JPEG marker segments to the start-of-frame, which carries the real
|
||||
/// dimensions. Returns null if the stream is truncated or malformed.
|
||||
/// </summary>
|
||||
private static (int Width, int Height)? ReadJpegSize(byte[] bytes)
|
||||
{
|
||||
// Skip the SOI marker.
|
||||
var i = 2;
|
||||
|
||||
while (i + 3 < bytes.Length)
|
||||
{
|
||||
if (bytes[i] != 0xFF)
|
||||
{
|
||||
i++; // Resynchronise past fill bytes / entropy data.
|
||||
continue;
|
||||
}
|
||||
|
||||
var marker = bytes[i + 1];
|
||||
|
||||
// Standalone markers carry no length payload.
|
||||
if (marker == 0xFF) { i++; continue; }
|
||||
if (marker == 0xD8 || marker == 0x01 || (marker >= 0xD0 && marker <= 0xD7))
|
||||
{
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Start of scan -- image data begins, no SOF found.
|
||||
if (marker == 0xDA || marker == 0xD9)
|
||||
return null;
|
||||
|
||||
if (i + 3 >= bytes.Length)
|
||||
return null;
|
||||
|
||||
var segmentLength = (bytes[i + 2] << 8) | bytes[i + 3];
|
||||
if (segmentLength < 2)
|
||||
return null;
|
||||
|
||||
// SOF0/1/2/3/5/6/7/9/10/11/13/14/15 hold the frame header. DHT (C4), JPGA (C8)
|
||||
// and DAC (CC) sit in the same range but are not frame headers.
|
||||
var isStartOfFrame =
|
||||
marker >= 0xC0 && marker <= 0xCF &&
|
||||
marker != 0xC4 && marker != 0xC8 && marker != 0xCC;
|
||||
|
||||
if (isStartOfFrame)
|
||||
{
|
||||
// [len:2][precision:1][height:2][width:2]
|
||||
if (i + 9 >= bytes.Length)
|
||||
return null;
|
||||
|
||||
var height = (bytes[i + 5] << 8) | bytes[i + 6];
|
||||
var width = (bytes[i + 7] << 8) | bytes[i + 8];
|
||||
|
||||
if (width <= 0 || height <= 0)
|
||||
return null;
|
||||
|
||||
return (width, height);
|
||||
}
|
||||
|
||||
i += 2 + segmentLength;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
// The app has no central exception->HTTP mapping today -- an unhandled NotFoundException/
|
||||
// BadRequestException/etc. (or the equivalent BCL KeyNotFoundException/ArgumentException
|
||||
// many services throw) 500s with a raw stack trace instead of the ApiResponse shape every
|
||||
// other error path returns. AgentController.cs already hand-rolls this exact mapping
|
||||
// per-action (see its catch blocks); this middleware applies the same mapping once, for
|
||||
// every controller, without having to add a try/catch to each action.
|
||||
public class GlobalExceptionMiddleware
|
||||
{
|
||||
private readonly RequestDelegate _next;
|
||||
private readonly ILogger<GlobalExceptionMiddleware> _logger;
|
||||
|
||||
public GlobalExceptionMiddleware(RequestDelegate next, ILogger<GlobalExceptionMiddleware> logger)
|
||||
{
|
||||
_next = next;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _next(context);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var (statusCode, message) = ex switch
|
||||
{
|
||||
NotFoundException or KeyNotFoundException => (404, ex.Message),
|
||||
BadRequestException or ArgumentException => (400, ex.Message),
|
||||
UnauthorizedException => (401, ex.Message),
|
||||
ForbiddenException => (403, ex.Message),
|
||||
ServiceUnavailableException => (503, ex.Message),
|
||||
_ => (500, "An unexpected error occurred."),
|
||||
};
|
||||
|
||||
if (statusCode == 500)
|
||||
_logger.LogError(ex, "Unhandled exception for {Method} {Path}", context.Request.Method, context.Request.Path);
|
||||
|
||||
if (context.Response.HasStarted)
|
||||
throw;
|
||||
|
||||
context.Response.Clear();
|
||||
context.Response.StatusCode = statusCode;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new ApiResponse<object>
|
||||
{
|
||||
StatusCode = statusCode,
|
||||
Message = message,
|
||||
Data = null!
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
public record EquipmentCount(string Name, int Count);
|
||||
|
||||
public class GymFacilitiesData
|
||||
{
|
||||
public bool HasAc { get; set; }
|
||||
public List<EquipmentCount> Equipment { get; set; } = new();
|
||||
}
|
||||
|
||||
public static class GymFacilities
|
||||
{
|
||||
private static readonly JsonSerializerOptions Opts = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public static GymFacilitiesData Parse(string? facilities)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(facilities)) return new GymFacilitiesData();
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<GymFacilitiesData>(facilities, Opts) ?? new GymFacilitiesData();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Older records may hold plain-text facilities — treat as no structured data.
|
||||
return new GymFacilitiesData();
|
||||
}
|
||||
}
|
||||
|
||||
public static string Serialize(bool hasAc, IEnumerable<EquipmentCount>? equipment)
|
||||
{
|
||||
var data = new GymFacilitiesData
|
||||
{
|
||||
HasAc = hasAc,
|
||||
Equipment = (equipment ?? Enumerable.Empty<EquipmentCount>())
|
||||
.Where(e => !string.IsNullOrWhiteSpace(e.Name))
|
||||
.GroupBy(e => e.Name.Trim(), StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => new EquipmentCount(
|
||||
group.First().Name.Trim(),
|
||||
(int)Math.Min(
|
||||
group.Sum(item => Math.Max(0L, item.Count)),
|
||||
int.MaxValue)))
|
||||
.ToList()
|
||||
};
|
||||
return JsonSerializer.Serialize(data, Opts);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
// Mints/validates the short-lived token used by the public "pay by link" page
|
||||
// (GET/POST /api/client/pay/{token}). Same self-contained HMAC approach as
|
||||
// RequestAccessTokenHelper, just scoped to a single CustomerInvoiceId.
|
||||
public class InvoicePaymentTokenHelper
|
||||
{
|
||||
private readonly string _signingKey;
|
||||
private readonly int _expiresInDays;
|
||||
|
||||
public InvoicePaymentTokenHelper(IConfiguration configuration)
|
||||
{
|
||||
_signingKey = configuration["InvoicePaymentToken:SigningKey"]
|
||||
?? throw new InvalidOperationException("InvoicePaymentToken:SigningKey is not configured.");
|
||||
_expiresInDays = int.TryParse(configuration["InvoicePaymentToken:ExpiresInDays"], out var d) ? d : 7;
|
||||
}
|
||||
|
||||
public string Generate(Guid customerInvoiceId)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_signingKey));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("CustomerInvoiceId", customerInvoiceId.ToString()),
|
||||
new Claim("Purpose", "InvoicePayment")
|
||||
};
|
||||
|
||||
var expires = DateTime.UtcNow.AddDays(_expiresInDays);
|
||||
var token = new JwtSecurityToken(
|
||||
claims: claims,
|
||||
expires: expires,
|
||||
signingCredentials: creds);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
public Guid? ValidateAndGetInvoiceId(string token)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_signingKey));
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
|
||||
try
|
||||
{
|
||||
var principal = handler.ValidateToken(token, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.FromMinutes(2)
|
||||
}, out _);
|
||||
|
||||
if (principal.FindFirst("Purpose")?.Value != "InvoicePayment")
|
||||
return null;
|
||||
|
||||
var idClaim = principal.FindFirst("CustomerInvoiceId")?.Value;
|
||||
return Guid.TryParse(idClaim, out var id) ? id : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
public class JwtMiddleware
|
||||
{
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public class CustomMiddlewareAttribute : Attribute, IAsyncActionFilter
|
||||
{
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var config = context.HttpContext.RequestServices.GetService<IConfiguration>();
|
||||
|
||||
string token = null;
|
||||
|
||||
var authHeader = context.HttpContext.Request.Headers["MobileAuthToken"].FirstOrDefault();
|
||||
if (!string.IsNullOrWhiteSpace(authHeader) && authHeader.StartsWith("Bearer "))
|
||||
{
|
||||
token = authHeader.Substring("Bearer ".Length).Trim();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
token = context.HttpContext.Request.Cookies["access_token"];
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = 401;
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = "Authentication cookie is missing",
|
||||
Data = null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var tokenHandler = new JwtSecurityTokenHandler();
|
||||
|
||||
var xmlKey = config["Jwt:PublicKey"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(xmlKey))
|
||||
throw new SecurityTokenException("JWT public key not configured.");
|
||||
|
||||
// Use FromXmlString (same approach as the Auth service's RsaKeyProvider)
|
||||
// to ensure identical key loading behavior
|
||||
var rsa = RSA.Create();
|
||||
rsa.FromXmlString(xmlKey);
|
||||
var rsaParameters = rsa.ExportParameters(false);
|
||||
|
||||
// Create RsaSecurityKey from RSAParameters directly (not from RSA object)
|
||||
// to avoid object lifecycle/disposal issues with IdentityModel v8+
|
||||
var key = new RsaSecurityKey(rsaParameters)
|
||||
{
|
||||
KeyId = ComputeKeyId(rsaParameters)
|
||||
};
|
||||
|
||||
var parameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ValidateIssuer = true,
|
||||
ValidIssuer = config["Jwt:Issuer"],
|
||||
ValidateAudience = true,
|
||||
ValidAudience = config["Jwt:Audience"],
|
||||
ValidateLifetime = true,
|
||||
ClockSkew = TimeSpan.FromMinutes(2)
|
||||
};
|
||||
|
||||
var principal = tokenHandler.ValidateToken(token, parameters, out SecurityToken _);
|
||||
|
||||
context.HttpContext.User = principal;
|
||||
|
||||
context.HttpContext.Items["UserId"] = principal.FindFirst("UserId")?.Value;
|
||||
context.HttpContext.Items["Email"] = principal.FindFirst("Email")?.Value;
|
||||
context.HttpContext.Items["UserType"] = principal.FindFirst("UserType")?.Value;
|
||||
context.HttpContext.Items["RoleId"] = principal.FindFirst("RoleId")?.Value;
|
||||
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = 401;
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 401,
|
||||
Message = "Invalid or expired token in cookie",
|
||||
Data = null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
private static string ComputeKeyId(RSAParameters publicParameters)
|
||||
{
|
||||
var modulus = publicParameters.Modulus ?? Array.Empty<byte>();
|
||||
var exponent = publicParameters.Exponent ?? Array.Empty<byte>();
|
||||
var payload = $"{Convert.ToBase64String(modulus)}.{Convert.ToBase64String(exponent)}";
|
||||
var hash = SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload));
|
||||
return Base64UrlEncoder.Encode(hash);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
// Mints/validates the short-lived token for the public "pay by link" page used for
|
||||
// platform (HexaFitness -> gym) invoices. Parallel to InvoicePaymentTokenHelper
|
||||
// (which is scoped to member-level CustomerInvoice) rather than a shared/generalized
|
||||
// helper, to avoid touching the token shape already relied on by live member links.
|
||||
public class PlatformInvoicePaymentTokenHelper
|
||||
{
|
||||
private readonly string _signingKey;
|
||||
private readonly int _expiresInDays;
|
||||
|
||||
public PlatformInvoicePaymentTokenHelper(IConfiguration configuration)
|
||||
{
|
||||
_signingKey = configuration["InvoicePaymentToken:SigningKey"]
|
||||
?? throw new InvalidOperationException("InvoicePaymentToken:SigningKey is not configured.");
|
||||
_expiresInDays = int.TryParse(configuration["InvoicePaymentToken:ExpiresInDays"], out var d) ? d : 7;
|
||||
}
|
||||
|
||||
public string Generate(Guid clientInvoiceId)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_signingKey));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("ClientInvoiceId", clientInvoiceId.ToString()),
|
||||
new Claim("Purpose", "PlatformInvoicePayment")
|
||||
};
|
||||
|
||||
var expires = DateTime.UtcNow.AddDays(_expiresInDays);
|
||||
var token = new JwtSecurityToken(claims: claims, expires: expires, signingCredentials: creds);
|
||||
|
||||
return new JwtSecurityTokenHandler().WriteToken(token);
|
||||
}
|
||||
|
||||
public Guid? ValidateAndGetInvoiceId(string token)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_signingKey));
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
|
||||
try
|
||||
{
|
||||
var principal = handler.ValidateToken(token, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.FromMinutes(2)
|
||||
}, out _);
|
||||
|
||||
if (principal.FindFirst("Purpose")?.Value != "PlatformInvoicePayment")
|
||||
return null;
|
||||
|
||||
var idClaim = principal.FindFirst("ClientInvoiceId")?.Value;
|
||||
return Guid.TryParse(idClaim, out var id) ? id : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
// Mints/validates the short-lived "requestAccessToken" used between mobile-OTP verification
|
||||
// and onboarding-request creation. Separate from the RSA-signed AuthHex tokens validated by
|
||||
// CustomMiddleware, since this repo only holds the AuthHex *public* key (no local RSA signing key).
|
||||
public class RequestAccessTokenHelper
|
||||
{
|
||||
private readonly string _signingKey;
|
||||
private readonly int _expiresInMinutes;
|
||||
|
||||
public RequestAccessTokenHelper(IConfiguration configuration)
|
||||
{
|
||||
_signingKey = configuration["RequestAccessToken:SigningKey"]
|
||||
?? throw new InvalidOperationException("RequestAccessToken:SigningKey is not configured.");
|
||||
_expiresInMinutes = int.TryParse(configuration["RequestAccessToken:ExpiresInMinutes"], out var m) ? m : 15;
|
||||
}
|
||||
|
||||
public (string token, int expiresIn) Generate(string mobileNumber)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_signingKey));
|
||||
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
|
||||
|
||||
var claims = new[]
|
||||
{
|
||||
new Claim("MobileNumber", mobileNumber),
|
||||
new Claim("Purpose", "RequestAccess")
|
||||
};
|
||||
|
||||
var expires = DateTime.UtcNow.AddMinutes(_expiresInMinutes);
|
||||
var token = new JwtSecurityToken(
|
||||
claims: claims,
|
||||
expires: expires,
|
||||
signingCredentials: creds);
|
||||
|
||||
return (new JwtSecurityTokenHandler().WriteToken(token), _expiresInMinutes * 60);
|
||||
}
|
||||
|
||||
public string? ValidateAndGetMobileNumber(string token)
|
||||
{
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_signingKey));
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
|
||||
try
|
||||
{
|
||||
var principal = handler.ValidateToken(token, new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = false,
|
||||
ValidateAudience = false,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
IssuerSigningKey = key,
|
||||
ClockSkew = TimeSpan.FromMinutes(2)
|
||||
}, out _);
|
||||
|
||||
if (principal.FindFirst("Purpose")?.Value != "RequestAccess")
|
||||
return null;
|
||||
|
||||
return principal.FindFirst("MobileNumber")?.Value;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
// Gates the subscription-billing worker endpoint behind a single shared secret in the
|
||||
// X-Worker-Key header. Distinct from JwtMiddleware/CustomMiddleware (human/agent session
|
||||
// auth) and from PaymentService:InternalApiKey (Core's own outbound key to Payment-Service) —
|
||||
// this authenticates the trusted scheduler calling back into Core. Writes the 401/503
|
||||
// response directly (like RequiresPermissionAttribute) rather than throwing, because Core
|
||||
// has no global exception handler to turn a thrown exception into the right status code on
|
||||
// this unauthenticated route.
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
|
||||
public sealed class RequireWorkerKeyAttribute : Attribute, IAsyncActionFilter
|
||||
{
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var configuration = context.HttpContext.RequestServices.GetService(typeof(IConfiguration)) as IConfiguration;
|
||||
var configuredKey = configuration?["Worker:InternalApiKey"];
|
||||
|
||||
if (string.IsNullOrWhiteSpace(configuredKey))
|
||||
{
|
||||
await WriteErrorAsync(context, 503, "Worker:InternalApiKey is not configured.");
|
||||
return;
|
||||
}
|
||||
|
||||
var providedKey = context.HttpContext.Request.Headers["X-Worker-Key"].FirstOrDefault();
|
||||
if (string.IsNullOrWhiteSpace(providedKey) || !FixedTimeEquals(providedKey, configuredKey))
|
||||
{
|
||||
await WriteErrorAsync(context, 401, "Invalid worker key.");
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
|
||||
private static async Task WriteErrorAsync(ActionExecutingContext context, int statusCode, string message)
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = statusCode;
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(new ApiResponse<object>
|
||||
{
|
||||
StatusCode = statusCode,
|
||||
Message = message,
|
||||
Data = null
|
||||
});
|
||||
}
|
||||
|
||||
private static bool FixedTimeEquals(string a, string b)
|
||||
{
|
||||
return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using Fitness_core_BE.Interfaces.Services;
|
||||
using Fitness_core_BE.Models.IOs;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
// Gates an action/controller behind one nav-section permission key. Must run AFTER
|
||||
// CustomMiddlewareAttribute (which populates HttpContext.Items["UserId"]) — guaranteed by
|
||||
// Order below, since CustomMiddlewareAttribute has no IOrderedFilter and runs at the
|
||||
// ASP.NET Core default Order (0), regardless of attribute declaration order on the method.
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
||||
public class RequiresPermissionAttribute : Attribute, IAsyncActionFilter, IOrderedFilter
|
||||
{
|
||||
private readonly string _navKey;
|
||||
|
||||
public int Order => 10;
|
||||
|
||||
public RequiresPermissionAttribute(string navKey)
|
||||
{
|
||||
_navKey = navKey;
|
||||
}
|
||||
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var permissionService = context.HttpContext.RequestServices.GetService(typeof(IPermissionService)) as IPermissionService;
|
||||
var ctx = await permissionService!.GetPermissionContextAsync(context.HttpContext);
|
||||
|
||||
if (!ctx.IsOwner && !ctx.AllowedNavKeys.Contains(_navKey))
|
||||
{
|
||||
context.HttpContext.Response.StatusCode = 403;
|
||||
await context.HttpContext.Response.WriteAsJsonAsync(new ApiResponse<object>
|
||||
{
|
||||
StatusCode = 403,
|
||||
Message = "You don't have permission to access this feature.",
|
||||
Data = null
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await next();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
// Short, shareable /sandbox/{token} code — replaces the old raw Guid. Made of a
|
||||
// time-derived prefix (minutes since a fixed epoch, base62) so codes drift forward
|
||||
// over time, plus a random suffix for uniqueness among requests created in the same
|
||||
// minute. Not a security boundary: the sandbox is a temporary trial state, not a
|
||||
// login credential.
|
||||
public static class SandboxTokenHelper
|
||||
{
|
||||
private const string Alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
private const int TimePartLength = 5;
|
||||
private const int RandomPartLength = 5;
|
||||
private static readonly DateTime Epoch = new(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
public static string Generate(DateTime createdAtUtc)
|
||||
{
|
||||
long minutesSinceEpoch = Math.Max(0, (long)(createdAtUtc - Epoch).TotalMinutes);
|
||||
string timePart = ToBaseAlphabet(minutesSinceEpoch).PadLeft(TimePartLength, '0');
|
||||
if (timePart.Length > TimePartLength)
|
||||
timePart = timePart[^TimePartLength..];
|
||||
|
||||
return timePart + RandomSuffix(RandomPartLength);
|
||||
}
|
||||
|
||||
private static string RandomSuffix(int length)
|
||||
{
|
||||
var bytes = new byte[length];
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
var sb = new StringBuilder(length);
|
||||
foreach (var b in bytes)
|
||||
sb.Append(Alphabet[b % Alphabet.Length]);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string ToBaseAlphabet(long value)
|
||||
{
|
||||
if (value <= 0) return "0";
|
||||
var sb = new StringBuilder();
|
||||
while (value > 0)
|
||||
{
|
||||
sb.Insert(0, Alphabet[(int)(value % Alphabet.Length)]);
|
||||
value /= Alphabet.Length;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using Fitness_core_BE.Infra.Tenancy;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
// Resolves the request's TenantId into the scoped ITenantContext, so downstream
|
||||
// repos/services can read it instead of re-deriving ClientId ad hoc. Must run AFTER
|
||||
// CustomMiddlewareAttribute (order 0, populates HttpContext.Items["UserId"]) and BEFORE
|
||||
// RequiresPermissionAttribute (order 10) — Order = 5 guarantees this regardless of
|
||||
// attribute declaration order on the method, same technique RequiresPermissionAttribute
|
||||
// itself uses.
|
||||
//
|
||||
// Single-club deployment: everyone (Client owner, Staff, Customer) resolves to the same
|
||||
// one Client row. GYM's split served many gyms, so a Customer's active gym was picked
|
||||
// per-request from a caller-supplied X-Tenant-Id header, independently verified against
|
||||
// the DB. There's only one gym here, so that header/verification step is gone — a
|
||||
// Customer always resolves to the one club, the same way Client/Staff already did.
|
||||
// Admin: platform-wide, no tenant.
|
||||
[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
|
||||
public class TenantResolutionAttribute : Attribute, IAsyncActionFilter, IOrderedFilter
|
||||
{
|
||||
public int Order => 5;
|
||||
|
||||
public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
|
||||
{
|
||||
var httpContext = context.HttpContext;
|
||||
var userRoleHelper = httpContext.RequestServices.GetRequiredService<UserRoleHelper>();
|
||||
var tenantContext = httpContext.RequestServices.GetRequiredService<ITenantContext>();
|
||||
|
||||
var userId = DataExtractor.GetUserIdFromContext(httpContext);
|
||||
var userType = await userRoleHelper.GetUserType(userId);
|
||||
|
||||
if (userType == "Admin")
|
||||
{
|
||||
tenantContext.SetTenant(null, isPlatformAdmin: true);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
if (userType == "Client" || userType == "Staff")
|
||||
{
|
||||
var clientId = await userRoleHelper.GetClientId(userId);
|
||||
tenantContext.SetTenant(clientId, isPlatformAdmin: false);
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Customer -- resolves to the one club, no header needed.
|
||||
var defaultClientId = await userRoleHelper.GetDefaultClientId();
|
||||
tenantContext.SetTenant(defaultClientId, isPlatformAdmin: false);
|
||||
await next();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
using Fitness_core_BE.Exceptions;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Fitness_core_BE.Helpers
|
||||
{
|
||||
public class UserRoleHelper
|
||||
{
|
||||
// Admins/Customer/Client are central-bucket; Staff and CustomerSubscriptions are
|
||||
// tenant-bucket.
|
||||
private readonly CentralDbContext _central;
|
||||
private readonly TenantDbContext _context;
|
||||
|
||||
public UserRoleHelper(CentralDbContext central, TenantDbContext context)
|
||||
{
|
||||
_central = central;
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<Guid?> GetAdminId(Guid userId)
|
||||
{
|
||||
var admin = await _central.Admins
|
||||
.AsNoTracking()
|
||||
.Where(a => a.FKUserId == userId && a.IsActive)
|
||||
.Select(a => a.AdminId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return admin == Guid.Empty ? null : admin;
|
||||
}
|
||||
public async Task<Guid> GetCusId(Guid userId)
|
||||
{
|
||||
var cus = await _central.Customer
|
||||
.AsNoTracking()
|
||||
.Where(a => a.FKUserId == userId && a.IsActive)
|
||||
.Select(a => a.CusId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (cus == Guid.Empty)
|
||||
throw new NotFoundException("Customer not found or inactive");
|
||||
|
||||
return cus;
|
||||
}
|
||||
|
||||
public async Task<Guid?> GetStaffId(Guid userId)
|
||||
{
|
||||
var staff = await _context.Staff
|
||||
.AsNoTracking()
|
||||
.Where(s => s.FKUserId == userId && s.IsActive)
|
||||
.Select(s => s.StaffId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return staff == Guid.Empty ? null : staff;
|
||||
}
|
||||
|
||||
public async Task<Guid> GetClientId(Guid userId)
|
||||
{
|
||||
var clientId = await _central.Client
|
||||
.AsNoTracking()
|
||||
.Where(c => c.FKUserId == userId)
|
||||
.Select(c => c.ClientId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
// Staff can use the same tenant-scoped dashboard and real-time hub.
|
||||
if (clientId == Guid.Empty)
|
||||
{
|
||||
clientId = await _context.Staff
|
||||
.AsNoTracking()
|
||||
.Where(s => s.FKUserId == userId && s.IsActive)
|
||||
.Select(s => s.FKClientId)
|
||||
.FirstOrDefaultAsync();
|
||||
}
|
||||
|
||||
if (clientId == Guid.Empty)
|
||||
throw new NotFoundException("Client not found");
|
||||
|
||||
return clientId;
|
||||
}
|
||||
|
||||
// Strict owner lookup used by RBAC decisions. GetClientId intentionally falls
|
||||
// back to a staff member's tenant so normal tenant-scoped data queries work;
|
||||
// that fallback must never make staff users appear to be gym owners.
|
||||
public async Task<Guid?> GetOwnedClientId(Guid userId)
|
||||
{
|
||||
var clientId = await _central.Client
|
||||
.AsNoTracking()
|
||||
.Where(c => c.FKUserId == userId)
|
||||
.Select(c => c.ClientId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
return clientId == Guid.Empty ? null : clientId;
|
||||
}
|
||||
|
||||
// Single-club deployment: exactly one Client row exists, so any caller that just needs
|
||||
// "the club" (rather than a specific user's club) can use this instead of a per-user
|
||||
// lookup. Used by TenantResolutionAttribute's Customer branch, which used to read a
|
||||
// caller-supplied X-Tenant-Id header to pick from many gyms -- there's only one now.
|
||||
public async Task<Guid> GetDefaultClientId()
|
||||
{
|
||||
var clientId = await _central.Client
|
||||
.AsNoTracking()
|
||||
.Select(c => c.ClientId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (clientId == Guid.Empty)
|
||||
throw new NotFoundException("Club is not configured yet");
|
||||
|
||||
return clientId;
|
||||
}
|
||||
|
||||
public async Task<Guid> GetClientIdByCusId(Guid cusId)
|
||||
{
|
||||
var clientId = await _context.CustomerSubscriptions
|
||||
.AsNoTracking()
|
||||
.Where(s => s.FKCustomerId == cusId && s.IsActive)
|
||||
.Select(s => s.CustomerPlan.FKClientId)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (clientId == Guid.Empty)
|
||||
throw new NotFoundException("No active subscription found for customer");
|
||||
|
||||
return clientId;
|
||||
}
|
||||
|
||||
public async Task<string> GetUserType(Guid userId)
|
||||
{
|
||||
var isAdmin = await _central.Admins
|
||||
.AsNoTracking()
|
||||
.AnyAsync(a => a.FKUserId == userId && a.IsActive);
|
||||
if (isAdmin)
|
||||
return "Admin";
|
||||
var isCustomer = await _central.Customer
|
||||
.AsNoTracking()
|
||||
.AnyAsync(c => c.FKUserId == userId && c.IsActive);
|
||||
if (isCustomer)
|
||||
return "Customer";
|
||||
var isClient = await _central.Client
|
||||
.AsNoTracking()
|
||||
.AnyAsync(cl => cl.FKUserId == userId);
|
||||
if (isClient)
|
||||
return "Client";
|
||||
|
||||
var isStaff = await _context.Staff
|
||||
.AsNoTracking()
|
||||
.AnyAsync(s => s.FKUserId == userId && s.IsActive);
|
||||
if (isStaff)
|
||||
return "Staff";
|
||||
throw new NotFoundException("User role not found or inactive");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user