Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions backend/MenuGreen.API/Controllers/FoodController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ public async Task<IActionResult> Search(
[FromQuery] string? region,
[FromQuery] bool? localOnly,
[FromQuery] string? mealContext,
[FromQuery] string? sort)
[FromQuery] string? sort,
[FromQuery] int? page,
[FromQuery] int? pageSize)
{
if (!ModelState.IsValid) return BadRequest(ModelState);

Expand All @@ -55,7 +57,7 @@ public async Task<IActionResult> Search(
var userId = TryGetUserId();
var result = await _foodService.SearchAsync(
keyword, minCalories, maxCalories, proteinLevel, maxPriceVnd, maxPrepTimeMin, category,
userId, allergyMode, region, localOnly, mealContext, sort);
userId, allergyMode, region, localOnly, mealContext, sort, page, pageSize);
return Ok(result);
}
catch (Exception ex)
Expand Down
6 changes: 4 additions & 2 deletions backend/MenuGreen.API/Controllers/IngredientController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,14 @@ public async Task<IActionResult> Search(
[FromQuery] string? keyword,
[FromQuery] string? category,
[FromQuery] bool? isActive,
[FromQuery] string? allergyMode)
[FromQuery] string? allergyMode,
[FromQuery] int? page,
[FromQuery] int? pageSize)
{
try
{
return Ok(await _ingredientService.SearchAsync(
keyword, category, isActive, TryGetUserId(), allergyMode));
keyword, category, isActive, TryGetUserId(), allergyMode, page, pageSize));
}
catch (Exception ex)
{
Expand Down
9 changes: 7 additions & 2 deletions backend/MenuGreen.API/Controllers/LuckyWheelController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,19 @@ public LuckyWheelController(ILuckyWheelService luckyWheelService)
/// Get 10 personalized non-duplicate foods for the lucky wheel display.
/// </summary>
[HttpGet("foods")]
public async Task<IActionResult> GetWheelFoods()
public async Task<IActionResult> GetWheelFoods([FromQuery] int? maxPriceVnd = null)
{
if (!Guid.TryParse(User.FindFirstValue(ClaimTypes.NameIdentifier), out var userId))
{
return Unauthorized();
}

var result = await _luckyWheelService.GetWheelFoodsAsync(userId);
if (maxPriceVnd is <= 0)
{
return BadRequest(new { message = "Ngân sách phải lớn hơn 0." });
}

var result = await _luckyWheelService.GetWheelFoodsAsync(userId, maxPriceVnd);
return Ok(result);
}

Expand Down
22 changes: 22 additions & 0 deletions backend/MenuGreen.API/Controllers/NutritionTrackingController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public NutritionTrackingController(
public async Task<IActionResult> GetMealLogs([FromQuery] int page = 1, [FromQuery] int pageSize = 20)
{
if (!TryGetUserId(out var userId)) return Unauthorized();
await _mealPlanService.SyncCompletedItemsToMealLogsAsync(userId);
return Ok(await _service.GetMealLogsAsync(userId, page, pageSize));
}

Expand Down Expand Up @@ -93,6 +94,10 @@ public async Task<IActionResult> DeleteMealLog(Guid mealLogId)
public async Task<IActionResult> GetMealLogsByRange([FromQuery] DateOnly startDate, [FromQuery] DateOnly endDate)
{
if (!TryGetUserId(out var userId)) return Unauthorized();
await _mealPlanService.SyncCompletedItemsToMealLogsAsync(
userId,
startDate,
endDate);
return Ok(await _service.GetMealLogsByRangeAsync(userId, startDate, endDate));
}

Expand Down Expand Up @@ -123,6 +128,10 @@ public async Task<IActionResult> GetTrends([FromQuery] DateOnly startDate, [From
public async Task<IActionResult> GetDaily([FromQuery] DateOnly date)
{
if (!TryGetUserId(out var userId)) return Unauthorized();
await _mealPlanService.SyncCompletedItemsToMealLogsAsync(
userId,
date,
date);
return Ok(await _service.GetDailySummaryAsync(userId, date));
}

Expand All @@ -133,6 +142,19 @@ public async Task<IActionResult> GetDaily([FromQuery] DateOnly date)
public async Task<IActionResult> GetDashboard([FromQuery] string range = "day", [FromQuery] DateOnly? startDate = null, [FromQuery] DateOnly? endDate = null)
{
if (!TryGetUserId(out var userId)) return Unauthorized();
var today = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(7));
var (syncFrom, syncTo) = startDate.HasValue && endDate.HasValue
? (startDate.Value, endDate.Value)
: range.Trim().ToLowerInvariant() switch
{
"week" => (today.AddDays(-6), today),
"month" => (new DateOnly(today.Year, today.Month, 1), today),
_ => (today, today)
};
await _mealPlanService.SyncCompletedItemsToMealLogsAsync(
userId,
syncFrom,
syncTo);
return Ok(await _service.GetDashboardAsync(userId, range, startDate, endDate));
}

Expand Down
6 changes: 4 additions & 2 deletions backend/MenuGreen.API/Controllers/RecipeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,14 @@ public async Task<IActionResult> Search(
[FromQuery] string? mealType,
[FromQuery] string? difficulty,
[FromQuery] bool? isActive,
[FromQuery] string? allergyMode)
[FromQuery] string? allergyMode,
[FromQuery] int? page,
[FromQuery] int? pageSize)
{
try
{
return Ok(await _recipeService.SearchAsync(
keyword, mealType, difficulty, isActive, TryGetUserId(), allergyMode));
keyword, mealType, difficulty, isActive, TryGetUserId(), allergyMode, page, pageSize));
}
catch (Exception ex)
{
Expand Down
6 changes: 4 additions & 2 deletions backend/MenuGreen.API/Controllers/UserMealPlanController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ public UserMealPlanController(IMealPlanService service)
}

[HttpGet]
public async Task<IActionResult> GetByDate([FromQuery] DateOnly date)
public async Task<IActionResult> GetByDate(
[FromQuery] DateOnly date,
[FromQuery] bool refresh = false)
{
if (!TryGetUserId(out var userId)) return Unauthorized();
var plan = await _service.GetByDateAsync(userId, date);
var plan = await _service.GetByDateAsync(userId, date, refresh);
if (plan == null) return NoContent();
return Ok(plan);
}
Expand Down
2 changes: 1 addition & 1 deletion backend/MenuGreen.API/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
: Path.Combine(builder.Environment.ContentRootPath, firebaseCredentialPath);
if (File.Exists(fullPath) && FirebaseApp.DefaultInstance == null)
{
FirebaseApp.Create(new AppOptions { Credential = GoogleCredential.FromFile(fullPath) });

Check warning on line 47 in backend/MenuGreen.API/Program.cs

View workflow job for this annotation

GitHub Actions / Build & Test

'GoogleCredential.FromFile(string)' is obsolete: 'This method is being deprecated because of a potential security risk. Use the methods in the CredentialFactory class instead. A GoogleCredential object can then be created by calling the .ToGoogleCredential() method on the returned specific credential. '

Check warning on line 47 in backend/MenuGreen.API/Program.cs

View workflow job for this annotation

GitHub Actions / Build & Test

'GoogleCredential.FromFile(string)' is obsolete: 'This method is being deprecated because of a potential security risk. Use the methods in the CredentialFactory class instead. A GoogleCredential object can then be created by calling the .ToGoogleCredential() method on the returned specific credential. '

Check warning on line 47 in backend/MenuGreen.API/Program.cs

View workflow job for this annotation

GitHub Actions / Build & Test

'GoogleCredential.FromFile(string)' is obsolete: 'This method is being deprecated because of a potential security risk. Use the methods in the CredentialFactory class instead. A GoogleCredential object can then be created by calling the .ToGoogleCredential() method on the returned specific credential. '

Check warning on line 47 in backend/MenuGreen.API/Program.cs

View workflow job for this annotation

GitHub Actions / Build & Test

'GoogleCredential.FromFile(string)' is obsolete: 'This method is being deprecated because of a potential security risk. Use the methods in the CredentialFactory class instead. A GoogleCredential object can then be created by calling the .ToGoogleCredential() method on the returned specific credential. '
}
}

