first commit

This commit is contained in:
Dhananjaya99
2026-09-28 11:44:26 +05:30
parent 721743ae6b
commit bffc9b1686
424 changed files with 46474 additions and 0 deletions
+43
View File
@@ -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('/', '_');
}
}
}
+11
View File
@@ -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 };
}
}
+50
View File
@@ -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;
}
}
}
+88
View File
@@ -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 ?? "");
}
}
+23
View File
@@ -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)}";
}
}
}
+514
View File
@@ -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";
}
}
+407
View File
@@ -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;
}
}
}
+31
View File
@@ -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;
}
}
}
}
+35
View File
@@ -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);
}
}
+157
View File
@@ -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;
}
}
}
+59
View File
@@ -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!
});
}
}
}
}
+49
View File
@@ -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);
}
}
}
+72
View File
@@ -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;
}
}
}
}
+114
View File
@@ -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;
}
}
}
}
+71
View File
@@ -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;
}
}
}
}
+55
View File
@@ -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));
}
}
}
+43
View File
@@ -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();
}
}
}
+50
View File
@@ -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();
}
}
}
+54
View File
@@ -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();
}
}
}
+149
View File
@@ -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");
}
}
}