Expand Down Expand Up @@ -105,7 +105,7 @@
options.AddPolicy("AdminOnly", policy => policy.RequireRole("Admin"));
options.AddPolicy(
"UserOnly",
policy => policy.RequireRole("Admin", "User", "Free", "Casual", "Gymer", "Office", "Coach")
policy => policy.RequireRole("Admin", "Free", "Casual", "Gymer", "Office", "Coach")
);
options.AddPolicy("CoachOnly", policy => policy.RequireRole("Coach", "Admin"));
options.AddPolicy(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,23 @@ public async Task ReconcilePaymentsAsync(CancellationToken stoppingToken)
};
await unitOfWork.SubscriptionTransactions.AddAsync(tx);

// Upgrade user role to Office when purchasing an Office plan
if (string.Equals(plan?.FeatureGroup, "office", StringComparison.OrdinalIgnoreCase) ||
string.Equals(plan?.Name, "Office", StringComparison.OrdinalIgnoreCase))
{
var user = await unitOfWork.Users.GetByIdAsync(subscription.UserId);
if (user != null)
{
var officeRole = (await unitOfWork.Roles.FindAsync(r => r.Name == "Office")).FirstOrDefault();
if (officeRole != null && user.RoleId != officeRole.Id)
{
user.RoleId = officeRole.Id;
user.UpdatedAt = DateTime.UtcNow;
unitOfWork.Users.Update(user);
}
}
}

// Notify user
var notifRequest = new NotificationSendRequest
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,6 @@ namespace MenuGreen.BusinessLogicLayer.DTOs.Requests
public class AssignRoleRequest
{
[Required]
public string Role { get; set; } = string.Empty; // e.g., "Admin", "User", "Manager"
public string Role { get; set; } = string.Empty; // e.g., "Admin", "Free", "Casual", "Gymer", "Office", "Coach"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,12 @@ public class BalanceMealPlanCaloriesRequest

[MinLength(1)]
public List<Guid> ItemIds { get; set; } = new();

/// <summary>
/// Scale the selected portions without replacing the configured daily
/// nutrition target. Gymer uses this when choosing an intake below or
/// above the recommended target.
/// </summary>
public bool PreservePlanTarget { get; set; }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,48 @@

namespace MenuGreen.BusinessLogicLayer.DTOs.Requests
{
public class DailyStarterMealItem
public class DailyStarterMealItem : IValidatableObject
{
[Required]
public Guid FoodId { get; set; }
public Guid? FoodId { get; set; }

[StringLength(200)]
public string? CustomName { get; set; }

[Range(typeof(decimal), "0", "10000")]
public decimal? CaloriesKcal { get; set; }

[Range(typeof(decimal), "0", "1000")]
public decimal? ProteinG { get; set; }

[Range(typeof(decimal), "0", "1000")]
public decimal? CarbsG { get; set; }

[Range(typeof(decimal), "0", "1000")]
public decimal? FatG { get; set; }

[Range(typeof(decimal), "0.01", "10000")]
public decimal? QuantityG { get; set; }

[Required]
[RegularExpression("^(Breakfast|Lunch|Dinner|Snack)$", ErrorMessage = "MealType must be Breakfast, Lunch, Dinner, or Snack.")]
public string MealType { get; set; } = "Breakfast"; // Breakfast, Lunch, Dinner, Snack

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (!FoodId.HasValue && string.IsNullOrWhiteSpace(CustomName))
{
yield return new ValidationResult(
"FoodId or CustomName is required.",
new[] { nameof(FoodId), nameof(CustomName) });
}

if (!FoodId.HasValue && !CaloriesKcal.HasValue)
{
yield return new ValidationResult(
"CaloriesKcal is required for a custom meal.",
new[] { nameof(CaloriesKcal) });
}
}
}

public class DailyStarterSelectMealRequest
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ public class MealLogUpsertRequest : IValidatableObject
public string? Notes { get; set; }
[MaxLength(200)]
public string? CustomName { get; set; }
[MaxLength(50)]
public string? SourceType { get; set; }
public DateTime? LoggedAt { get; set; }
public Guid? MealPlanItemId { get; set; }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ public class MealPlanItemUpsertRequest

public string? CustomName { get; set; }
public double? QuantityG { get; set; }
public decimal? ProteinG { get; set; }
public decimal? CarbsG { get; set; }
public decimal? FatG { get; set; }
public string? SourceType { get; set; }
public List<MealPlanIngredientPortionRequest>? Ingredients { get; set; }
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ public class RegisterRequest
public string Password { get; set; } = string.Empty;

[Required(ErrorMessage = "Account type is required.")]
[RegularExpression("(?i)^(User|PT)$", ErrorMessage = "Account type must be User or PT.")]
public string AccountType { get; set; } = "User";
[RegularExpression("(?i)^(Free|PT)$", ErrorMessage = "Account type must be Free or PT.")]
public string AccountType { get; set; } = "Free";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,10 @@ public class FoodSearchResponse
{
public List<FoodResponse> Items { get; set; } = new();
public int TotalCount { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; }
public int TotalPages => PageSize > 0
? (int)System.Math.Ceiling((double)TotalCount / PageSize)
: 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,10 @@ public class IngredientSearchResponse
{
public List<IngredientResponse> Items { get; set; } = new();
public int TotalCount { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; }
public int TotalPages => PageSize > 0
? (int)System.Math.Ceiling((double)TotalCount / PageSize)
: 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ public class MealTemplateItemResponse
public Guid? RecipeId { get; set; }
public string? CustomName { get; set; }
public string? SourceType { get; set; }
public string? Name { get; set; }
public string MealType { get; set; } = "Snack";
public decimal QuantityG { get; set; }
public List<OfficeScanIngredientRequest> Ingredients { get; set; } = new();
public string? Notes { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public class RecipeResponse
public string? Instructions { get; set; }
public string? ImageUrl { get; set; }
public string? VideoUrl { get; set; }
public string? SourceName { get; set; }
public string? SourceUrl { get; set; }
public bool? IsActive { get; set; }
public List<RecipeIngredientResponse> Ingredients { get; set; } = new();
public List<string> MatchedAllergens { get; set; } = new();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,10 @@ public class RecipeSearchResponse
{
public List<RecipeResponse> Items { get; set; } = new();
public int TotalCount { get; set; }
public int Page { get; set; } = 1;
public int PageSize { get; set; }
public int TotalPages => PageSize > 0
? (int)System.Math.Ceiling((double)TotalCount / PageSize)
: 0;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ namespace MenuGreen.BusinessLogicLayer.Helpers
public static class CacheKeys
{
private const string Version = "v1";
private const string MealPlanVersion = "v3";

public static string FoodCatalog(string? keyword, string? category, int? minCal, int? maxCal)
=> $"food:catalog:{Version}:{keyword ?? ""}:{category ?? ""}:{minCal}:{maxCal}";
Expand Down Expand Up @@ -41,10 +42,10 @@ public static string CaloriesRemaining(Guid userId, DateTime date)
=> $"user:{userId}:calories-remaining:{date:yyyy-MM-dd}:{Version}";

public static string MealPlan(Guid userId, DateTime date)
=> $"user:{userId}:mealplan:{date:yyyy-MM-dd}:{Version}";
=> $"user:{userId}:mealplan:{date:yyyy-MM-dd}:{MealPlanVersion}";

public static string MealPlanByDate(Guid userId, DateOnly date)
=> $"user:{userId}:mealplan:{date:yyyy-MM-dd}:{Version}";
=> $"user:{userId}:mealplan:{date:yyyy-MM-dd}:{MealPlanVersion}";

public static string UserAiContext(Guid userId) => $"user:{userId}:ai-context:{Version}";

Expand Down
32 changes: 32 additions & 0 deletions backend/MenuGreen.BusinessLogicLayer/Helpers/VietnamTime.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
using System;

namespace MenuGreen.BusinessLogicLayer.Helpers
{
internal static class VietnamTime
{
private const int UtcOffsetHours = 7;

public static DateOnly ToDate(DateTime value)
{
// Npgsql legacy timestamp mode can materialize timestamptz as
// DateTimeKind.Local. Adding seven hours directly in that case
// double-applies the Vietnam offset and moves evening meals to the
// following day. Normalize the instant to UTC first.
var utc = value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
return DateOnly.FromDateTime(utc.AddHours(UtcOffsetHours));
}

public static DateTime RangeStartUtc(DateOnly date) => date
.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc)
.AddHours(-UtcOffsetHours);

public static DateTime RangeEndUtc(DateOnly date) => date
.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc)
.AddHours(-UtcOffsetHours);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ Task<FoodSearchResponse> SearchAsync(
string? region = null,
bool? localOnly = null,
string? mealContext = null,
string? sort = null);
string? sort = null,
int? page = null,
int? pageSize = null);
Task<IReadOnlyList<RecipeResponse>> GetRecipesAsync(Guid foodId);
Task<IReadOnlyList<FavoriteFoodResponse>> GetFavoritesAsync(Guid userId);
Task<FavoriteFoodResponse> FavoriteAsync(Guid userId, Guid foodId);
Expand Down
Loading
Loading