* Extended functionalities
* Added different UIs
This commit is contained in:
284
MealPlan.Api/Controllers/DietController.cs
Normal file
284
MealPlan.Api/Controllers/DietController.cs
Normal file
@@ -0,0 +1,284 @@
|
||||
using MealPlan.Api.Extensions;
|
||||
using MealPlan.Api.DTOs.Diet;
|
||||
using MealPlan.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MealPlan.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/diet")]
|
||||
[Authorize]
|
||||
public class DietController : ControllerBase
|
||||
{
|
||||
private readonly IDietTrackingService _dietService;
|
||||
|
||||
public DietController(IDietTrackingService dietService) => _dietService = dietService;
|
||||
|
||||
/// <summary>Daily intake summary with consumed totals, goals, and remaining amounts.</summary>
|
||||
[HttpGet("summary")]
|
||||
[ProducesResponseType(typeof(DailySummaryDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetSummary([FromQuery] DateOnly? date, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var summary = await _dietService.GetDailySummaryAsync(userId, targetDate, ct);
|
||||
return Ok(summary);
|
||||
}
|
||||
|
||||
[HttpGet("goals")]
|
||||
[ProducesResponseType(typeof(UserDailyGoalsDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetGoals(CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var goals = await _dietService.GetGoalsAsync(userId, ct);
|
||||
return Ok(goals ?? new UserDailyGoalsDto());
|
||||
}
|
||||
|
||||
[HttpPut("goals")]
|
||||
[ProducesResponseType(typeof(UserDailyGoalsDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> UpsertGoals([FromBody] UserDailyGoalsDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var goals = await _dietService.UpsertGoalsAsync(userId, dto, ct);
|
||||
return Ok(goals);
|
||||
}
|
||||
|
||||
[HttpGet("meals")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<MealConsumptionDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetMeals([FromQuery] DateOnly? date, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var meals = await _dietService.GetMealsAsync(userId, targetDate, ct);
|
||||
return Ok(meals);
|
||||
}
|
||||
|
||||
[HttpPost("meals")]
|
||||
[ProducesResponseType(typeof(MealConsumptionDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> LogMeal([FromBody] LogMealDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var meal = await _dietService.LogMealAsync(userId, dto, ct);
|
||||
if (meal is null)
|
||||
{
|
||||
return dto.RecipeId.HasValue
|
||||
? NotFound(new { error = $"Recipe {dto.RecipeId} was not found." })
|
||||
: BadRequest(new { error = "Invalid meal log payload." });
|
||||
}
|
||||
|
||||
return CreatedAtAction(nameof(GetMeals), new { date = meal.LogDate }, meal);
|
||||
}
|
||||
|
||||
[HttpPost("meals/preview")]
|
||||
[ProducesResponseType(typeof(MealPreviewDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> PreviewMeal([FromQuery] DateOnly? date, [FromBody] LogMealDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var preview = await _dietService.PreviewMealAsync(userId, targetDate, dto, ct);
|
||||
return preview is null
|
||||
? BadRequest(new { error = "Could not preview meal macros." })
|
||||
: Ok(preview);
|
||||
}
|
||||
|
||||
[HttpGet("suggestions")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<DietSuggestionDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetSuggestions([FromQuery] DateOnly? date, [FromQuery] int limit = 5, CancellationToken ct = default)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var suggestions = await _dietService.GetMealSuggestionsAsync(userId, targetDate, limit, ct);
|
||||
return Ok(suggestions);
|
||||
}
|
||||
|
||||
[HttpDelete("meals/{id:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteMeal(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var deleted = await _dietService.DeleteMealAsync(userId, id, ct);
|
||||
return deleted ? NoContent() : NotFound(new { error = $"Meal log {id} was not found." });
|
||||
}
|
||||
|
||||
[HttpGet("drinks/catalog")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<DrinkCatalogItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetDrinkCatalog(CancellationToken ct)
|
||||
{
|
||||
var catalog = await _dietService.GetDrinkCatalogAsync(ct);
|
||||
return Ok(catalog);
|
||||
}
|
||||
|
||||
[HttpGet("drinks")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<DrinkConsumptionDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetDrinks([FromQuery] DateOnly? date, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var drinks = await _dietService.GetDrinksAsync(userId, targetDate, ct);
|
||||
return Ok(drinks);
|
||||
}
|
||||
|
||||
[HttpPost("drinks")]
|
||||
[ProducesResponseType(typeof(DrinkConsumptionDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> LogDrink([FromBody] LogDrinkDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var drink = await _dietService.LogDrinkAsync(userId, dto, ct);
|
||||
if (drink is null)
|
||||
{
|
||||
return NotFound(new { error = "Drink catalog item was not found." });
|
||||
}
|
||||
|
||||
return CreatedAtAction(nameof(GetDrinks), new { date = drink.LogDate }, drink);
|
||||
}
|
||||
|
||||
[HttpDelete("drinks/{id:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteDrink(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var deleted = await _dietService.DeleteDrinkAsync(userId, id, ct);
|
||||
return deleted ? NoContent() : NotFound(new { error = $"Drink log {id} was not found." });
|
||||
}
|
||||
|
||||
[HttpGet("reminders")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<DietReminderDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetReminders(CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var reminders = await _dietService.GetRemindersAsync(userId, ct);
|
||||
return Ok(reminders);
|
||||
}
|
||||
|
||||
[HttpGet("reminders/due")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<DueReminderDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetDueReminders(
|
||||
[FromQuery] DateOnly? date,
|
||||
[FromQuery] TimeOnly? time,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var due = await _dietService.GetDueRemindersAsync(userId, date, time, ct);
|
||||
return Ok(due);
|
||||
}
|
||||
|
||||
[HttpPost("reminders")]
|
||||
[ProducesResponseType(typeof(DietReminderDto), StatusCodes.Status201Created)]
|
||||
public async Task<IActionResult> CreateReminder([FromBody] CreateDietReminderDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var reminder = await _dietService.CreateReminderAsync(userId, dto, ct);
|
||||
return CreatedAtAction(nameof(GetReminders), reminder);
|
||||
}
|
||||
|
||||
[HttpPut("reminders/{id:int}")]
|
||||
[ProducesResponseType(typeof(DietReminderDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateReminder(int id, [FromBody] UpdateDietReminderDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var reminder = await _dietService.UpdateReminderAsync(userId, id, dto, ct);
|
||||
return reminder is null
|
||||
? NotFound(new { error = $"Reminder {id} was not found." })
|
||||
: Ok(reminder);
|
||||
}
|
||||
|
||||
[HttpDelete("reminders/{id:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteReminder(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var deleted = await _dietService.DeleteReminderAsync(userId, id, ct);
|
||||
return deleted ? NoContent() : NotFound(new { error = $"Reminder {id} was not found." });
|
||||
}
|
||||
|
||||
[HttpGet("history")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<HistoryDayDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetHistory([FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var history = await _dietService.GetHistoryAsync(userId, from, to, ct);
|
||||
return Ok(history);
|
||||
}
|
||||
|
||||
[HttpGet("meals/recent")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<RecentMealDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetRecentMeals([FromQuery] int limit = 10, CancellationToken ct = default)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var meals = await _dietService.GetRecentMealsAsync(userId, limit, ct);
|
||||
return Ok(meals);
|
||||
}
|
||||
|
||||
[HttpPost("meals/copy-yesterday")]
|
||||
[ProducesResponseType(typeof(CopyMealsResultDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> CopyYesterdayMeals([FromQuery] DateOnly? date, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var result = await _dietService.CopyYesterdayMealsAsync(userId, targetDate, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("favorites/recipes")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<FavoriteRecipeDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetFavoriteRecipes(CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
return Ok(await _dietService.GetFavoriteRecipesAsync(userId, ct));
|
||||
}
|
||||
|
||||
[HttpPost("favorites/recipes/{recipeId:int}")]
|
||||
[ProducesResponseType(typeof(FavoriteRecipeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> AddFavoriteRecipe(int recipeId, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var fav = await _dietService.AddFavoriteRecipeAsync(userId, recipeId, ct);
|
||||
return fav is null ? NotFound() : Ok(fav);
|
||||
}
|
||||
|
||||
[HttpDelete("favorites/recipes/{recipeId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> RemoveFavoriteRecipe(int recipeId, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var deleted = await _dietService.RemoveFavoriteRecipeAsync(userId, recipeId, ct);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("favorites/catalog")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<FavoriteCatalogItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetFavoriteCatalogItems(CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
return Ok(await _dietService.GetFavoriteCatalogItemsAsync(userId, ct));
|
||||
}
|
||||
|
||||
[HttpPost("favorites/catalog/{catalogItemId:int}")]
|
||||
[ProducesResponseType(typeof(FavoriteCatalogItemDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> AddFavoriteCatalogItem(int catalogItemId, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var fav = await _dietService.AddFavoriteCatalogItemAsync(userId, catalogItemId, ct);
|
||||
return fav is null ? NotFound() : Ok(fav);
|
||||
}
|
||||
|
||||
[HttpDelete("favorites/catalog/{catalogItemId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> RemoveFavoriteCatalogItem(int catalogItemId, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var deleted = await _dietService.RemoveFavoriteCatalogItemAsync(userId, catalogItemId, ct);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
}
|
||||
128
MealPlan.Api/Controllers/DietGeneratorController.cs
Normal file
128
MealPlan.Api/Controllers/DietGeneratorController.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using MealPlan.Api.DTOs.Generators;
|
||||
using MealPlan.Api.Extensions;
|
||||
using MealPlan.Api.Services.Generators;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MealPlan.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/diet-generator")]
|
||||
[Authorize]
|
||||
public class DietGeneratorController : ControllerBase
|
||||
{
|
||||
private readonly IDietGeneratorService _service;
|
||||
|
||||
public DietGeneratorController(IDietGeneratorService service) => _service = service;
|
||||
|
||||
[HttpGet("profile")]
|
||||
[ProducesResponseType(typeof(DietUserProfileDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetProfile(CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var profile = await _service.GetProfileAsync(userId, ct);
|
||||
return Ok(profile ?? new DietUserProfileDto());
|
||||
}
|
||||
|
||||
[HttpPut("profile")]
|
||||
[ProducesResponseType(typeof(DietUserProfileDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> UpsertProfile([FromBody] DietUserProfileDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
return Ok(await _service.UpsertProfileAsync(userId, dto, ct));
|
||||
}
|
||||
|
||||
[HttpPost("sessions")]
|
||||
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status201Created)]
|
||||
public async Task<IActionResult> CreateSession([FromBody] CreateDietGeneratorSessionDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
try
|
||||
{
|
||||
var session = await _service.CreateSessionAsync(userId, dto, ct);
|
||||
return CreatedAtAction(nameof(GetSession), new { id = session.Id }, session);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("sessions/{id:int}")]
|
||||
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetSession(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var session = await _service.GetSessionAsync(userId, id, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:int}/propose-macros")]
|
||||
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> ProposeMacros(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var session = await _service.ProposeMacrosAsync(userId, id, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:int}/accept-macros")]
|
||||
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> AcceptMacros(int id, [FromBody] AcceptMacrosDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var session = await _service.AcceptMacrosAsync(userId, id, dto, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:int}/generate-plan")]
|
||||
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GeneratePlan(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
try
|
||||
{
|
||||
var session = await _service.GeneratePlanAsync(userId, id, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:int}/regenerate-meal")]
|
||||
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> RegenerateMeal(int id, [FromBody] RegenerateDietMealDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
try
|
||||
{
|
||||
var session = await _service.RegenerateMealAsync(userId, id, dto, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPatch("sessions/{id:int}/meals/{mealId:int}")]
|
||||
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> UpdateMeal(int id, int mealId, [FromBody] UpdateDraftMealDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var session = await _service.UpdateDraftMealAsync(userId, id, mealId, dto, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:int}/commit")]
|
||||
[ProducesResponseType(typeof(CommitDietPlanResultDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Commit(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var result = await _service.CommitAsync(userId, id, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
76
MealPlan.Api/Controllers/IngredientCatalogController.cs
Normal file
76
MealPlan.Api/Controllers/IngredientCatalogController.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using MealPlan.Api.DTOs.IngredientCatalog;
|
||||
using MealPlan.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MealPlan.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/ingredient-catalog")]
|
||||
[Authorize]
|
||||
public class IngredientCatalogController : ControllerBase
|
||||
{
|
||||
private readonly IIngredientCatalogService _catalogService;
|
||||
|
||||
public IngredientCatalogController(IIngredientCatalogService catalogService) =>
|
||||
_catalogService = catalogService;
|
||||
|
||||
[HttpGet("categories")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<IngredientCategoryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetCategories(CancellationToken ct)
|
||||
{
|
||||
var categories = await _catalogService.GetCategoriesAsync(ct);
|
||||
return Ok(categories);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<IngredientNutritionItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetItems(
|
||||
[FromQuery] string? category,
|
||||
[FromQuery] string? search,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var items = await _catalogService.GetItemsAsync(category, search, ct);
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(IngredientNutritionItemDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetItem(int id, CancellationToken ct)
|
||||
{
|
||||
var item = await _catalogService.GetItemByIdAsync(id, ct);
|
||||
return item is null ? NotFound(new { error = $"Ingredient catalog item {id} was not found." }) : Ok(item);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(IngredientNutritionItemDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> CreateItem([FromBody] UpsertIngredientNutritionItemDto dto, CancellationToken ct)
|
||||
{
|
||||
var item = await _catalogService.CreateItemAsync(dto, ct);
|
||||
return item is null
|
||||
? BadRequest(new { error = "Invalid category or payload." })
|
||||
: CreatedAtAction(nameof(GetItem), new { id = item.Id }, item);
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(IngredientNutritionItemDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateItem(int id, [FromBody] UpsertIngredientNutritionItemDto dto, CancellationToken ct)
|
||||
{
|
||||
var item = await _catalogService.UpdateItemAsync(id, dto, ct);
|
||||
return item is null
|
||||
? NotFound(new { error = $"Ingredient catalog item {id} was not found." })
|
||||
: Ok(item);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteItem(int id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await _catalogService.DeleteItemAsync(id, ct);
|
||||
return deleted ? NoContent() : NotFound(new { error = $"Ingredient catalog item {id} was not found." });
|
||||
}
|
||||
}
|
||||
55
MealPlan.Api/Controllers/MealPlanController.cs
Normal file
55
MealPlan.Api/Controllers/MealPlanController.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using MealPlan.Api.DTOs.MealPlan;
|
||||
using MealPlan.Api.Extensions;
|
||||
using MealPlan.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MealPlan.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/meal-plan")]
|
||||
[Authorize]
|
||||
public class MealPlanController : ControllerBase
|
||||
{
|
||||
private readonly IMealPlanService _mealPlanService;
|
||||
|
||||
public MealPlanController(IMealPlanService mealPlanService) => _mealPlanService = mealPlanService;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<MealPlanEntryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetEntries([FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var entries = await _mealPlanService.GetEntriesAsync(userId, from, to, ct);
|
||||
return Ok(entries);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(MealPlanEntryDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpsertEntry([FromBody] UpsertMealPlanEntryDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var entry = await _mealPlanService.UpsertEntryAsync(userId, dto, ct);
|
||||
return entry is null ? NotFound(new { error = "Recipe not found." }) : Ok(entry);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteEntry(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var deleted = await _mealPlanService.DeleteEntryAsync(userId, id, ct);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("shopping-list")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<ShoppingListItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetShoppingList([FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var items = await _mealPlanService.GetShoppingListAsync(userId, from, to, ct);
|
||||
return Ok(items);
|
||||
}
|
||||
}
|
||||
115
MealPlan.Api/Controllers/RecipeGeneratorController.cs
Normal file
115
MealPlan.Api/Controllers/RecipeGeneratorController.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using System.Text.Json;
|
||||
using MealPlan.Api.DTOs.Generators;
|
||||
using MealPlan.Api.Extensions;
|
||||
using MealPlan.Api.Services.Generators;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MealPlan.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/recipe-generator")]
|
||||
[Authorize]
|
||||
public class RecipeGeneratorController : ControllerBase
|
||||
{
|
||||
private readonly IRecipeGeneratorService _service;
|
||||
|
||||
public RecipeGeneratorController(IRecipeGeneratorService service) => _service = service;
|
||||
|
||||
[HttpPost("sessions")]
|
||||
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status201Created)]
|
||||
public async Task<IActionResult> CreateSession([FromBody] RecipeGeneratorConstraintsDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var session = await _service.CreateSessionAsync(userId, dto, ct);
|
||||
return CreatedAtAction(nameof(GetSession), new { id = session.Id }, session);
|
||||
}
|
||||
|
||||
[HttpGet("sessions/{id:int}")]
|
||||
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetSession(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var session = await _service.GetSessionAsync(userId, id, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:int}/generate")]
|
||||
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Generate(int id, [FromBody] GenerateRecipesRequestDto? request, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
try
|
||||
{
|
||||
var session = await _service.GenerateAsync(userId, id, request, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:int}/regenerate")]
|
||||
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Regenerate(int id, [FromBody] RegenerateRecipePartDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
try
|
||||
{
|
||||
var session = await _service.RegeneratePartAsync(userId, id, dto, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return BadRequest(new { error = "Could not parse AI recipe response. Try again." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("sessions/{id:int}/draft")]
|
||||
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> UpdateDraft(int id, [FromBody] GeneratedRecipeDraftDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var session = await _service.UpdateDraftAsync(userId, id, dto, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
|
||||
[HttpDelete("sessions/{id:int}/drafts/{draftId}")]
|
||||
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> RemoveDraft(int id, string draftId, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var session = await _service.RemoveDraftAsync(userId, id, draftId, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:int}/commit")]
|
||||
[ProducesResponseType(typeof(CommitRecipesResultDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Commit(int id, [FromBody] CommitRecipesRequestDto? request, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
try
|
||||
{
|
||||
var result = await _service.CommitAsync(userId, id, request, ct);
|
||||
if (result is null)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
error = "Nothing to save. The session may have expired, or the selected drafts were not found.",
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
93
MealPlan.Api/Controllers/RecipeManagementController.cs
Normal file
93
MealPlan.Api/Controllers/RecipeManagementController.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using MealPlan.Api.DTOs.Recipe;
|
||||
using MealPlan.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MealPlan.Api.Controllers;
|
||||
|
||||
/// <summary>Endpoints for creating recipes manually and importing from Excel.</summary>
|
||||
[ApiController]
|
||||
[Route("api/recipes")]
|
||||
[Authorize]
|
||||
public class RecipeManagementController : ControllerBase
|
||||
{
|
||||
private readonly IRecipeManagementService _managementService;
|
||||
private readonly IRecipeExcelService _excelService;
|
||||
|
||||
public RecipeManagementController(
|
||||
IRecipeManagementService managementService,
|
||||
IRecipeExcelService excelService)
|
||||
{
|
||||
_managementService = managementService;
|
||||
_excelService = excelService;
|
||||
}
|
||||
|
||||
/// <summary>Creates a new recipe with ingredients and steps.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(RecipeDetailDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateRecipeDto dto, CancellationToken ct)
|
||||
{
|
||||
var recipe = await _managementService.CreateRecipeAsync(dto, ct);
|
||||
return CreatedAtAction(
|
||||
nameof(RecipesController.GetRecipe),
|
||||
"Recipes",
|
||||
new { id = recipe.Id },
|
||||
recipe);
|
||||
}
|
||||
|
||||
/// <summary>Updates an existing recipe (replaces ingredients and steps).</summary>
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(RecipeDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] CreateRecipeDto dto, CancellationToken ct)
|
||||
{
|
||||
var recipe = await _managementService.UpdateRecipeAsync(id, dto, ct);
|
||||
return recipe is null
|
||||
? NotFound(new { error = $"Recipe {id} was not found." })
|
||||
: Ok(recipe);
|
||||
}
|
||||
|
||||
/// <summary>Downloads an Excel template for bulk recipe import.</summary>
|
||||
[HttpGet("import/template")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
public IActionResult DownloadTemplate()
|
||||
{
|
||||
var bytes = _excelService.GenerateTemplate();
|
||||
return File(
|
||||
bytes,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"DailyMeals-recipe-import-template.xlsx");
|
||||
}
|
||||
|
||||
/// <summary>Imports recipes from an uploaded .xlsx file (Recipes + Ingredients + Steps sheets).</summary>
|
||||
[HttpPost("import/excel")]
|
||||
[RequestSizeLimit(10_000_000)]
|
||||
[ProducesResponseType(typeof(RecipeImportResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ImportExcel(IFormFile? file, CancellationToken ct)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { error = "No file uploaded." });
|
||||
}
|
||||
|
||||
if (!file.FileName.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BadRequest(new { error = "Only .xlsx files are supported." });
|
||||
}
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = await _excelService.ImportAsync(stream, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Recalculates recipe macros from linked catalog ingredients (all active recipes).</summary>
|
||||
[HttpPost("recalculate-macros")]
|
||||
[ProducesResponseType(typeof(RecipeMacroRecalcResultDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> RecalculateMacros(CancellationToken ct)
|
||||
{
|
||||
var result = await _managementService.RecalculateAllMacrosFromCatalogAsync(ct);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,16 @@ public class RecipesController : ControllerBase
|
||||
|
||||
public RecipesController(IRecipeService recipeService) => _recipeService = recipeService;
|
||||
|
||||
/// <summary>Lists active recipes, optionally filtered by category and/or name search.</summary>
|
||||
/// <summary>
|
||||
/// Lists active recipes, optionally filtered by category, recipe-name search,
|
||||
/// and/or an ingredient name (returns recipes that contain that ingredient).
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<RecipeListItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetRecipes(
|
||||
[FromQuery] int? category,
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? ingredient,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (category is < 0 or > 3)
|
||||
@@ -27,7 +31,7 @@ public class RecipesController : ControllerBase
|
||||
return BadRequest(new { error = "category must be between 0 and 3." });
|
||||
}
|
||||
|
||||
var recipes = await _recipeService.GetRecipesAsync(category, search, ct);
|
||||
var recipes = await _recipeService.GetRecipesAsync(category, search, ingredient, ct);
|
||||
return Ok(recipes);
|
||||
}
|
||||
|
||||
|
||||
227
MealPlan.Api/DTOs/Diet/DietDtos.cs
Normal file
227
MealPlan.Api/DTOs/Diet/DietDtos.cs
Normal file
@@ -0,0 +1,227 @@
|
||||
namespace MealPlan.Api.DTOs.Diet;
|
||||
|
||||
public class UserDailyGoalsDto
|
||||
{
|
||||
public int? CalorieGoal { get; set; }
|
||||
public decimal? ProteinGoalG { get; set; }
|
||||
public decimal? FatGoalG { get; set; }
|
||||
public decimal? CarbsGoalG { get; set; }
|
||||
public int? WaterGoalMl { get; set; }
|
||||
}
|
||||
|
||||
public class MacroRemainingDto
|
||||
{
|
||||
public int? Goal { get; set; }
|
||||
public int Consumed { get; set; }
|
||||
public int? Remaining { get; set; }
|
||||
public decimal? ProgressPercent { get; set; }
|
||||
}
|
||||
|
||||
public class DecimalMacroRemainingDto
|
||||
{
|
||||
public decimal? Goal { get; set; }
|
||||
public decimal Consumed { get; set; }
|
||||
public decimal? Remaining { get; set; }
|
||||
public decimal? ProgressPercent { get; set; }
|
||||
}
|
||||
|
||||
public class DailySummaryDto
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
public UserDailyGoalsDto? Goals { get; set; }
|
||||
public MacroRemainingDto Calories { get; set; } = new();
|
||||
public DecimalMacroRemainingDto ProteinG { get; set; } = new();
|
||||
public DecimalMacroRemainingDto FatG { get; set; } = new();
|
||||
public DecimalMacroRemainingDto CarbsG { get; set; } = new();
|
||||
public MacroRemainingDto WaterMl { get; set; } = new();
|
||||
public IReadOnlyList<MealConsumptionDto> Meals { get; set; } = Array.Empty<MealConsumptionDto>();
|
||||
public IReadOnlyList<DrinkConsumptionDto> Drinks { get; set; } = Array.Empty<DrinkConsumptionDto>();
|
||||
}
|
||||
|
||||
public class MealConsumptionDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int? RecipeId { get; set; }
|
||||
public int MealCategory { get; set; }
|
||||
public DateOnly LogDate { get; set; }
|
||||
public DateTime ConsumedAt { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public decimal Portions { get; set; }
|
||||
public int? Calories { get; set; }
|
||||
public decimal? ProteinG { get; set; }
|
||||
public decimal? FatG { get; set; }
|
||||
public decimal? CarbsG { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class LogMealDto
|
||||
{
|
||||
public int? RecipeId { get; set; }
|
||||
public int? CatalogItemId { get; set; }
|
||||
public decimal? Grams { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public int MealCategory { get; set; }
|
||||
public DateOnly? LogDate { get; set; }
|
||||
public DateTime? ConsumedAt { get; set; }
|
||||
public decimal Portions { get; set; } = 1m;
|
||||
public int? Calories { get; set; }
|
||||
public decimal? ProteinG { get; set; }
|
||||
public decimal? FatG { get; set; }
|
||||
public decimal? CarbsG { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
}
|
||||
|
||||
public class MealMacroSnapshotDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public decimal Portions { get; set; } = 1m;
|
||||
public int? Calories { get; set; }
|
||||
public decimal? ProteinG { get; set; }
|
||||
public decimal? FatG { get; set; }
|
||||
public decimal? CarbsG { get; set; }
|
||||
}
|
||||
|
||||
public class RemainingAfterMealDto
|
||||
{
|
||||
public int? Calories { get; set; }
|
||||
public decimal? ProteinG { get; set; }
|
||||
public decimal? FatG { get; set; }
|
||||
public decimal? CarbsG { get; set; }
|
||||
}
|
||||
|
||||
public class MealPreviewDto
|
||||
{
|
||||
public MealMacroSnapshotDto Meal { get; set; } = new();
|
||||
public RemainingAfterMealDto RemainingAfter { get; set; } = new();
|
||||
}
|
||||
|
||||
public class DietSuggestionDto
|
||||
{
|
||||
public int CatalogItemId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? NamePl { get; set; }
|
||||
public string CategoryCode { get; set; } = string.Empty;
|
||||
public int SuggestedGrams { get; set; }
|
||||
public int? Calories { get; set; }
|
||||
public decimal? ProteinG { get; set; }
|
||||
public decimal? FatG { get; set; }
|
||||
public decimal? CarbsG { get; set; }
|
||||
public string ReasonKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class DrinkCatalogItemDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int DefaultVolumeMl { get; set; }
|
||||
public decimal? CaloriesPer100Ml { get; set; }
|
||||
public string? IconEmoji { get; set; }
|
||||
}
|
||||
|
||||
public class DrinkConsumptionDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int? DrinkCatalogId { get; set; }
|
||||
public DateOnly LogDate { get; set; }
|
||||
public DateTime ConsumedAt { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int VolumeMl { get; set; }
|
||||
public int? Calories { get; set; }
|
||||
}
|
||||
|
||||
public class LogDrinkDto
|
||||
{
|
||||
public int? DrinkCatalogId { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public int VolumeMl { get; set; }
|
||||
public DateOnly? LogDate { get; set; }
|
||||
public DateTime? ConsumedAt { get; set; }
|
||||
public int? Calories { get; set; }
|
||||
}
|
||||
|
||||
public class DietReminderDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ReminderType { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Message { get; set; }
|
||||
public TimeOnly TimeOfDay { get; set; }
|
||||
public int DaysOfWeekMask { get; set; }
|
||||
public int? MealCategory { get; set; }
|
||||
public bool IsEnabled { get; set; }
|
||||
}
|
||||
|
||||
public class CreateDietReminderDto
|
||||
{
|
||||
public int ReminderType { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Message { get; set; }
|
||||
public TimeOnly TimeOfDay { get; set; }
|
||||
public int DaysOfWeekMask { get; set; } = Models.ReminderDaysMask.All;
|
||||
public int? MealCategory { get; set; }
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
}
|
||||
|
||||
public class UpdateDietReminderDto : CreateDietReminderDto
|
||||
{
|
||||
}
|
||||
|
||||
public class DueReminderDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int ReminderType { get; set; }
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Message { get; set; }
|
||||
public TimeOnly TimeOfDay { get; set; }
|
||||
public int? MealCategory { get; set; }
|
||||
}
|
||||
|
||||
public class HistoryDayDto
|
||||
{
|
||||
public DateOnly Date { get; set; }
|
||||
public int Calories { get; set; }
|
||||
public decimal ProteinG { get; set; }
|
||||
public decimal FatG { get; set; }
|
||||
public decimal CarbsG { get; set; }
|
||||
public int WaterMl { get; set; }
|
||||
public int MealCount { get; set; }
|
||||
}
|
||||
|
||||
public class RecentMealDto
|
||||
{
|
||||
public int? RecipeId { get; set; }
|
||||
public int? CatalogItemId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int MealCategory { get; set; }
|
||||
public decimal Portions { get; set; } = 1m;
|
||||
public decimal? Grams { get; set; }
|
||||
public int? Calories { get; set; }
|
||||
public decimal? ProteinG { get; set; }
|
||||
public decimal? FatG { get; set; }
|
||||
public decimal? CarbsG { get; set; }
|
||||
public DateTime LastLoggedAt { get; set; }
|
||||
}
|
||||
|
||||
public class FavoriteRecipeDto
|
||||
{
|
||||
public int RecipeId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int MealCategory { get; set; }
|
||||
public int? Calories { get; set; }
|
||||
}
|
||||
|
||||
public class FavoriteCatalogItemDto
|
||||
{
|
||||
public int CatalogItemId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? NamePl { get; set; }
|
||||
public string CategoryCode { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class CopyMealsResultDto
|
||||
{
|
||||
public DateOnly SourceDate { get; set; }
|
||||
public DateOnly TargetDate { get; set; }
|
||||
public int CopiedCount { get; set; }
|
||||
}
|
||||
201
MealPlan.Api/DTOs/Generators/GeneratorDtos.cs
Normal file
201
MealPlan.Api/DTOs/Generators/GeneratorDtos.cs
Normal file
@@ -0,0 +1,201 @@
|
||||
namespace MealPlan.Api.DTOs.Generators;
|
||||
|
||||
public class DietUserProfileDto
|
||||
{
|
||||
public decimal HeightCm { get; set; }
|
||||
public decimal WeightKg { get; set; }
|
||||
public int Age { get; set; }
|
||||
public string Sex { get; set; } = "male";
|
||||
public string ActivityLevel { get; set; } = "moderate";
|
||||
public string Goal { get; set; } = "maintain";
|
||||
public string? AllergiesNotes { get; set; }
|
||||
public string? DislikedFoods { get; set; }
|
||||
public int MealsPerDayMask { get; set; } = 15;
|
||||
}
|
||||
|
||||
public class CreateDietGeneratorSessionDto
|
||||
{
|
||||
public DateOnly? PlanStartDate { get; set; }
|
||||
public int PlanDayCount { get; set; } = 7;
|
||||
public int CalorieToleranceKcal { get; set; } = 10;
|
||||
}
|
||||
|
||||
public class MacroProposalDto
|
||||
{
|
||||
public int Calories { get; set; }
|
||||
public decimal ProteinG { get; set; }
|
||||
public decimal FatG { get; set; }
|
||||
public decimal CarbsG { get; set; }
|
||||
public string? Rationale { get; set; }
|
||||
}
|
||||
|
||||
public class AcceptMacrosDto
|
||||
{
|
||||
public int? Calories { get; set; }
|
||||
public decimal? ProteinG { get; set; }
|
||||
public decimal? FatG { get; set; }
|
||||
public decimal? CarbsG { get; set; }
|
||||
}
|
||||
|
||||
public class DietGeneratorSessionDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public MacroProposalDto? ProposedMacros { get; set; }
|
||||
public DateOnly? PlanStartDate { get; set; }
|
||||
public int PlanDayCount { get; set; }
|
||||
public int CalorieToleranceKcal { get; set; }
|
||||
public IReadOnlyList<DietGeneratorDraftMealDto> DraftMeals { get; set; } = Array.Empty<DietGeneratorDraftMealDto>();
|
||||
public IReadOnlyList<DietGeneratorDaySummaryDto> DaySummaries { get; set; } = Array.Empty<DietGeneratorDaySummaryDto>();
|
||||
}
|
||||
|
||||
public class DietGeneratorDraftMealDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateOnly PlanDate { get; set; }
|
||||
public int MealCategory { get; set; }
|
||||
public int RecipeId { get; set; }
|
||||
public string RecipeName { get; set; } = string.Empty;
|
||||
public decimal Portions { get; set; }
|
||||
public int? Calories { get; set; }
|
||||
public decimal? ProteinG { get; set; }
|
||||
public decimal? FatG { get; set; }
|
||||
public decimal? CarbsG { get; set; }
|
||||
public bool IsLocked { get; set; }
|
||||
public int? PrepTimeMinutes { get; set; }
|
||||
public IReadOnlyList<DietGeneratorDraftMealIngredientDto> Ingredients { get; set; } =
|
||||
Array.Empty<DietGeneratorDraftMealIngredientDto>();
|
||||
public IReadOnlyList<DietGeneratorDraftMealStepDto> Steps { get; set; } =
|
||||
Array.Empty<DietGeneratorDraftMealStepDto>();
|
||||
}
|
||||
|
||||
public class DietGeneratorDraftMealStepDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int StepNumber { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class DietGeneratorDraftMealIngredientDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public decimal? AmountGrams { get; set; }
|
||||
public string? Unit { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public int? SourceIngredientId { get; set; }
|
||||
}
|
||||
|
||||
public class DietGeneratorDaySummaryDto
|
||||
{
|
||||
public DateOnly PlanDate { get; set; }
|
||||
public int TotalCalories { get; set; }
|
||||
public int TargetCalories { get; set; }
|
||||
public bool WithinTolerance { get; set; }
|
||||
}
|
||||
|
||||
public class RegenerateDietMealDto
|
||||
{
|
||||
public DateOnly PlanDate { get; set; }
|
||||
public int MealCategory { get; set; }
|
||||
}
|
||||
|
||||
public class UpdateDraftMealDto
|
||||
{
|
||||
public int? RecipeId { get; set; }
|
||||
public decimal? Portions { get; set; }
|
||||
public bool? IsLocked { get; set; }
|
||||
}
|
||||
|
||||
public class CommitDietPlanResultDto
|
||||
{
|
||||
public int SessionId { get; set; }
|
||||
public DateOnly PlanStartDate { get; set; }
|
||||
public DateOnly PlanEndDate { get; set; }
|
||||
public int MealsWritten { get; set; }
|
||||
}
|
||||
|
||||
public class RecipeGeneratorConstraintsDto
|
||||
{
|
||||
public string Prompt { get; set; } = string.Empty;
|
||||
public int MealCategory { get; set; }
|
||||
public int? TargetCalories { get; set; }
|
||||
public int CalorieToleranceKcal { get; set; } = 10;
|
||||
public int? MaxPrepTimeMinutes { get; set; }
|
||||
public string? CuisineStyle { get; set; }
|
||||
public string? DietStyle { get; set; }
|
||||
public string? Exclusions { get; set; }
|
||||
}
|
||||
|
||||
public class GeneratedIngredientDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public decimal? AmountGrams { get; set; }
|
||||
public string? Unit { get; set; }
|
||||
public int? CatalogItemId { get; set; }
|
||||
public string? CatalogName { get; set; }
|
||||
public bool IsLinked { get; set; }
|
||||
}
|
||||
|
||||
public class GeneratedStepDto
|
||||
{
|
||||
public int StepNumber { get; set; }
|
||||
public string Description { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class GeneratedRecipeDraftDto
|
||||
{
|
||||
public string DraftId { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int MealCategory { get; set; }
|
||||
public int? PrepTimeMinutes { get; set; }
|
||||
public int? Calories { get; set; }
|
||||
public decimal? Protein { get; set; }
|
||||
public decimal? Fat { get; set; }
|
||||
public decimal? Carbs { get; set; }
|
||||
public IReadOnlyList<GeneratedIngredientDto> Ingredients { get; set; } = Array.Empty<GeneratedIngredientDto>();
|
||||
public IReadOnlyList<GeneratedStepDto> Steps { get; set; } = Array.Empty<GeneratedStepDto>();
|
||||
public int UnlinkedIngredientCount { get; set; }
|
||||
}
|
||||
|
||||
public class RecipeGeneratorSessionDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Status { get; set; } = string.Empty;
|
||||
public RecipeGeneratorConstraintsDto Constraints { get; set; } = new();
|
||||
public GeneratedRecipeDraftDto? Draft { get; set; }
|
||||
public IReadOnlyList<GeneratedRecipeDraftDto> Drafts { get; set; } = Array.Empty<GeneratedRecipeDraftDto>();
|
||||
public int? SavedRecipeId { get; set; }
|
||||
public int GenerationVersion { get; set; }
|
||||
}
|
||||
|
||||
public class GenerateRecipesRequestDto
|
||||
{
|
||||
public int Count { get; set; } = 3;
|
||||
}
|
||||
|
||||
public class CommitRecipesRequestDto
|
||||
{
|
||||
public IReadOnlyList<string>? DraftIds { get; set; }
|
||||
}
|
||||
|
||||
public class RegenerateRecipePartDto
|
||||
{
|
||||
public string DraftId { get; set; } = string.Empty;
|
||||
public string Mode { get; set; } = "ingredients";
|
||||
public string? Instruction { get; set; }
|
||||
}
|
||||
|
||||
public class CommitRecipeResultDto
|
||||
{
|
||||
public int SessionId { get; set; }
|
||||
public int RecipeId { get; set; }
|
||||
public string RecipeName { get; set; } = string.Empty;
|
||||
public string DraftId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class CommitRecipesResultDto
|
||||
{
|
||||
public int SessionId { get; set; }
|
||||
public IReadOnlyList<CommitRecipeResultDto> Saved { get; set; } = Array.Empty<CommitRecipeResultDto>();
|
||||
}
|
||||
38
MealPlan.Api/DTOs/IngredientCatalog/IngredientCatalogDtos.cs
Normal file
38
MealPlan.Api/DTOs/IngredientCatalog/IngredientCatalogDtos.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
namespace MealPlan.Api.DTOs.IngredientCatalog;
|
||||
|
||||
public class IngredientCategoryDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Code { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public int ItemCount { get; set; }
|
||||
}
|
||||
|
||||
public class IngredientNutritionItemDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int CategoryId { get; set; }
|
||||
public string CategoryCode { get; set; } = string.Empty;
|
||||
public string CategoryName { get; set; } = string.Empty;
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? NamePl { get; set; }
|
||||
public int CaloriesPer100G { get; set; }
|
||||
public decimal ProteinGPer100G { get; set; }
|
||||
public decimal FatGPer100G { get; set; }
|
||||
public decimal CarbsGPer100G { get; set; }
|
||||
public decimal? FiberGPer100G { get; set; }
|
||||
}
|
||||
|
||||
public class UpsertIngredientNutritionItemDto
|
||||
{
|
||||
public int CategoryId { get; set; }
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? NamePl { get; set; }
|
||||
public int CaloriesPer100G { get; set; }
|
||||
public decimal ProteinGPer100G { get; set; }
|
||||
public decimal FatGPer100G { get; set; }
|
||||
public decimal CarbsGPer100G { get; set; }
|
||||
public decimal? FiberGPer100G { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public bool IsActive { get; set; } = true;
|
||||
}
|
||||
29
MealPlan.Api/DTOs/MealPlan/MealPlanDtos.cs
Normal file
29
MealPlan.Api/DTOs/MealPlan/MealPlanDtos.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
namespace MealPlan.Api.DTOs.MealPlan;
|
||||
|
||||
public class MealPlanEntryDto
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public DateOnly PlanDate { get; set; }
|
||||
public int MealCategory { get; set; }
|
||||
public int RecipeId { get; set; }
|
||||
public string RecipeName { get; set; } = string.Empty;
|
||||
public decimal Portions { get; set; }
|
||||
public int? Calories { get; set; }
|
||||
}
|
||||
|
||||
public class UpsertMealPlanEntryDto
|
||||
{
|
||||
public DateOnly PlanDate { get; set; }
|
||||
public int MealCategory { get; set; }
|
||||
public int RecipeId { get; set; }
|
||||
public decimal Portions { get; set; } = 1m;
|
||||
}
|
||||
|
||||
public class ShoppingListItemDto
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string? Unit { get; set; }
|
||||
public decimal? TotalGrams { get; set; }
|
||||
public decimal? TotalAmount { get; set; }
|
||||
public IReadOnlyList<string> Breakdown { get; set; } = Array.Empty<string>();
|
||||
}
|
||||
59
MealPlan.Api/DTOs/Recipe/CreateRecipeDto.cs
Normal file
59
MealPlan.Api/DTOs/Recipe/CreateRecipeDto.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace MealPlan.Api.DTOs.Recipe;
|
||||
|
||||
/// <summary>Payload for creating or updating a recipe with nested ingredients and steps.</summary>
|
||||
public class CreateRecipeDto
|
||||
{
|
||||
[Required]
|
||||
[MaxLength(255)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>0 = Breakfast, 1 = SecondBreakfast, 2 = Lunch, 3 = Dinner.</summary>
|
||||
[Range(0, 3)]
|
||||
public int MealCategory { get; set; }
|
||||
|
||||
[Range(0, 100000)]
|
||||
public int? Calories { get; set; }
|
||||
|
||||
[Range(0, 10000)]
|
||||
public decimal? Protein { get; set; }
|
||||
|
||||
[Range(0, 10000)]
|
||||
public decimal? Fat { get; set; }
|
||||
|
||||
[Range(0, 10000)]
|
||||
public decimal? Carbs { get; set; }
|
||||
|
||||
[Range(0, 100000)]
|
||||
public int? PrepTimeMinutes { get; set; }
|
||||
|
||||
public List<CreateIngredientDto> Ingredients { get; set; } = new();
|
||||
public List<CreateRecipeStepDto> Steps { get; set; } = new();
|
||||
}
|
||||
|
||||
public class CreateIngredientDto
|
||||
{
|
||||
[Required]
|
||||
[MaxLength(255)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Range(0, 100000)]
|
||||
public decimal? AmountGrams { get; set; }
|
||||
|
||||
[MaxLength(50)]
|
||||
public string? Unit { get; set; }
|
||||
|
||||
public int? CatalogItemId { get; set; }
|
||||
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
||||
public class CreateRecipeStepDto
|
||||
{
|
||||
[Range(1, 1000)]
|
||||
public int StepNumber { get; set; }
|
||||
|
||||
[Required]
|
||||
public string Description { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ public class IngredientDto
|
||||
public decimal? AmountGrams { get; set; }
|
||||
public string? Unit { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public int? CatalogItemId { get; set; }
|
||||
}
|
||||
|
||||
public class RecipeStepDto
|
||||
|
||||
10
MealPlan.Api/DTOs/Recipe/RecipeImportResultDto.cs
Normal file
10
MealPlan.Api/DTOs/Recipe/RecipeImportResultDto.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace MealPlan.Api.DTOs.Recipe;
|
||||
|
||||
/// <summary>Summary returned after an Excel import operation.</summary>
|
||||
public class RecipeImportResultDto
|
||||
{
|
||||
public int CreatedCount { get; set; }
|
||||
public int SkippedCount { get; set; }
|
||||
public List<string> Errors { get; set; } = new();
|
||||
public List<int> CreatedRecipeIds { get; set; } = new();
|
||||
}
|
||||
10
MealPlan.Api/DTOs/Recipe/RecipeMacroRecalcResultDto.cs
Normal file
10
MealPlan.Api/DTOs/Recipe/RecipeMacroRecalcResultDto.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace MealPlan.Api.DTOs.Recipe;
|
||||
|
||||
public class RecipeMacroRecalcResultDto
|
||||
{
|
||||
public int RecipesProcessed { get; init; }
|
||||
|
||||
public int RecipesUpdated { get; init; }
|
||||
|
||||
public int UnlinkedIngredientRows { get; init; }
|
||||
}
|
||||
@@ -17,6 +17,23 @@ public class AppDbContext : DbContext
|
||||
public DbSet<Ingredient> Ingredients => Set<Ingredient>();
|
||||
public DbSet<RecipeStep> RecipeSteps => Set<RecipeStep>();
|
||||
public DbSet<ApplicationUser> Users => Set<ApplicationUser>();
|
||||
public DbSet<UserDailyGoal> UserDailyGoals => Set<UserDailyGoal>();
|
||||
public DbSet<DrinkCatalogItem> DrinkCatalog => Set<DrinkCatalogItem>();
|
||||
public DbSet<MealConsumption> MealConsumptions => Set<MealConsumption>();
|
||||
public DbSet<DrinkConsumption> DrinkConsumptions => Set<DrinkConsumption>();
|
||||
public DbSet<DietReminder> DietReminders => Set<DietReminder>();
|
||||
public DbSet<IngredientCategory> IngredientCategories => Set<IngredientCategory>();
|
||||
public DbSet<IngredientNutritionCatalogItem> IngredientNutritionCatalog => Set<IngredientNutritionCatalogItem>();
|
||||
public DbSet<RefreshTokenEntity> RefreshTokens => Set<RefreshTokenEntity>();
|
||||
public DbSet<MealPlanEntry> MealPlanEntries => Set<MealPlanEntry>();
|
||||
public DbSet<UserFavoriteRecipe> UserFavoriteRecipes => Set<UserFavoriteRecipe>();
|
||||
public DbSet<UserFavoriteCatalogItem> UserFavoriteCatalogItems => Set<UserFavoriteCatalogItem>();
|
||||
public DbSet<DietUserProfile> DietUserProfiles => Set<DietUserProfile>();
|
||||
public DbSet<DietGeneratorSession> DietGeneratorSessions => Set<DietGeneratorSession>();
|
||||
public DbSet<DietGeneratorDraftMeal> DietGeneratorDraftMeals => Set<DietGeneratorDraftMeal>();
|
||||
public DbSet<DietGeneratorDraftMealIngredient> DietGeneratorDraftMealIngredients =>
|
||||
Set<DietGeneratorDraftMealIngredient>();
|
||||
public DbSet<RecipeGeneratorSession> RecipeGeneratorSessions => Set<RecipeGeneratorSession>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
@@ -48,6 +65,10 @@ public class AppDbContext : DbContext
|
||||
e.HasKey(i => i.Id);
|
||||
e.Property(i => i.Id).UseIdentityByDefaultColumn();
|
||||
e.Property(i => i.AmountGrams).HasColumnType("numeric(8,2)");
|
||||
e.HasOne(i => i.CatalogItem)
|
||||
.WithMany()
|
||||
.HasForeignKey(i => i.CatalogItemId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RecipeStep>(e =>
|
||||
@@ -61,5 +82,180 @@ public class AppDbContext : DbContext
|
||||
e.HasKey(u => u.Id);
|
||||
e.Property(u => u.Id).HasColumnType("uuid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<UserDailyGoal>(e =>
|
||||
{
|
||||
e.HasKey(g => g.UserId);
|
||||
e.Property(g => g.UserId).HasColumnType("uuid");
|
||||
e.Property(g => g.ProteinGoalG).HasColumnType("numeric(6,2)");
|
||||
e.Property(g => g.FatGoalG).HasColumnType("numeric(6,2)");
|
||||
e.Property(g => g.CarbsGoalG).HasColumnType("numeric(6,2)");
|
||||
e.HasOne(g => g.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(g => g.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<DrinkCatalogItem>(e =>
|
||||
{
|
||||
e.HasKey(d => d.Id);
|
||||
e.Property(d => d.Id).UseIdentityByDefaultColumn();
|
||||
e.Property(d => d.CaloriesPer100Ml).HasColumnType("numeric(6,2)");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MealConsumption>(e =>
|
||||
{
|
||||
e.HasKey(m => m.Id);
|
||||
e.Property(m => m.Id).UseIdentityByDefaultColumn();
|
||||
e.Property(m => m.UserId).HasColumnType("uuid");
|
||||
e.Property(m => m.Portions).HasColumnType("numeric(4,2)");
|
||||
e.Property(m => m.ProteinG).HasColumnType("numeric(6,2)");
|
||||
e.Property(m => m.FatG).HasColumnType("numeric(6,2)");
|
||||
e.Property(m => m.CarbsG).HasColumnType("numeric(6,2)");
|
||||
e.HasOne(m => m.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(m => m.Recipe)
|
||||
.WithMany()
|
||||
.HasForeignKey(m => m.RecipeId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<DrinkConsumption>(e =>
|
||||
{
|
||||
e.HasKey(d => d.Id);
|
||||
e.Property(d => d.Id).UseIdentityByDefaultColumn();
|
||||
e.Property(d => d.UserId).HasColumnType("uuid");
|
||||
e.HasOne(d => d.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(d => d.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(d => d.DrinkCatalog)
|
||||
.WithMany()
|
||||
.HasForeignKey(d => d.DrinkCatalogId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<DietReminder>(e =>
|
||||
{
|
||||
e.HasKey(r => r.Id);
|
||||
e.Property(r => r.Id).UseIdentityByDefaultColumn();
|
||||
e.Property(r => r.UserId).HasColumnType("uuid");
|
||||
e.HasOne(r => r.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(r => r.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<IngredientCategory>(e =>
|
||||
{
|
||||
e.HasKey(c => c.Id);
|
||||
e.Property(c => c.Id).UseIdentityByDefaultColumn();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<IngredientNutritionCatalogItem>(e =>
|
||||
{
|
||||
e.HasKey(i => i.Id);
|
||||
e.Property(i => i.Id).UseIdentityByDefaultColumn();
|
||||
e.Property(i => i.ProteinGPer100G).HasColumnType("numeric(6,2)");
|
||||
e.Property(i => i.FatGPer100G).HasColumnType("numeric(6,2)");
|
||||
e.Property(i => i.CarbsGPer100G).HasColumnType("numeric(6,2)");
|
||||
e.Property(i => i.FiberGPer100G).HasColumnType("numeric(6,2)");
|
||||
e.HasOne(i => i.Category)
|
||||
.WithMany(c => c.Items)
|
||||
.HasForeignKey(i => i.CategoryId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RefreshTokenEntity>(e =>
|
||||
{
|
||||
e.HasKey(t => t.Token);
|
||||
e.Property(t => t.UserId).HasColumnType("uuid");
|
||||
e.HasOne(t => t.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(t => t.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<MealPlanEntry>(e =>
|
||||
{
|
||||
e.HasKey(m => m.Id);
|
||||
e.Property(m => m.Id).UseIdentityByDefaultColumn();
|
||||
e.Property(m => m.UserId).HasColumnType("uuid");
|
||||
e.Property(m => m.Portions).HasColumnType("numeric(4,2)");
|
||||
e.HasOne(m => m.User).WithMany().HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(m => m.Recipe).WithMany().HasForeignKey(m => m.RecipeId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<UserFavoriteRecipe>(e =>
|
||||
{
|
||||
e.HasKey(f => new { f.UserId, f.RecipeId });
|
||||
e.Property(f => f.UserId).HasColumnType("uuid");
|
||||
e.HasOne(f => f.Recipe).WithMany().HasForeignKey(f => f.RecipeId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<UserFavoriteCatalogItem>(e =>
|
||||
{
|
||||
e.HasKey(f => new { f.UserId, f.CatalogItemId });
|
||||
e.Property(f => f.UserId).HasColumnType("uuid");
|
||||
e.HasOne(f => f.CatalogItem).WithMany().HasForeignKey(f => f.CatalogItemId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<DietUserProfile>(e =>
|
||||
{
|
||||
e.HasKey(p => p.UserId);
|
||||
e.Property(p => p.UserId).HasColumnType("uuid");
|
||||
e.Property(p => p.HeightCm).HasColumnType("numeric(5,2)");
|
||||
e.Property(p => p.WeightKg).HasColumnType("numeric(5,2)");
|
||||
e.HasOne(p => p.User)
|
||||
.WithMany()
|
||||
.HasForeignKey(p => p.UserId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<DietGeneratorSession>(e =>
|
||||
{
|
||||
e.HasKey(s => s.Id);
|
||||
e.Property(s => s.Id).UseIdentityByDefaultColumn();
|
||||
e.Property(s => s.UserId).HasColumnType("uuid");
|
||||
e.Property(s => s.ProposedProteinG).HasColumnType("numeric(6,2)");
|
||||
e.Property(s => s.ProposedFatG).HasColumnType("numeric(6,2)");
|
||||
e.Property(s => s.ProposedCarbsG).HasColumnType("numeric(6,2)");
|
||||
e.Property(s => s.PreferencesJson).HasColumnType("jsonb");
|
||||
e.HasMany(s => s.DraftMeals).WithOne(m => m.Session!).HasForeignKey(m => m.SessionId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<DietGeneratorDraftMeal>(e =>
|
||||
{
|
||||
e.HasKey(m => m.Id);
|
||||
e.Property(m => m.Id).UseIdentityByDefaultColumn();
|
||||
e.Property(m => m.Portions).HasColumnType("numeric(4,2)");
|
||||
e.Property(m => m.ProteinG).HasColumnType("numeric(6,2)");
|
||||
e.Property(m => m.FatG).HasColumnType("numeric(6,2)");
|
||||
e.Property(m => m.CarbsG).HasColumnType("numeric(6,2)");
|
||||
e.HasOne(m => m.Recipe).WithMany().HasForeignKey(m => m.RecipeId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasMany(m => m.PlannedIngredients).WithOne(i => i.DraftMeal!).HasForeignKey(i => i.DraftMealId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<DietGeneratorDraftMealIngredient>(e =>
|
||||
{
|
||||
e.HasKey(i => i.Id);
|
||||
e.Property(i => i.Id).UseIdentityByDefaultColumn();
|
||||
e.Property(i => i.AmountGrams).HasColumnType("numeric(8,2)");
|
||||
e.HasOne(i => i.SourceIngredient).WithMany().HasForeignKey(i => i.SourceIngredientId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<RecipeGeneratorSession>(e =>
|
||||
{
|
||||
e.HasKey(s => s.Id);
|
||||
e.Property(s => s.Id).UseIdentityByDefaultColumn();
|
||||
e.Property(s => s.UserId).HasColumnType("uuid");
|
||||
e.Property(s => s.ConstraintsJson).HasColumnType("jsonb");
|
||||
e.Property(s => s.DraftJson).HasColumnType("jsonb");
|
||||
e.HasOne(s => s.SavedRecipe).WithMany().HasForeignKey(s => s.SavedRecipeId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
50
MealPlan.Api/Data/SequenceMaintenance.cs
Normal file
50
MealPlan.Api/Data/SequenceMaintenance.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MealPlan.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Resyncs PostgreSQL identity sequences after bulk imports or manual ID inserts.
|
||||
/// </summary>
|
||||
public static class SequenceMaintenance
|
||||
{
|
||||
private static readonly (string Table, string Column)[] IdentityTables =
|
||||
[
|
||||
("Recipes", "Id"),
|
||||
("Ingredients", "Id"),
|
||||
("RecipeSteps", "Id"),
|
||||
("RecipeGeneratorSessions", "Id"),
|
||||
("DietGeneratorSessions", "Id"),
|
||||
("DietGeneratorDraftMeals", "Id"),
|
||||
("DietGeneratorDraftMealIngredients", "Id"),
|
||||
("MealPlanEntries", "Id"),
|
||||
];
|
||||
|
||||
public static async Task<IReadOnlyList<string>> ResyncIdentitySequencesAsync(
|
||||
AppDbContext db,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var messages = new List<string>();
|
||||
|
||||
foreach (var (table, column) in IdentityTables)
|
||||
{
|
||||
var sql = $"""
|
||||
SELECT setval(
|
||||
pg_get_serial_sequence('diet."{table}"', '{column}'),
|
||||
COALESCE((SELECT MAX("{column}") FROM diet."{table}"), 1),
|
||||
(SELECT MAX("{column}") IS NOT NULL FROM diet."{table}"))
|
||||
""";
|
||||
|
||||
try
|
||||
{
|
||||
await db.Database.ExecuteSqlRawAsync(sql, ct);
|
||||
messages.Add($"Resynced diet.\"{table}\".\"{column}\" sequence.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
messages.Add($"Skipped diet.\"{table}\": {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
27
MealPlan.Api/Extensions/ClaimsPrincipalExtensions.cs
Normal file
27
MealPlan.Api/Extensions/ClaimsPrincipalExtensions.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MealPlan.Api.Extensions;
|
||||
|
||||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
public static Guid? GetUserId(this ClaimsPrincipal user)
|
||||
{
|
||||
var raw = user.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? user.FindFirstValue("sub");
|
||||
return Guid.TryParse(raw, out var id) ? id : null;
|
||||
}
|
||||
|
||||
public static IActionResult? RequireUserId(this ClaimsPrincipal user, out Guid userId)
|
||||
{
|
||||
var id = user.GetUserId();
|
||||
if (id is null)
|
||||
{
|
||||
userId = Guid.Empty;
|
||||
return new UnauthorizedResult();
|
||||
}
|
||||
|
||||
userId = id.Value;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ClosedXML" Version="0.104.2" />
|
||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.3.1" />
|
||||
|
||||
41
MealPlan.Api/Models/DietEnums.cs
Normal file
41
MealPlan.Api/Models/DietEnums.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
/// <summary>Meal slot when logging consumption. Extends recipe categories with snack.</summary>
|
||||
public enum ConsumptionMealCategory
|
||||
{
|
||||
Breakfast = 0,
|
||||
SecondBreakfast = 1,
|
||||
Lunch = 2,
|
||||
Dinner = 3,
|
||||
Snack = 4,
|
||||
}
|
||||
|
||||
public enum DietReminderType
|
||||
{
|
||||
Water = 0,
|
||||
Meal = 1,
|
||||
Snack = 2,
|
||||
Custom = 3,
|
||||
}
|
||||
|
||||
/// <summary>Bitmask for reminder days: Sun=1, Mon=2, Tue=4, Wed=8, Thu=16, Fri=32, Sat=64.</summary>
|
||||
public static class ReminderDaysMask
|
||||
{
|
||||
public const int All = 127;
|
||||
|
||||
public static bool IncludesDay(int mask, DayOfWeek day)
|
||||
{
|
||||
var bit = day switch
|
||||
{
|
||||
DayOfWeek.Sunday => 1,
|
||||
DayOfWeek.Monday => 2,
|
||||
DayOfWeek.Tuesday => 4,
|
||||
DayOfWeek.Wednesday => 8,
|
||||
DayOfWeek.Thursday => 16,
|
||||
DayOfWeek.Friday => 32,
|
||||
DayOfWeek.Saturday => 64,
|
||||
_ => 0,
|
||||
};
|
||||
return (mask & bit) != 0;
|
||||
}
|
||||
}
|
||||
49
MealPlan.Api/Models/DietGeneratorDraftMeal.cs
Normal file
49
MealPlan.Api/Models/DietGeneratorDraftMeal.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
[Table("DietGeneratorDraftMeals")]
|
||||
public class DietGeneratorDraftMeal
|
||||
{
|
||||
[Column("Id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Column("SessionId")]
|
||||
public int SessionId { get; set; }
|
||||
|
||||
[Column("PlanDate")]
|
||||
public DateOnly PlanDate { get; set; }
|
||||
|
||||
[Column("MealCategory")]
|
||||
public int MealCategory { get; set; }
|
||||
|
||||
[Column("RecipeId")]
|
||||
public int RecipeId { get; set; }
|
||||
|
||||
[Column("Portions")]
|
||||
public decimal Portions { get; set; } = 1;
|
||||
|
||||
[Column("Calories")]
|
||||
public int? Calories { get; set; }
|
||||
|
||||
[Column("ProteinG")]
|
||||
public decimal? ProteinG { get; set; }
|
||||
|
||||
[Column("FatG")]
|
||||
public decimal? FatG { get; set; }
|
||||
|
||||
[Column("CarbsG")]
|
||||
public decimal? CarbsG { get; set; }
|
||||
|
||||
[Column("IsLocked")]
|
||||
public bool IsLocked { get; set; }
|
||||
|
||||
[Column("GenerationVersion")]
|
||||
public int GenerationVersion { get; set; } = 1;
|
||||
|
||||
public DietGeneratorSession? Session { get; set; }
|
||||
public Recipe? Recipe { get; set; }
|
||||
public ICollection<DietGeneratorDraftMealIngredient> PlannedIngredients { get; set; } =
|
||||
new List<DietGeneratorDraftMealIngredient>();
|
||||
}
|
||||
34
MealPlan.Api/Models/DietGeneratorDraftMealIngredient.cs
Normal file
34
MealPlan.Api/Models/DietGeneratorDraftMealIngredient.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
[Table("DietGeneratorDraftMealIngredients")]
|
||||
public class DietGeneratorDraftMealIngredient
|
||||
{
|
||||
[Column("Id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Column("DraftMealId")]
|
||||
public int DraftMealId { get; set; }
|
||||
|
||||
[Column("SourceIngredientId")]
|
||||
public int? SourceIngredientId { get; set; }
|
||||
|
||||
[Column("Name")]
|
||||
[MaxLength(255)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Column("AmountGrams")]
|
||||
public decimal? AmountGrams { get; set; }
|
||||
|
||||
[Column("Unit")]
|
||||
[MaxLength(50)]
|
||||
public string? Unit { get; set; }
|
||||
|
||||
[Column("SortOrder")]
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public DietGeneratorDraftMeal? DraftMeal { get; set; }
|
||||
public Ingredient? SourceIngredient { get; set; }
|
||||
}
|
||||
68
MealPlan.Api/Models/DietGeneratorSession.cs
Normal file
68
MealPlan.Api/Models/DietGeneratorSession.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
[Table("DietGeneratorSessions")]
|
||||
public class DietGeneratorSession
|
||||
{
|
||||
[Column("Id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Column("UserId")]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Column("Status")]
|
||||
[MaxLength(32)]
|
||||
public string Status { get; set; } = DietGeneratorStatuses.MacrosPending;
|
||||
|
||||
[Column("ProposedCalories")]
|
||||
public int? ProposedCalories { get; set; }
|
||||
|
||||
[Column("ProposedProteinG")]
|
||||
public decimal? ProposedProteinG { get; set; }
|
||||
|
||||
[Column("ProposedFatG")]
|
||||
public decimal? ProposedFatG { get; set; }
|
||||
|
||||
[Column("ProposedCarbsG")]
|
||||
public decimal? ProposedCarbsG { get; set; }
|
||||
|
||||
[Column("MacroRationale")]
|
||||
public string? MacroRationale { get; set; }
|
||||
|
||||
[Column("MacrosAcceptedAt")]
|
||||
public DateTime? MacrosAcceptedAt { get; set; }
|
||||
|
||||
[Column("PlanStartDate")]
|
||||
public DateOnly? PlanStartDate { get; set; }
|
||||
|
||||
[Column("PlanDayCount")]
|
||||
public int PlanDayCount { get; set; } = 7;
|
||||
|
||||
[Column("CalorieToleranceKcal")]
|
||||
public int CalorieToleranceKcal { get; set; } = 10;
|
||||
|
||||
[Column("PreferencesJson")]
|
||||
public string? PreferencesJson { get; set; }
|
||||
|
||||
[Column("CommittedAt")]
|
||||
public DateTime? CommittedAt { get; set; }
|
||||
|
||||
[Column("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[Column("UpdatedAt")]
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public ICollection<DietGeneratorDraftMeal> DraftMeals { get; set; } = new List<DietGeneratorDraftMeal>();
|
||||
}
|
||||
|
||||
public static class DietGeneratorStatuses
|
||||
{
|
||||
public const string MacrosPending = "macros_pending";
|
||||
public const string MacrosAccepted = "macros_accepted";
|
||||
public const string PlanDraft = "plan_draft";
|
||||
public const string Committed = "committed";
|
||||
public const string Abandoned = "abandoned";
|
||||
}
|
||||
45
MealPlan.Api/Models/DietReminder.cs
Normal file
45
MealPlan.Api/Models/DietReminder.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
[Table("DietReminders")]
|
||||
public class DietReminder
|
||||
{
|
||||
[Column("Id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Column("UserId")]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Column("ReminderType")]
|
||||
public int ReminderType { get; set; }
|
||||
|
||||
[Column("Title")]
|
||||
[MaxLength(120)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
[Column("Message")]
|
||||
[MaxLength(500)]
|
||||
public string? Message { get; set; }
|
||||
|
||||
[Column("TimeOfDay")]
|
||||
public TimeOnly TimeOfDay { get; set; }
|
||||
|
||||
[Column("DaysOfWeekMask")]
|
||||
public int DaysOfWeekMask { get; set; } = ReminderDaysMask.All;
|
||||
|
||||
[Column("MealCategory")]
|
||||
public int? MealCategory { get; set; }
|
||||
|
||||
[Column("IsEnabled")]
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
[Column("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[Column("UpdatedAt")]
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public ApplicationUser? User { get; set; }
|
||||
}
|
||||
49
MealPlan.Api/Models/DietUserProfile.cs
Normal file
49
MealPlan.Api/Models/DietUserProfile.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
[Table("DietUserProfiles")]
|
||||
public class DietUserProfile
|
||||
{
|
||||
[Column("UserId")]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Column("HeightCm")]
|
||||
public decimal HeightCm { get; set; }
|
||||
|
||||
[Column("WeightKg")]
|
||||
public decimal WeightKg { get; set; }
|
||||
|
||||
[Column("Age")]
|
||||
public int Age { get; set; }
|
||||
|
||||
[Column("Sex")]
|
||||
[MaxLength(16)]
|
||||
public string Sex { get; set; } = "male";
|
||||
|
||||
[Column("ActivityLevel")]
|
||||
[MaxLength(32)]
|
||||
public string ActivityLevel { get; set; } = "moderate";
|
||||
|
||||
[Column("Goal")]
|
||||
[MaxLength(32)]
|
||||
public string Goal { get; set; } = "maintain";
|
||||
|
||||
[Column("AllergiesNotes")]
|
||||
public string? AllergiesNotes { get; set; }
|
||||
|
||||
[Column("DislikedFoods")]
|
||||
public string? DislikedFoods { get; set; }
|
||||
|
||||
[Column("MealsPerDayMask")]
|
||||
public int MealsPerDayMask { get; set; } = 15;
|
||||
|
||||
[Column("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[Column("UpdatedAt")]
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public ApplicationUser? User { get; set; }
|
||||
}
|
||||
35
MealPlan.Api/Models/DrinkCatalogItem.cs
Normal file
35
MealPlan.Api/Models/DrinkCatalogItem.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
[Table("DrinkCatalog")]
|
||||
public class DrinkCatalogItem
|
||||
{
|
||||
[Column("Id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Column("Code")]
|
||||
[MaxLength(32)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
[Column("Name")]
|
||||
[MaxLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Column("DefaultVolumeMl")]
|
||||
public int DefaultVolumeMl { get; set; }
|
||||
|
||||
[Column("CaloriesPer100Ml")]
|
||||
public decimal? CaloriesPer100Ml { get; set; }
|
||||
|
||||
[Column("IconEmoji")]
|
||||
[MaxLength(16)]
|
||||
public string? IconEmoji { get; set; }
|
||||
|
||||
[Column("SortOrder")]
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
[Column("IsActive")]
|
||||
public bool IsActive { get; set; }
|
||||
}
|
||||
39
MealPlan.Api/Models/DrinkConsumption.cs
Normal file
39
MealPlan.Api/Models/DrinkConsumption.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
[Table("DrinkConsumptions")]
|
||||
public class DrinkConsumption
|
||||
{
|
||||
[Column("Id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Column("UserId")]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Column("DrinkCatalogId")]
|
||||
public int? DrinkCatalogId { get; set; }
|
||||
|
||||
[Column("LogDate")]
|
||||
public DateOnly LogDate { get; set; }
|
||||
|
||||
[Column("ConsumedAt")]
|
||||
public DateTime ConsumedAt { get; set; }
|
||||
|
||||
[Column("Name")]
|
||||
[MaxLength(255)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Column("VolumeMl")]
|
||||
public int VolumeMl { get; set; }
|
||||
|
||||
[Column("Calories")]
|
||||
public int? Calories { get; set; }
|
||||
|
||||
[Column("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public ApplicationUser? User { get; set; }
|
||||
public DrinkCatalogItem? DrinkCatalog { get; set; }
|
||||
}
|
||||
@@ -29,5 +29,9 @@ public class Ingredient
|
||||
[Column("SortOrder")]
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
[Column("CatalogItemId")]
|
||||
public int? CatalogItemId { get; set; }
|
||||
|
||||
public Recipe? Recipe { get; set; }
|
||||
public IngredientNutritionCatalogItem? CatalogItem { get; set; }
|
||||
}
|
||||
|
||||
27
MealPlan.Api/Models/IngredientCategory.cs
Normal file
27
MealPlan.Api/Models/IngredientCategory.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
[Table("IngredientCategories")]
|
||||
public class IngredientCategory
|
||||
{
|
||||
[Column("Id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Column("Code")]
|
||||
[MaxLength(32)]
|
||||
public string Code { get; set; } = string.Empty;
|
||||
|
||||
[Column("Name")]
|
||||
[MaxLength(100)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Column("SortOrder")]
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
[Column("IsActive")]
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public ICollection<IngredientNutritionCatalogItem> Items { get; set; } = new List<IngredientNutritionCatalogItem>();
|
||||
}
|
||||
45
MealPlan.Api/Models/IngredientNutritionCatalogItem.cs
Normal file
45
MealPlan.Api/Models/IngredientNutritionCatalogItem.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
[Table("IngredientNutritionCatalog")]
|
||||
public class IngredientNutritionCatalogItem
|
||||
{
|
||||
[Column("Id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Column("CategoryId")]
|
||||
public int CategoryId { get; set; }
|
||||
|
||||
[Column("Name")]
|
||||
[MaxLength(255)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Column("NamePl")]
|
||||
[MaxLength(255)]
|
||||
public string? NamePl { get; set; }
|
||||
|
||||
[Column("CaloriesPer100G")]
|
||||
public int CaloriesPer100G { get; set; }
|
||||
|
||||
[Column("ProteinGPer100G")]
|
||||
public decimal ProteinGPer100G { get; set; }
|
||||
|
||||
[Column("FatGPer100G")]
|
||||
public decimal FatGPer100G { get; set; }
|
||||
|
||||
[Column("CarbsGPer100G")]
|
||||
public decimal CarbsGPer100G { get; set; }
|
||||
|
||||
[Column("FiberGPer100G")]
|
||||
public decimal? FiberGPer100G { get; set; }
|
||||
|
||||
[Column("SortOrder")]
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
[Column("IsActive")]
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public IngredientCategory? Category { get; set; }
|
||||
}
|
||||
54
MealPlan.Api/Models/MealConsumption.cs
Normal file
54
MealPlan.Api/Models/MealConsumption.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
[Table("MealConsumptions")]
|
||||
public class MealConsumption
|
||||
{
|
||||
[Column("Id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Column("UserId")]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Column("RecipeId")]
|
||||
public int? RecipeId { get; set; }
|
||||
|
||||
[Column("MealCategory")]
|
||||
public int MealCategory { get; set; }
|
||||
|
||||
[Column("LogDate")]
|
||||
public DateOnly LogDate { get; set; }
|
||||
|
||||
[Column("ConsumedAt")]
|
||||
public DateTime ConsumedAt { get; set; }
|
||||
|
||||
[Column("Name")]
|
||||
[MaxLength(255)]
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
[Column("Portions")]
|
||||
public decimal Portions { get; set; } = 1m;
|
||||
|
||||
[Column("Calories")]
|
||||
public int? Calories { get; set; }
|
||||
|
||||
[Column("ProteinG")]
|
||||
public decimal? ProteinG { get; set; }
|
||||
|
||||
[Column("FatG")]
|
||||
public decimal? FatG { get; set; }
|
||||
|
||||
[Column("CarbsG")]
|
||||
public decimal? CarbsG { get; set; }
|
||||
|
||||
[Column("Notes")]
|
||||
public string? Notes { get; set; }
|
||||
|
||||
[Column("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public ApplicationUser? User { get; set; }
|
||||
public Recipe? Recipe { get; set; }
|
||||
}
|
||||
34
MealPlan.Api/Models/MealPlanEntry.cs
Normal file
34
MealPlan.Api/Models/MealPlanEntry.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
[Table("MealPlanEntries")]
|
||||
public class MealPlanEntry
|
||||
{
|
||||
[Column("Id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Column("UserId")]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Column("PlanDate")]
|
||||
public DateOnly PlanDate { get; set; }
|
||||
|
||||
[Column("MealCategory")]
|
||||
public int MealCategory { get; set; }
|
||||
|
||||
[Column("RecipeId")]
|
||||
public int RecipeId { get; set; }
|
||||
|
||||
[Column("Portions")]
|
||||
public decimal Portions { get; set; } = 1m;
|
||||
|
||||
[Column("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[Column("UpdatedAt")]
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public ApplicationUser? User { get; set; }
|
||||
public Recipe? Recipe { get; set; }
|
||||
}
|
||||
@@ -44,6 +44,16 @@ public class Recipe
|
||||
[Column("IsActive")]
|
||||
public bool IsActive { get; set; }
|
||||
|
||||
[Column("Source")]
|
||||
[MaxLength(32)]
|
||||
public string Source { get; set; } = "manual";
|
||||
|
||||
[Column("CreatedByUserId")]
|
||||
public Guid? CreatedByUserId { get; set; }
|
||||
|
||||
[Column("IsDraft")]
|
||||
public bool IsDraft { get; set; }
|
||||
|
||||
public ICollection<Ingredient> Ingredients { get; set; } = new List<Ingredient>();
|
||||
public ICollection<RecipeStep> Steps { get; set; } = new List<RecipeStep>();
|
||||
}
|
||||
|
||||
45
MealPlan.Api/Models/RecipeGeneratorSession.cs
Normal file
45
MealPlan.Api/Models/RecipeGeneratorSession.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
[Table("RecipeGeneratorSessions")]
|
||||
public class RecipeGeneratorSession
|
||||
{
|
||||
[Column("Id")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Column("UserId")]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Column("Status")]
|
||||
[MaxLength(32)]
|
||||
public string Status { get; set; } = RecipeGeneratorStatuses.Draft;
|
||||
|
||||
[Column("ConstraintsJson")]
|
||||
public string ConstraintsJson { get; set; } = "{}";
|
||||
|
||||
[Column("DraftJson")]
|
||||
public string? DraftJson { get; set; }
|
||||
|
||||
[Column("SavedRecipeId")]
|
||||
public int? SavedRecipeId { get; set; }
|
||||
|
||||
[Column("GenerationVersion")]
|
||||
public int GenerationVersion { get; set; } = 1;
|
||||
|
||||
[Column("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[Column("UpdatedAt")]
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public Recipe? SavedRecipe { get; set; }
|
||||
}
|
||||
|
||||
public static class RecipeGeneratorStatuses
|
||||
{
|
||||
public const string Draft = "draft";
|
||||
public const string Saved = "saved";
|
||||
public const string Abandoned = "abandoned";
|
||||
}
|
||||
26
MealPlan.Api/Models/RefreshTokenEntity.cs
Normal file
26
MealPlan.Api/Models/RefreshTokenEntity.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
[Table("RefreshTokens")]
|
||||
public class RefreshTokenEntity
|
||||
{
|
||||
[Column("Token")]
|
||||
public string Token { get; set; } = string.Empty;
|
||||
|
||||
[Column("UserId")]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Column("ExpiresAtUtc")]
|
||||
public DateTime ExpiresAtUtc { get; set; }
|
||||
|
||||
[Column("CreatedAtUtc")]
|
||||
public DateTime CreatedAtUtc { get; set; }
|
||||
|
||||
[Column("IsRevoked")]
|
||||
public bool IsRevoked { get; set; }
|
||||
|
||||
public bool IsActive => !IsRevoked && DateTime.UtcNow < ExpiresAtUtc;
|
||||
|
||||
public ApplicationUser? User { get; set; }
|
||||
}
|
||||
34
MealPlan.Api/Models/UserDailyGoal.cs
Normal file
34
MealPlan.Api/Models/UserDailyGoal.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
[Table("UserDailyGoals")]
|
||||
public class UserDailyGoal
|
||||
{
|
||||
[Column("UserId")]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Column("CalorieGoal")]
|
||||
public int? CalorieGoal { get; set; }
|
||||
|
||||
[Column("ProteinGoalG")]
|
||||
public decimal? ProteinGoalG { get; set; }
|
||||
|
||||
[Column("FatGoalG")]
|
||||
public decimal? FatGoalG { get; set; }
|
||||
|
||||
[Column("CarbsGoalG")]
|
||||
public decimal? CarbsGoalG { get; set; }
|
||||
|
||||
[Column("WaterGoalMl")]
|
||||
public int? WaterGoalMl { get; set; }
|
||||
|
||||
[Column("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
[Column("UpdatedAt")]
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public ApplicationUser? User { get; set; }
|
||||
}
|
||||
18
MealPlan.Api/Models/UserFavoriteCatalogItem.cs
Normal file
18
MealPlan.Api/Models/UserFavoriteCatalogItem.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
[Table("UserFavoriteCatalogItems")]
|
||||
public class UserFavoriteCatalogItem
|
||||
{
|
||||
[Column("UserId")]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Column("CatalogItemId")]
|
||||
public int CatalogItemId { get; set; }
|
||||
|
||||
[Column("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public IngredientNutritionCatalogItem? CatalogItem { get; set; }
|
||||
}
|
||||
18
MealPlan.Api/Models/UserFavoriteRecipe.cs
Normal file
18
MealPlan.Api/Models/UserFavoriteRecipe.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace MealPlan.Api.Models;
|
||||
|
||||
[Table("UserFavoriteRecipes")]
|
||||
public class UserFavoriteRecipe
|
||||
{
|
||||
[Column("UserId")]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
[Column("RecipeId")]
|
||||
public int RecipeId { get; set; }
|
||||
|
||||
[Column("CreatedAt")]
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public Recipe? Recipe { get; set; }
|
||||
}
|
||||
@@ -62,12 +62,23 @@ builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
// ---------------------------------------------------------------------------
|
||||
// Application services
|
||||
// ---------------------------------------------------------------------------
|
||||
builder.Services.AddSingleton<IRefreshTokenStore, InMemoryRefreshTokenStore>();
|
||||
builder.Services.AddSingleton<IRefreshTokenStore, DbRefreshTokenStore>();
|
||||
builder.Services.AddHostedService<RefreshTokenCleanupService>();
|
||||
builder.Services.AddSingleton<IPasswordHasher<ApplicationUser>, PasswordHasher<ApplicationUser>>();
|
||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IRecipeService, RecipeService>();
|
||||
builder.Services.AddScoped<IRecipeManagementService, RecipeManagementService>();
|
||||
builder.Services.AddScoped<IRecipeExcelService, RecipeExcelService>();
|
||||
builder.Services.AddScoped<IDietTrackingService, DietTrackingService>();
|
||||
builder.Services.AddScoped<IIngredientCatalogService, IngredientCatalogService>();
|
||||
builder.Services.AddScoped<IMealPlanService, MealPlanService>();
|
||||
builder.Services.Configure<MealPlan.Api.Services.AI.OpenAiOptions>(
|
||||
builder.Configuration.GetSection(MealPlan.Api.Services.AI.OpenAiOptions.SectionName));
|
||||
builder.Services.AddHttpClient<MealPlan.Api.Services.AI.IOpenAiChatService, MealPlan.Api.Services.AI.OpenAiChatService>();
|
||||
builder.Services.AddScoped<MealPlan.Api.Services.Generators.ICatalogMatchingService, MealPlan.Api.Services.Generators.CatalogMatchingService>();
|
||||
builder.Services.AddScoped<MealPlan.Api.Services.Generators.IDietGeneratorService, MealPlan.Api.Services.Generators.DietGeneratorService>();
|
||||
builder.Services.AddScoped<MealPlan.Api.Services.Generators.IRecipeGeneratorService, MealPlan.Api.Services.Generators.RecipeGeneratorService>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Authentication / authorization (JWT bearer)
|
||||
@@ -163,6 +174,19 @@ builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (args.Contains("--fix-sequences", StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
await using var scope = app.Services.CreateAsyncScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var messages = await SequenceMaintenance.ResyncIdentitySequencesAsync(db);
|
||||
foreach (var message in messages)
|
||||
{
|
||||
Console.WriteLine(message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HTTP request pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
12
MealPlan.Api/Services/AI/IOpenAiChatService.cs
Normal file
12
MealPlan.Api/Services/AI/IOpenAiChatService.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace MealPlan.Api.Services.AI;
|
||||
|
||||
public interface IOpenAiChatService
|
||||
{
|
||||
bool IsConfigured { get; }
|
||||
|
||||
Task<string> CompleteJsonAsync(
|
||||
string systemPrompt,
|
||||
string userPrompt,
|
||||
OpenAiUseCase useCase = OpenAiUseCase.Default,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
80
MealPlan.Api/Services/AI/OpenAiChatService.cs
Normal file
80
MealPlan.Api/Services/AI/OpenAiChatService.cs
Normal file
@@ -0,0 +1,80 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace MealPlan.Api.Services.AI;
|
||||
|
||||
public class OpenAiChatService : IOpenAiChatService
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly OpenAiOptions _options;
|
||||
private readonly ILogger<OpenAiChatService> _logger;
|
||||
|
||||
public OpenAiChatService(HttpClient http, IOptions<OpenAiOptions> options, ILogger<OpenAiChatService> logger)
|
||||
{
|
||||
_http = http;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public bool IsConfigured => _options.IsConfigured;
|
||||
|
||||
public async Task<string> CompleteJsonAsync(
|
||||
string systemPrompt,
|
||||
string userPrompt,
|
||||
OpenAiUseCase useCase = OpenAiUseCase.Default,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!IsConfigured)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"OpenAI is not configured. Set OpenAI__ApiKey in appsettings.Local.json.");
|
||||
}
|
||||
|
||||
var model = _options.ResolveModel(useCase);
|
||||
var payload = new
|
||||
{
|
||||
model,
|
||||
max_tokens = _options.MaxTokens,
|
||||
response_format = new { type = "json_object" },
|
||||
messages = new[]
|
||||
{
|
||||
new { role = "system", content = systemPrompt },
|
||||
new { role = "user", content = userPrompt },
|
||||
},
|
||||
};
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _options.ApiKey);
|
||||
request.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await _http.SendAsync(request, ct);
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogWarning("OpenAI API error {Status}: {Body}", response.StatusCode, body);
|
||||
throw new InvalidOperationException("OpenAI request failed. Check API key and quota.");
|
||||
}
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var content = doc.RootElement
|
||||
.GetProperty("choices")[0]
|
||||
.GetProperty("message")
|
||||
.GetProperty("content")
|
||||
.GetString() ?? "{}";
|
||||
|
||||
return StripMarkdownFence(content);
|
||||
}
|
||||
|
||||
private static string StripMarkdownFence(string content)
|
||||
{
|
||||
var trimmed = content.Trim();
|
||||
if (!trimmed.StartsWith("```", StringComparison.Ordinal)) return trimmed;
|
||||
var firstNewline = trimmed.IndexOf('\n');
|
||||
if (firstNewline < 0) return trimmed;
|
||||
var endFence = trimmed.LastIndexOf("```", StringComparison.Ordinal);
|
||||
if (endFence <= firstNewline) return trimmed;
|
||||
return trimmed[(firstNewline + 1)..endFence].Trim();
|
||||
}
|
||||
}
|
||||
44
MealPlan.Api/Services/AI/OpenAiOptions.cs
Normal file
44
MealPlan.Api/Services/AI/OpenAiOptions.cs
Normal file
@@ -0,0 +1,44 @@
|
||||
namespace MealPlan.Api.Services.AI;
|
||||
|
||||
public enum OpenAiUseCase
|
||||
{
|
||||
Default,
|
||||
Macros,
|
||||
Plan,
|
||||
Recipe,
|
||||
}
|
||||
|
||||
public class OpenAiOptions
|
||||
{
|
||||
public const string SectionName = "OpenAI";
|
||||
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Fallback when a task-specific model is not set.</summary>
|
||||
public string Model { get; set; } = "gpt-4.1-mini";
|
||||
|
||||
/// <summary>Macro target refinement (diet generator step 2).</summary>
|
||||
public string? MacrosModel { get; set; }
|
||||
|
||||
/// <summary>Weekly meal plan recipe selection (diet generator step 3).</summary>
|
||||
public string? PlanModel { get; set; }
|
||||
|
||||
/// <summary>AI recipe draft generation and partial regen.</summary>
|
||||
public string? RecipeModel { get; set; }
|
||||
|
||||
public int MaxTokens { get; set; } = 4096;
|
||||
|
||||
public bool IsConfigured => !string.IsNullOrWhiteSpace(ApiKey);
|
||||
|
||||
public string ResolveModel(OpenAiUseCase useCase) =>
|
||||
useCase switch
|
||||
{
|
||||
OpenAiUseCase.Macros => FirstNonEmpty(MacrosModel, Model),
|
||||
OpenAiUseCase.Plan => FirstNonEmpty(PlanModel, Model),
|
||||
OpenAiUseCase.Recipe => FirstNonEmpty(RecipeModel, Model),
|
||||
_ => Model,
|
||||
};
|
||||
|
||||
private static string FirstNonEmpty(string? preferred, string fallback) =>
|
||||
string.IsNullOrWhiteSpace(preferred) ? fallback : preferred;
|
||||
}
|
||||
68
MealPlan.Api/Services/DbRefreshTokenStore.cs
Normal file
68
MealPlan.Api/Services/DbRefreshTokenStore.cs
Normal file
@@ -0,0 +1,68 @@
|
||||
using MealPlan.Api.Data;
|
||||
using MealPlan.Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
/// <summary>Persists refresh tokens in PostgreSQL (survives API restarts).</summary>
|
||||
public class DbRefreshTokenStore : IRefreshTokenStore
|
||||
{
|
||||
private readonly IServiceScopeFactory _scopeFactory;
|
||||
|
||||
public DbRefreshTokenStore(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;
|
||||
|
||||
public void Add(RefreshToken token)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.RefreshTokens.Add(new RefreshTokenEntity
|
||||
{
|
||||
Token = token.Token,
|
||||
UserId = Guid.Parse(token.UserId),
|
||||
ExpiresAtUtc = token.ExpiresAtUtc,
|
||||
CreatedAtUtc = token.CreatedAtUtc,
|
||||
IsRevoked = false,
|
||||
});
|
||||
db.SaveChanges();
|
||||
}
|
||||
|
||||
public RefreshToken? Get(string token)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var entity = db.RefreshTokens.AsNoTracking().FirstOrDefault(t => t.Token == token);
|
||||
return entity is null ? null : Map(entity);
|
||||
}
|
||||
|
||||
public RefreshToken? Revoke(string token)
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var entity = db.RefreshTokens.FirstOrDefault(t => t.Token == token);
|
||||
if (entity is null) return null;
|
||||
var wasActive = entity.IsActive;
|
||||
entity.IsRevoked = true;
|
||||
db.SaveChanges();
|
||||
return wasActive ? Map(entity) : null;
|
||||
}
|
||||
|
||||
public void RemoveExpired()
|
||||
{
|
||||
using var scope = _scopeFactory.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var cutoff = DateTime.UtcNow;
|
||||
db.RefreshTokens
|
||||
.Where(t => t.IsRevoked || t.ExpiresAtUtc <= cutoff)
|
||||
.ExecuteDelete();
|
||||
}
|
||||
|
||||
private static RefreshToken Map(RefreshTokenEntity entity) => new()
|
||||
{
|
||||
Token = entity.Token,
|
||||
UserId = entity.UserId.ToString(),
|
||||
ExpiresAtUtc = entity.ExpiresAtUtc,
|
||||
CreatedAtUtc = entity.CreatedAtUtc,
|
||||
IsRevoked = entity.IsRevoked,
|
||||
};
|
||||
}
|
||||
850
MealPlan.Api/Services/DietTrackingService.cs
Normal file
850
MealPlan.Api/Services/DietTrackingService.cs
Normal file
@@ -0,0 +1,850 @@
|
||||
using MealPlan.Api.Data;
|
||||
using MealPlan.Api.DTOs.Diet;
|
||||
using MealPlan.Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
public class DietTrackingService : IDietTrackingService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IRecipeService _recipeService;
|
||||
|
||||
public DietTrackingService(AppDbContext db, IRecipeService recipeService)
|
||||
{
|
||||
_db = db;
|
||||
_recipeService = recipeService;
|
||||
}
|
||||
|
||||
public async Task<UserDailyGoalsDto?> GetGoalsAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var goals = await _db.UserDailyGoals.AsNoTracking()
|
||||
.FirstOrDefaultAsync(g => g.UserId == userId, ct);
|
||||
return goals is null ? null : MapGoals(goals);
|
||||
}
|
||||
|
||||
public async Task<UserDailyGoalsDto> UpsertGoalsAsync(Guid userId, UserDailyGoalsDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await _db.UserDailyGoals.FirstOrDefaultAsync(g => g.UserId == userId, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new UserDailyGoal
|
||||
{
|
||||
UserId = userId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_db.UserDailyGoals.Add(existing);
|
||||
}
|
||||
|
||||
existing.CalorieGoal = dto.CalorieGoal;
|
||||
existing.ProteinGoalG = dto.ProteinGoalG;
|
||||
existing.FatGoalG = dto.FatGoalG;
|
||||
existing.CarbsGoalG = dto.CarbsGoalG;
|
||||
existing.WaterGoalMl = dto.WaterGoalMl;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return MapGoals(existing);
|
||||
}
|
||||
|
||||
public async Task<DailySummaryDto> GetDailySummaryAsync(Guid userId, DateOnly date, CancellationToken ct = default)
|
||||
{
|
||||
var goals = await GetGoalsAsync(userId, ct);
|
||||
var meals = await GetMealsAsync(userId, date, ct);
|
||||
var drinks = await GetDrinksAsync(userId, date, ct);
|
||||
|
||||
var totalCalories = meals.Sum(m => m.Calories ?? 0);
|
||||
var totalProtein = meals.Sum(m => m.ProteinG ?? 0);
|
||||
var totalFat = meals.Sum(m => m.FatG ?? 0);
|
||||
var totalCarbs = meals.Sum(m => m.CarbsG ?? 0);
|
||||
var totalWater = drinks.Sum(d => d.VolumeMl);
|
||||
|
||||
return new DailySummaryDto
|
||||
{
|
||||
Date = date,
|
||||
Goals = goals,
|
||||
Calories = BuildIntMacro(goals?.CalorieGoal, totalCalories),
|
||||
ProteinG = BuildDecimalMacro(goals?.ProteinGoalG, totalProtein),
|
||||
FatG = BuildDecimalMacro(goals?.FatGoalG, totalFat),
|
||||
CarbsG = BuildDecimalMacro(goals?.CarbsGoalG, totalCarbs),
|
||||
WaterMl = BuildIntMacro(goals?.WaterGoalMl, totalWater),
|
||||
Meals = meals,
|
||||
Drinks = drinks,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MealConsumptionDto>> GetMealsAsync(Guid userId, DateOnly date, CancellationToken ct = default)
|
||||
{
|
||||
var rows = await _db.MealConsumptions.AsNoTracking()
|
||||
.Where(m => m.UserId == userId && m.LogDate == date)
|
||||
.OrderByDescending(m => m.ConsumedAt)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(MapMeal).ToList();
|
||||
}
|
||||
|
||||
public async Task<MealConsumptionDto?> LogMealAsync(Guid userId, LogMealDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var consumedAt = dto.ConsumedAt ?? DateTime.UtcNow;
|
||||
var logDate = dto.LogDate ?? DateOnly.FromDateTime(consumedAt);
|
||||
|
||||
string name;
|
||||
int? calories;
|
||||
decimal? protein;
|
||||
decimal? fat;
|
||||
decimal? carbs;
|
||||
int? recipeId = dto.RecipeId;
|
||||
|
||||
if (dto.RecipeId.HasValue)
|
||||
{
|
||||
var recipe = await _recipeService.GetRecipeByIdAsync(dto.RecipeId.Value, ct);
|
||||
if (recipe is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
name = recipe.Name;
|
||||
calories = ScaleInt(recipe.Calories, dto.Portions);
|
||||
protein = ScaleDecimal(recipe.Protein, dto.Portions);
|
||||
fat = ScaleDecimal(recipe.Fat, dto.Portions);
|
||||
carbs = ScaleDecimal(recipe.Carbs, dto.Portions);
|
||||
|
||||
if (dto.Calories.HasValue) calories = dto.Calories;
|
||||
if (dto.ProteinG.HasValue) protein = dto.ProteinG;
|
||||
if (dto.FatG.HasValue) fat = dto.FatG;
|
||||
if (dto.CarbsG.HasValue) carbs = dto.CarbsG;
|
||||
}
|
||||
else if (dto.CatalogItemId.HasValue && dto.Grams is > 0)
|
||||
{
|
||||
var catalog = await _db.IngredientNutritionCatalog.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == dto.CatalogItemId && i.IsActive, ct);
|
||||
if (catalog is null) return null;
|
||||
|
||||
name = string.IsNullOrWhiteSpace(dto.Name) ? catalog.Name : dto.Name.Trim();
|
||||
var scale = dto.Grams.Value / 100m;
|
||||
calories = (int)Math.Round(catalog.CaloriesPer100G * scale);
|
||||
protein = Math.Round(catalog.ProteinGPer100G * scale, 2);
|
||||
fat = Math.Round(catalog.FatGPer100G * scale, 2);
|
||||
carbs = Math.Round(catalog.CarbsGPer100G * scale, 2);
|
||||
recipeId = null;
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(dto.Name))
|
||||
{
|
||||
name = dto.Name.Trim();
|
||||
calories = dto.Calories;
|
||||
protein = dto.ProteinG;
|
||||
fat = dto.FatG;
|
||||
carbs = dto.CarbsG;
|
||||
recipeId = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var entity = new MealConsumption
|
||||
{
|
||||
UserId = userId,
|
||||
RecipeId = recipeId,
|
||||
MealCategory = dto.MealCategory,
|
||||
LogDate = logDate,
|
||||
ConsumedAt = consumedAt,
|
||||
Name = name,
|
||||
Portions = dto.Portions,
|
||||
Calories = calories,
|
||||
ProteinG = protein,
|
||||
FatG = fat,
|
||||
CarbsG = carbs,
|
||||
Notes = dto.Notes,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
_db.MealConsumptions.Add(entity);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return MapMeal(entity);
|
||||
}
|
||||
|
||||
public async Task<MealPreviewDto?> PreviewMealAsync(
|
||||
Guid userId,
|
||||
DateOnly date,
|
||||
LogMealDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var summary = await GetDailySummaryAsync(userId, date, ct);
|
||||
var snapshot = await ResolveMealSnapshotAsync(dto, ct);
|
||||
if (snapshot is null) return null;
|
||||
|
||||
return new MealPreviewDto
|
||||
{
|
||||
Meal = snapshot,
|
||||
RemainingAfter = BuildRemainingAfter(summary, snapshot),
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DietSuggestionDto>> GetMealSuggestionsAsync(
|
||||
Guid userId,
|
||||
DateOnly date,
|
||||
int limit = 5,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
limit = Math.Clamp(limit, 1, 10);
|
||||
var summary = await GetDailySummaryAsync(userId, date, ct);
|
||||
var items = await _db.IngredientNutritionCatalog.AsNoTracking()
|
||||
.Include(i => i.Category)
|
||||
.Where(i => i.IsActive && i.Category!.IsActive)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (items.Count == 0) return Array.Empty<DietSuggestionDto>();
|
||||
|
||||
var reasonKey = PickPrimaryDeficit(summary);
|
||||
const int defaultGrams = 150;
|
||||
|
||||
var scored = items.Select(item =>
|
||||
{
|
||||
var scale = defaultGrams / 100m;
|
||||
var calories = (int)Math.Round(item.CaloriesPer100G * scale);
|
||||
var protein = Math.Round(item.ProteinGPer100G * scale, 2);
|
||||
var fat = Math.Round(item.FatGPer100G * scale, 2);
|
||||
var carbs = Math.Round(item.CarbsGPer100G * scale, 2);
|
||||
var score = ScoreSuggestion(summary, reasonKey, calories, protein, fat, carbs);
|
||||
|
||||
return (
|
||||
score,
|
||||
Suggestion: new DietSuggestionDto
|
||||
{
|
||||
CatalogItemId = item.Id,
|
||||
Name = item.Name,
|
||||
NamePl = item.NamePl,
|
||||
CategoryCode = item.Category?.Code ?? string.Empty,
|
||||
SuggestedGrams = defaultGrams,
|
||||
Calories = calories,
|
||||
ProteinG = protein,
|
||||
FatG = fat,
|
||||
CarbsG = carbs,
|
||||
ReasonKey = reasonKey,
|
||||
});
|
||||
})
|
||||
.OrderByDescending(x => x.score)
|
||||
.ThenBy(x => x.Suggestion.Name)
|
||||
.Take(limit)
|
||||
.Select(x => x.Suggestion)
|
||||
.ToList();
|
||||
|
||||
return scored;
|
||||
}
|
||||
|
||||
private async Task<MealMacroSnapshotDto?> ResolveMealSnapshotAsync(LogMealDto dto, CancellationToken ct)
|
||||
{
|
||||
if (dto.RecipeId.HasValue)
|
||||
{
|
||||
var recipe = await _recipeService.GetRecipeByIdAsync(dto.RecipeId.Value, ct);
|
||||
if (recipe is null) return null;
|
||||
|
||||
return new MealMacroSnapshotDto
|
||||
{
|
||||
Name = recipe.Name,
|
||||
Portions = dto.Portions,
|
||||
Calories = ScaleInt(recipe.Calories, dto.Portions),
|
||||
ProteinG = ScaleDecimal(recipe.Protein, dto.Portions),
|
||||
FatG = ScaleDecimal(recipe.Fat, dto.Portions),
|
||||
CarbsG = ScaleDecimal(recipe.Carbs, dto.Portions),
|
||||
};
|
||||
}
|
||||
|
||||
if (dto.CatalogItemId.HasValue && dto.Grams is > 0)
|
||||
{
|
||||
var catalog = await _db.IngredientNutritionCatalog.AsNoTracking()
|
||||
.FirstOrDefaultAsync(i => i.Id == dto.CatalogItemId && i.IsActive, ct);
|
||||
if (catalog is null) return null;
|
||||
|
||||
var scale = dto.Grams.Value / 100m;
|
||||
return new MealMacroSnapshotDto
|
||||
{
|
||||
Name = string.IsNullOrWhiteSpace(dto.Name) ? catalog.Name : dto.Name.Trim(),
|
||||
Portions = 1,
|
||||
Calories = (int)Math.Round(catalog.CaloriesPer100G * scale),
|
||||
ProteinG = Math.Round(catalog.ProteinGPer100G * scale, 2),
|
||||
FatG = Math.Round(catalog.FatGPer100G * scale, 2),
|
||||
CarbsG = Math.Round(catalog.CarbsGPer100G * scale, 2),
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(dto.Name))
|
||||
{
|
||||
return new MealMacroSnapshotDto
|
||||
{
|
||||
Name = dto.Name.Trim(),
|
||||
Portions = dto.Portions,
|
||||
Calories = dto.Calories,
|
||||
ProteinG = dto.ProteinG,
|
||||
FatG = dto.FatG,
|
||||
CarbsG = dto.CarbsG,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static RemainingAfterMealDto BuildRemainingAfter(DailySummaryDto summary, MealMacroSnapshotDto meal)
|
||||
{
|
||||
int? RemainingInt(int? goal, int consumed, int? mealValue) =>
|
||||
goal.HasValue ? Math.Max(goal.Value - consumed - (mealValue ?? 0), 0) : null;
|
||||
|
||||
decimal? RemainingDec(decimal? goal, decimal consumed, decimal? mealValue) =>
|
||||
goal.HasValue ? Math.Max(goal.Value - consumed - (mealValue ?? 0), 0) : null;
|
||||
|
||||
return new RemainingAfterMealDto
|
||||
{
|
||||
Calories = RemainingInt(summary.Calories.Goal, summary.Calories.Consumed, meal.Calories),
|
||||
ProteinG = RemainingDec(summary.ProteinG.Goal, summary.ProteinG.Consumed, meal.ProteinG),
|
||||
FatG = RemainingDec(summary.FatG.Goal, summary.FatG.Consumed, meal.FatG),
|
||||
CarbsG = RemainingDec(summary.CarbsG.Goal, summary.CarbsG.Consumed, meal.CarbsG),
|
||||
};
|
||||
}
|
||||
|
||||
private static string PickPrimaryDeficit(DailySummaryDto summary)
|
||||
{
|
||||
var deficits = new List<(string Key, decimal Weight)>();
|
||||
|
||||
if (summary.ProteinG.Goal is > 0 && summary.ProteinG.Remaining is > 0)
|
||||
{
|
||||
deficits.Add(("highProtein", summary.ProteinG.Remaining.Value / summary.ProteinG.Goal.Value));
|
||||
}
|
||||
|
||||
if (summary.CarbsG.Goal is > 0 && summary.CarbsG.Remaining is > 0)
|
||||
{
|
||||
deficits.Add(("needCarbs", summary.CarbsG.Remaining.Value / summary.CarbsG.Goal.Value));
|
||||
}
|
||||
|
||||
if (summary.FatG.Goal is > 0 && summary.FatG.Remaining is > 0)
|
||||
{
|
||||
deficits.Add(("needFat", summary.FatG.Remaining.Value / summary.FatG.Goal.Value));
|
||||
}
|
||||
|
||||
if (summary.Calories.Goal is > 0 && summary.Calories.Remaining is > 0)
|
||||
{
|
||||
deficits.Add(("needCalories", (decimal)summary.Calories.Remaining.Value / summary.Calories.Goal.Value));
|
||||
}
|
||||
|
||||
return deficits.OrderByDescending(d => d.Weight).FirstOrDefault().Key ?? "balanced";
|
||||
}
|
||||
|
||||
private static decimal ScoreSuggestion(
|
||||
DailySummaryDto summary,
|
||||
string reasonKey,
|
||||
int calories,
|
||||
decimal protein,
|
||||
decimal fat,
|
||||
decimal carbs)
|
||||
{
|
||||
if (calories <= 0) return 0;
|
||||
|
||||
return reasonKey switch
|
||||
{
|
||||
"highProtein" => protein * 2m + protein / calories,
|
||||
"needCarbs" => carbs * 2m + carbs / calories,
|
||||
"needFat" => fat * 2m + fat / calories,
|
||||
"needCalories" => calories,
|
||||
_ => protein + carbs + (1000m / Math.Max(calories, 1)),
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteMealAsync(Guid userId, int id, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _db.MealConsumptions.FirstOrDefaultAsync(m => m.Id == id && m.UserId == userId, ct);
|
||||
if (entity is null) return false;
|
||||
_db.MealConsumptions.Remove(entity);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DrinkCatalogItemDto>> GetDrinkCatalogAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _db.DrinkCatalog.AsNoTracking()
|
||||
.Where(d => d.IsActive)
|
||||
.OrderBy(d => d.SortOrder)
|
||||
.Select(d => new DrinkCatalogItemDto
|
||||
{
|
||||
Id = d.Id,
|
||||
Code = d.Code,
|
||||
Name = d.Name,
|
||||
DefaultVolumeMl = d.DefaultVolumeMl,
|
||||
CaloriesPer100Ml = d.CaloriesPer100Ml,
|
||||
IconEmoji = d.IconEmoji,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DrinkConsumptionDto>> GetDrinksAsync(Guid userId, DateOnly date, CancellationToken ct = default)
|
||||
{
|
||||
var rows = await _db.DrinkConsumptions.AsNoTracking()
|
||||
.Where(d => d.UserId == userId && d.LogDate == date)
|
||||
.OrderByDescending(d => d.ConsumedAt)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(MapDrink).ToList();
|
||||
}
|
||||
|
||||
public async Task<DrinkConsumptionDto?> LogDrinkAsync(Guid userId, LogDrinkDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var consumedAt = dto.ConsumedAt ?? DateTime.UtcNow;
|
||||
var logDate = dto.LogDate ?? DateOnly.FromDateTime(consumedAt);
|
||||
|
||||
string name;
|
||||
int? catalogId = dto.DrinkCatalogId;
|
||||
int? calories = dto.Calories;
|
||||
|
||||
if (dto.DrinkCatalogId.HasValue)
|
||||
{
|
||||
var catalog = await _db.DrinkCatalog.AsNoTracking()
|
||||
.FirstOrDefaultAsync(d => d.Id == dto.DrinkCatalogId && d.IsActive, ct);
|
||||
if (catalog is null) return null;
|
||||
|
||||
name = dto.Name?.Trim() ?? catalog.Name;
|
||||
if (!calories.HasValue && catalog.CaloriesPer100Ml.HasValue)
|
||||
{
|
||||
calories = (int)Math.Round(catalog.CaloriesPer100Ml.Value * dto.VolumeMl / 100m);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
name = dto.Name!.Trim();
|
||||
catalogId = null;
|
||||
}
|
||||
|
||||
var entity = new DrinkConsumption
|
||||
{
|
||||
UserId = userId,
|
||||
DrinkCatalogId = catalogId,
|
||||
LogDate = logDate,
|
||||
ConsumedAt = consumedAt,
|
||||
Name = name,
|
||||
VolumeMl = dto.VolumeMl,
|
||||
Calories = calories,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
_db.DrinkConsumptions.Add(entity);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return MapDrink(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteDrinkAsync(Guid userId, int id, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _db.DrinkConsumptions.FirstOrDefaultAsync(d => d.Id == id && d.UserId == userId, ct);
|
||||
if (entity is null) return false;
|
||||
_db.DrinkConsumptions.Remove(entity);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DietReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var rows = await _db.DietReminders.AsNoTracking()
|
||||
.Where(r => r.UserId == userId)
|
||||
.OrderBy(r => r.TimeOfDay)
|
||||
.ToListAsync(ct);
|
||||
return rows.Select(MapReminder).ToList();
|
||||
}
|
||||
|
||||
public async Task<DietReminderDto?> CreateReminderAsync(Guid userId, CreateDietReminderDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var entity = new DietReminder
|
||||
{
|
||||
UserId = userId,
|
||||
ReminderType = dto.ReminderType,
|
||||
Title = dto.Title.Trim(),
|
||||
Message = dto.Message?.Trim(),
|
||||
TimeOfDay = dto.TimeOfDay,
|
||||
DaysOfWeekMask = dto.DaysOfWeekMask,
|
||||
MealCategory = dto.MealCategory,
|
||||
IsEnabled = dto.IsEnabled,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
_db.DietReminders.Add(entity);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return MapReminder(entity);
|
||||
}
|
||||
|
||||
public async Task<DietReminderDto?> UpdateReminderAsync(Guid userId, int id, UpdateDietReminderDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _db.DietReminders.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
||||
if (entity is null) return null;
|
||||
|
||||
entity.ReminderType = dto.ReminderType;
|
||||
entity.Title = dto.Title.Trim();
|
||||
entity.Message = dto.Message?.Trim();
|
||||
entity.TimeOfDay = dto.TimeOfDay;
|
||||
entity.DaysOfWeekMask = dto.DaysOfWeekMask;
|
||||
entity.MealCategory = dto.MealCategory;
|
||||
entity.IsEnabled = dto.IsEnabled;
|
||||
entity.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return MapReminder(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteReminderAsync(Guid userId, int id, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _db.DietReminders.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct);
|
||||
if (entity is null) return false;
|
||||
_db.DietReminders.Remove(entity);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<DueReminderDto>> GetDueRemindersAsync(
|
||||
Guid userId,
|
||||
DateOnly? date,
|
||||
TimeOnly? time,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var targetTime = time ?? TimeOnly.FromDateTime(DateTime.UtcNow);
|
||||
var dayOfWeek = targetDate.ToDateTime(TimeOnly.MinValue).DayOfWeek;
|
||||
|
||||
var reminders = await _db.DietReminders.AsNoTracking()
|
||||
.Where(r => r.UserId == userId && r.IsEnabled)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return reminders
|
||||
.Where(r => ReminderDaysMask.IncludesDay(r.DaysOfWeekMask, dayOfWeek))
|
||||
.Where(r => r.TimeOfDay.Hour == targetTime.Hour && r.TimeOfDay.Minute == targetTime.Minute)
|
||||
.Select(r => new DueReminderDto
|
||||
{
|
||||
Id = r.Id,
|
||||
ReminderType = r.ReminderType,
|
||||
Title = r.Title,
|
||||
Message = r.Message,
|
||||
TimeOfDay = r.TimeOfDay,
|
||||
MealCategory = r.MealCategory,
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<HistoryDayDto>> GetHistoryAsync(
|
||||
Guid userId,
|
||||
DateOnly from,
|
||||
DateOnly to,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var meals = await _db.MealConsumptions.AsNoTracking()
|
||||
.Where(m => m.UserId == userId && m.LogDate >= from && m.LogDate <= to)
|
||||
.GroupBy(m => m.LogDate)
|
||||
.Select(g => new
|
||||
{
|
||||
Date = g.Key,
|
||||
Calories = g.Sum(m => m.Calories ?? 0),
|
||||
ProteinG = g.Sum(m => m.ProteinG ?? 0),
|
||||
FatG = g.Sum(m => m.FatG ?? 0),
|
||||
CarbsG = g.Sum(m => m.CarbsG ?? 0),
|
||||
MealCount = g.Count(),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
|
||||
var drinks = await _db.DrinkConsumptions.AsNoTracking()
|
||||
.Where(d => d.UserId == userId && d.LogDate >= from && d.LogDate <= to)
|
||||
.GroupBy(d => d.LogDate)
|
||||
.Select(g => new { Date = g.Key, WaterMl = g.Sum(d => d.VolumeMl) })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var drinkMap = drinks.ToDictionary(d => d.Date, d => d.WaterMl);
|
||||
var days = new List<HistoryDayDto>();
|
||||
for (var d = from; d <= to; d = d.AddDays(1))
|
||||
{
|
||||
var meal = meals.FirstOrDefault(m => m.Date == d);
|
||||
days.Add(new HistoryDayDto
|
||||
{
|
||||
Date = d,
|
||||
Calories = meal?.Calories ?? 0,
|
||||
ProteinG = meal?.ProteinG ?? 0,
|
||||
FatG = meal?.FatG ?? 0,
|
||||
CarbsG = meal?.CarbsG ?? 0,
|
||||
WaterMl = drinkMap.GetValueOrDefault(d, 0),
|
||||
MealCount = meal?.MealCount ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<RecentMealDto>> GetRecentMealsAsync(
|
||||
Guid userId,
|
||||
int limit = 10,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var rows = await _db.MealConsumptions.AsNoTracking()
|
||||
.Where(m => m.UserId == userId)
|
||||
.OrderByDescending(m => m.ConsumedAt)
|
||||
.Take(Math.Clamp(limit * 3, 10, 100))
|
||||
.ToListAsync(ct);
|
||||
|
||||
var seen = new HashSet<string>();
|
||||
var result = new List<RecentMealDto>();
|
||||
foreach (var m in rows)
|
||||
{
|
||||
var key = m.RecipeId.HasValue
|
||||
? $"r:{m.RecipeId}:{m.MealCategory}"
|
||||
: $"n:{m.Name.ToLowerInvariant()}:{m.MealCategory}";
|
||||
if (!seen.Add(key)) continue;
|
||||
|
||||
result.Add(new RecentMealDto
|
||||
{
|
||||
RecipeId = m.RecipeId,
|
||||
Name = m.Name,
|
||||
MealCategory = m.MealCategory,
|
||||
Portions = m.Portions,
|
||||
Calories = m.Calories,
|
||||
ProteinG = m.ProteinG,
|
||||
FatG = m.FatG,
|
||||
CarbsG = m.CarbsG,
|
||||
LastLoggedAt = m.ConsumedAt,
|
||||
});
|
||||
if (result.Count >= limit) break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public async Task<CopyMealsResultDto?> CopyYesterdayMealsAsync(
|
||||
Guid userId,
|
||||
DateOnly targetDate,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var sourceDate = targetDate.AddDays(-1);
|
||||
var sourceMeals = await _db.MealConsumptions
|
||||
.Where(m => m.UserId == userId && m.LogDate == sourceDate)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (sourceMeals.Count == 0)
|
||||
{
|
||||
return new CopyMealsResultDto
|
||||
{
|
||||
SourceDate = sourceDate,
|
||||
TargetDate = targetDate,
|
||||
CopiedCount = 0,
|
||||
};
|
||||
}
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var src in sourceMeals)
|
||||
{
|
||||
_db.MealConsumptions.Add(new MealConsumption
|
||||
{
|
||||
UserId = userId,
|
||||
RecipeId = src.RecipeId,
|
||||
MealCategory = src.MealCategory,
|
||||
LogDate = targetDate,
|
||||
ConsumedAt = now,
|
||||
Name = src.Name,
|
||||
Portions = src.Portions,
|
||||
Calories = src.Calories,
|
||||
ProteinG = src.ProteinG,
|
||||
FatG = src.FatG,
|
||||
CarbsG = src.CarbsG,
|
||||
Notes = src.Notes,
|
||||
CreatedAt = now,
|
||||
});
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return new CopyMealsResultDto
|
||||
{
|
||||
SourceDate = sourceDate,
|
||||
TargetDate = targetDate,
|
||||
CopiedCount = sourceMeals.Count,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FavoriteRecipeDto>> GetFavoriteRecipesAsync(
|
||||
Guid userId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _db.UserFavoriteRecipes.AsNoTracking()
|
||||
.Include(f => f.Recipe)
|
||||
.Where(f => f.UserId == userId && f.Recipe!.IsActive)
|
||||
.OrderBy(f => f.Recipe!.Name)
|
||||
.Select(f => new FavoriteRecipeDto
|
||||
{
|
||||
RecipeId = f.RecipeId,
|
||||
Name = f.Recipe!.Name,
|
||||
MealCategory = f.Recipe!.MealCategory,
|
||||
Calories = f.Recipe!.Calories,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<FavoriteRecipeDto?> AddFavoriteRecipeAsync(
|
||||
Guid userId,
|
||||
int recipeId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var recipe = await _db.Recipes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.Id == recipeId && r.IsActive, ct);
|
||||
if (recipe is null) return null;
|
||||
|
||||
var exists = await _db.UserFavoriteRecipes
|
||||
.AnyAsync(f => f.UserId == userId && f.RecipeId == recipeId, ct);
|
||||
if (!exists)
|
||||
{
|
||||
_db.UserFavoriteRecipes.Add(new UserFavoriteRecipe
|
||||
{
|
||||
UserId = userId,
|
||||
RecipeId = recipeId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return new FavoriteRecipeDto
|
||||
{
|
||||
RecipeId = recipe.Id,
|
||||
Name = recipe.Name,
|
||||
MealCategory = recipe.MealCategory,
|
||||
Calories = recipe.Calories,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveFavoriteRecipeAsync(Guid userId, int recipeId, CancellationToken ct = default)
|
||||
{
|
||||
var fav = await _db.UserFavoriteRecipes
|
||||
.FirstOrDefaultAsync(f => f.UserId == userId && f.RecipeId == recipeId, ct);
|
||||
if (fav is null) return false;
|
||||
_db.UserFavoriteRecipes.Remove(fav);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<FavoriteCatalogItemDto>> GetFavoriteCatalogItemsAsync(
|
||||
Guid userId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _db.UserFavoriteCatalogItems.AsNoTracking()
|
||||
.Include(f => f.CatalogItem)
|
||||
.ThenInclude(c => c!.Category)
|
||||
.Where(f => f.UserId == userId && f.CatalogItem!.IsActive)
|
||||
.OrderBy(f => f.CatalogItem!.Name)
|
||||
.Select(f => new FavoriteCatalogItemDto
|
||||
{
|
||||
CatalogItemId = f.CatalogItemId,
|
||||
Name = f.CatalogItem!.Name,
|
||||
NamePl = f.CatalogItem!.NamePl,
|
||||
CategoryCode = f.CatalogItem!.Category!.Code,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<FavoriteCatalogItemDto?> AddFavoriteCatalogItemAsync(
|
||||
Guid userId,
|
||||
int catalogItemId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var item = await _db.IngredientNutritionCatalog.AsNoTracking()
|
||||
.Include(i => i.Category)
|
||||
.FirstOrDefaultAsync(i => i.Id == catalogItemId && i.IsActive, ct);
|
||||
if (item is null) return null;
|
||||
|
||||
var exists = await _db.UserFavoriteCatalogItems
|
||||
.AnyAsync(f => f.UserId == userId && f.CatalogItemId == catalogItemId, ct);
|
||||
if (!exists)
|
||||
{
|
||||
_db.UserFavoriteCatalogItems.Add(new UserFavoriteCatalogItem
|
||||
{
|
||||
UserId = userId,
|
||||
CatalogItemId = catalogItemId,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
return new FavoriteCatalogItemDto
|
||||
{
|
||||
CatalogItemId = item.Id,
|
||||
Name = item.Name,
|
||||
NamePl = item.NamePl,
|
||||
CategoryCode = item.Category?.Code ?? string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> RemoveFavoriteCatalogItemAsync(
|
||||
Guid userId,
|
||||
int catalogItemId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var fav = await _db.UserFavoriteCatalogItems
|
||||
.FirstOrDefaultAsync(f => f.UserId == userId && f.CatalogItemId == catalogItemId, ct);
|
||||
if (fav is null) return false;
|
||||
_db.UserFavoriteCatalogItems.Remove(fav);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static UserDailyGoalsDto MapGoals(UserDailyGoal g) => new()
|
||||
{
|
||||
CalorieGoal = g.CalorieGoal,
|
||||
ProteinGoalG = g.ProteinGoalG,
|
||||
FatGoalG = g.FatGoalG,
|
||||
CarbsGoalG = g.CarbsGoalG,
|
||||
WaterGoalMl = g.WaterGoalMl,
|
||||
};
|
||||
|
||||
private static MealConsumptionDto MapMeal(MealConsumption m) => new()
|
||||
{
|
||||
Id = m.Id,
|
||||
RecipeId = m.RecipeId,
|
||||
MealCategory = m.MealCategory,
|
||||
LogDate = m.LogDate,
|
||||
ConsumedAt = m.ConsumedAt,
|
||||
Name = m.Name,
|
||||
Portions = m.Portions,
|
||||
Calories = m.Calories,
|
||||
ProteinG = m.ProteinG,
|
||||
FatG = m.FatG,
|
||||
CarbsG = m.CarbsG,
|
||||
Notes = m.Notes,
|
||||
};
|
||||
|
||||
private static DrinkConsumptionDto MapDrink(DrinkConsumption d) => new()
|
||||
{
|
||||
Id = d.Id,
|
||||
DrinkCatalogId = d.DrinkCatalogId,
|
||||
LogDate = d.LogDate,
|
||||
ConsumedAt = d.ConsumedAt,
|
||||
Name = d.Name,
|
||||
VolumeMl = d.VolumeMl,
|
||||
Calories = d.Calories,
|
||||
};
|
||||
|
||||
private static DietReminderDto MapReminder(DietReminder r) => new()
|
||||
{
|
||||
Id = r.Id,
|
||||
ReminderType = r.ReminderType,
|
||||
Title = r.Title,
|
||||
Message = r.Message,
|
||||
TimeOfDay = r.TimeOfDay,
|
||||
DaysOfWeekMask = r.DaysOfWeekMask,
|
||||
MealCategory = r.MealCategory,
|
||||
IsEnabled = r.IsEnabled,
|
||||
};
|
||||
|
||||
private static MacroRemainingDto BuildIntMacro(int? goal, int consumed) => new()
|
||||
{
|
||||
Goal = goal,
|
||||
Consumed = consumed,
|
||||
Remaining = goal.HasValue ? Math.Max(goal.Value - consumed, 0) : null,
|
||||
ProgressPercent = goal is > 0 ? Math.Round(consumed * 100m / goal.Value, 1) : null,
|
||||
};
|
||||
|
||||
private static DecimalMacroRemainingDto BuildDecimalMacro(decimal? goal, decimal consumed) => new()
|
||||
{
|
||||
Goal = goal,
|
||||
Consumed = consumed,
|
||||
Remaining = goal.HasValue ? Math.Max(goal.Value - consumed, 0) : null,
|
||||
ProgressPercent = goal is > 0 ? Math.Round(consumed * 100m / goal.Value, 1) : null,
|
||||
};
|
||||
|
||||
private static int? ScaleInt(int? value, decimal portions) =>
|
||||
value.HasValue ? (int)Math.Round(value.Value * portions) : null;
|
||||
|
||||
private static decimal? ScaleDecimal(decimal? value, decimal portions) =>
|
||||
value.HasValue ? Math.Round(value.Value * portions, 2) : null;
|
||||
}
|
||||
39
MealPlan.Api/Services/Generators/AiRecipeJsonParser.cs
Normal file
39
MealPlan.Api/Services/Generators/AiRecipeJsonParser.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace MealPlan.Api.Services.Generators;
|
||||
|
||||
internal static class AiRecipeJsonParser
|
||||
{
|
||||
public static int GetInt32(JsonElement element, int fallback = 0)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Number when element.TryGetInt32(out var value) => value,
|
||||
JsonValueKind.Number => (int)element.GetDecimal(),
|
||||
JsonValueKind.String when int.TryParse(element.GetString(), out var parsed) => parsed,
|
||||
JsonValueKind.True => 1,
|
||||
JsonValueKind.False => 0,
|
||||
_ => fallback,
|
||||
};
|
||||
}
|
||||
|
||||
public static int? GetNullableInt32(JsonElement element)
|
||||
{
|
||||
if (element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined) return null;
|
||||
return GetInt32(element);
|
||||
}
|
||||
|
||||
public static decimal? GetNullableDecimal(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.Null or JsonValueKind.Undefined => null,
|
||||
JsonValueKind.Number => element.GetDecimal(),
|
||||
JsonValueKind.String when decimal.TryParse(element.GetString(), out var parsed) => parsed,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
public static string? GetString(JsonElement element) =>
|
||||
element.ValueKind == JsonValueKind.String ? element.GetString() : element.ToString();
|
||||
}
|
||||
60
MealPlan.Api/Services/Generators/CatalogMatchingService.cs
Normal file
60
MealPlan.Api/Services/Generators/CatalogMatchingService.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using MealPlan.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MealPlan.Api.Services.Generators;
|
||||
|
||||
public interface ICatalogMatchingService
|
||||
{
|
||||
Task<int?> ResolveCatalogItemIdAsync(string nameOrCatalogName, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class CatalogMatchingService : ICatalogMatchingService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private List<CatalogEntry>? _cache;
|
||||
|
||||
public CatalogMatchingService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<int?> ResolveCatalogItemIdAsync(string nameOrCatalogName, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(nameOrCatalogName)) return null;
|
||||
|
||||
var cache = await GetCacheAsync(ct);
|
||||
var key = Normalize(nameOrCatalogName);
|
||||
|
||||
var exact = cache.FirstOrDefault(c =>
|
||||
c.NameKey == key || c.NamePlKey == key || c.CatalogNameKey == key);
|
||||
if (exact is not null) return exact.Id;
|
||||
|
||||
return cache
|
||||
.Where(c => key.Contains(c.NameKey, StringComparison.Ordinal) ||
|
||||
c.NameKey.Contains(key, StringComparison.Ordinal) ||
|
||||
(!string.IsNullOrEmpty(c.NamePlKey) &&
|
||||
(key.Contains(c.NamePlKey, StringComparison.Ordinal) ||
|
||||
c.NamePlKey.Contains(key, StringComparison.Ordinal))))
|
||||
.OrderBy(c => Math.Abs(c.NameKey.Length - key.Length))
|
||||
.FirstOrDefault()
|
||||
?.Id;
|
||||
}
|
||||
|
||||
private async Task<List<CatalogEntry>> GetCacheAsync(CancellationToken ct)
|
||||
{
|
||||
if (_cache is not null) return _cache;
|
||||
|
||||
_cache = await _db.IngredientNutritionCatalog.AsNoTracking()
|
||||
.Where(i => i.IsActive)
|
||||
.Select(i => new CatalogEntry(i.Id, i.Name, i.NamePl))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return _cache;
|
||||
}
|
||||
|
||||
private static string Normalize(string value) => value.Trim().ToLowerInvariant();
|
||||
|
||||
private sealed record CatalogEntry(int Id, string Name, string? NamePl)
|
||||
{
|
||||
public string NameKey { get; } = Name.Trim().ToLowerInvariant();
|
||||
public string NamePlKey { get; } = NamePl?.Trim().ToLowerInvariant() ?? string.Empty;
|
||||
public string CatalogNameKey { get; } = Name.Trim().ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
780
MealPlan.Api/Services/Generators/DietGeneratorService.cs
Normal file
780
MealPlan.Api/Services/Generators/DietGeneratorService.cs
Normal file
@@ -0,0 +1,780 @@
|
||||
using System.Text.Json;
|
||||
using MealPlan.Api.Data;
|
||||
using MealPlan.Api.DTOs.Diet;
|
||||
using MealPlan.Api.DTOs.Generators;
|
||||
using MealPlan.Api.Models;
|
||||
using MealPlan.Api.Services.AI;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MealPlan.Api.Services.Generators;
|
||||
|
||||
public interface IDietGeneratorService
|
||||
{
|
||||
Task<DietUserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<DietUserProfileDto> UpsertProfileAsync(Guid userId, DietUserProfileDto dto, CancellationToken ct = default);
|
||||
Task<DietGeneratorSessionDto> CreateSessionAsync(Guid userId, CreateDietGeneratorSessionDto dto, CancellationToken ct = default);
|
||||
Task<DietGeneratorSessionDto?> GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default);
|
||||
Task<DietGeneratorSessionDto?> ProposeMacrosAsync(Guid userId, int sessionId, CancellationToken ct = default);
|
||||
Task<DietGeneratorSessionDto?> AcceptMacrosAsync(Guid userId, int sessionId, AcceptMacrosDto dto, CancellationToken ct = default);
|
||||
Task<DietGeneratorSessionDto?> GeneratePlanAsync(Guid userId, int sessionId, CancellationToken ct = default);
|
||||
Task<DietGeneratorSessionDto?> RegenerateMealAsync(Guid userId, int sessionId, RegenerateDietMealDto dto, CancellationToken ct = default);
|
||||
Task<DietGeneratorSessionDto?> UpdateDraftMealAsync(Guid userId, int sessionId, int mealId, UpdateDraftMealDto dto, CancellationToken ct = default);
|
||||
Task<CommitDietPlanResultDto?> CommitAsync(Guid userId, int sessionId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class DietGeneratorService : IDietGeneratorService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IOpenAiChatService _openAi;
|
||||
|
||||
public DietGeneratorService(AppDbContext db, IOpenAiChatService openAi)
|
||||
{
|
||||
_db = db;
|
||||
_openAi = openAi;
|
||||
}
|
||||
|
||||
public async Task<DietUserProfileDto?> GetProfileAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var profile = await _db.DietUserProfiles.AsNoTracking().FirstOrDefaultAsync(p => p.UserId == userId, ct);
|
||||
return profile is null ? null : MapProfile(profile);
|
||||
}
|
||||
|
||||
public async Task<DietUserProfileDto> UpsertProfileAsync(Guid userId, DietUserProfileDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var profile = await _db.DietUserProfiles.FirstOrDefaultAsync(p => p.UserId == userId, ct);
|
||||
if (profile is null)
|
||||
{
|
||||
profile = new DietUserProfile { UserId = userId, CreatedAt = DateTime.UtcNow };
|
||||
_db.DietUserProfiles.Add(profile);
|
||||
}
|
||||
|
||||
profile.HeightCm = dto.HeightCm;
|
||||
profile.WeightKg = dto.WeightKg;
|
||||
profile.Age = dto.Age;
|
||||
profile.Sex = dto.Sex;
|
||||
profile.ActivityLevel = dto.ActivityLevel;
|
||||
profile.Goal = dto.Goal;
|
||||
profile.AllergiesNotes = dto.AllergiesNotes;
|
||||
profile.DislikedFoods = dto.DislikedFoods;
|
||||
profile.MealsPerDayMask = dto.MealsPerDayMask;
|
||||
profile.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return MapProfile(profile);
|
||||
}
|
||||
|
||||
public async Task<DietGeneratorSessionDto> CreateSessionAsync(
|
||||
Guid userId,
|
||||
CreateDietGeneratorSessionDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var profile = await _db.DietUserProfiles.AsNoTracking().FirstOrDefaultAsync(p => p.UserId == userId, ct)
|
||||
?? throw new InvalidOperationException("Save your diet profile before starting a generator session.");
|
||||
|
||||
var session = new DietGeneratorSession
|
||||
{
|
||||
UserId = userId,
|
||||
Status = DietGeneratorStatuses.MacrosPending,
|
||||
PlanStartDate = dto.PlanStartDate ?? DateOnly.FromDateTime(DateTime.UtcNow),
|
||||
PlanDayCount = dto.PlanDayCount,
|
||||
CalorieToleranceKcal = dto.CalorieToleranceKcal,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
_db.DietGeneratorSessions.Add(session);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var calculated = TdeeCalculator.Calculate(
|
||||
profile.HeightCm, profile.WeightKg, profile.Age, profile.Sex, profile.ActivityLevel, profile.Goal);
|
||||
|
||||
session.ProposedCalories = calculated.Calories;
|
||||
session.ProposedProteinG = calculated.ProteinG;
|
||||
session.ProposedFatG = calculated.FatG;
|
||||
session.ProposedCarbsG = calculated.CarbsG;
|
||||
session.MacroRationale = calculated.Rationale;
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return await MapSessionAsync(session.Id, ct) ?? throw new InvalidOperationException("Session not found.");
|
||||
}
|
||||
|
||||
public async Task<DietGeneratorSessionDto?> GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default)
|
||||
{
|
||||
await BackfillMissingDraftIngredientsAsync(userId, sessionId, ct);
|
||||
return await MapSessionAsync(sessionId, userId, ct);
|
||||
}
|
||||
|
||||
public async Task<DietGeneratorSessionDto?> ProposeMacrosAsync(Guid userId, int sessionId, CancellationToken ct = default)
|
||||
{
|
||||
var session = await GetEditableSessionAsync(userId, sessionId, ct);
|
||||
if (session is null) return null;
|
||||
|
||||
var profile = await _db.DietUserProfiles.AsNoTracking().FirstAsync(p => p.UserId == userId, ct);
|
||||
var calculated = TdeeCalculator.Calculate(
|
||||
profile.HeightCm, profile.WeightKg, profile.Age, profile.Sex, profile.ActivityLevel, profile.Goal);
|
||||
|
||||
if (_openAi.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
var system = """
|
||||
You are a sports nutrition assistant. Respond ONLY with JSON:
|
||||
{"calories":number,"proteinG":number,"fatG":number,"carbsG":number,"rationale":"string"}
|
||||
Keep calories within 10% of the provided baseline. Use realistic macro splits.
|
||||
""";
|
||||
var user = JsonSerializer.Serialize(new
|
||||
{
|
||||
baseline = calculated,
|
||||
profile = new
|
||||
{
|
||||
profile.HeightCm,
|
||||
profile.WeightKg,
|
||||
profile.Age,
|
||||
profile.Sex,
|
||||
profile.ActivityLevel,
|
||||
profile.Goal,
|
||||
profile.AllergiesNotes,
|
||||
},
|
||||
});
|
||||
|
||||
var json = await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Macros, ct);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
session.ProposedCalories = root.TryGetProperty("calories", out var c)
|
||||
? c.GetInt32()
|
||||
: calculated.Calories;
|
||||
session.ProposedProteinG = root.TryGetProperty("proteinG", out var p)
|
||||
? p.GetDecimal()
|
||||
: calculated.ProteinG;
|
||||
session.ProposedFatG = root.TryGetProperty("fatG", out var f)
|
||||
? f.GetDecimal()
|
||||
: calculated.FatG;
|
||||
session.ProposedCarbsG = root.TryGetProperty("carbsG", out var cb)
|
||||
? cb.GetDecimal()
|
||||
: calculated.CarbsG;
|
||||
session.MacroRationale = root.TryGetProperty("rationale", out var r)
|
||||
? r.GetString()
|
||||
: calculated.Rationale;
|
||||
}
|
||||
catch
|
||||
{
|
||||
ApplyCalculatedMacros(session, calculated);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
ApplyCalculatedMacros(session, calculated);
|
||||
}
|
||||
|
||||
session.Status = DietGeneratorStatuses.MacrosPending;
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await MapSessionAsync(sessionId, ct);
|
||||
}
|
||||
|
||||
public async Task<DietGeneratorSessionDto?> AcceptMacrosAsync(
|
||||
Guid userId,
|
||||
int sessionId,
|
||||
AcceptMacrosDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = await GetEditableSessionAsync(userId, sessionId, ct);
|
||||
if (session is null) return null;
|
||||
|
||||
if (dto.Calories.HasValue) session.ProposedCalories = dto.Calories;
|
||||
if (dto.ProteinG.HasValue) session.ProposedProteinG = dto.ProteinG;
|
||||
if (dto.FatG.HasValue) session.ProposedFatG = dto.FatG;
|
||||
if (dto.CarbsG.HasValue) session.ProposedCarbsG = dto.CarbsG;
|
||||
|
||||
session.Status = DietGeneratorStatuses.MacrosAccepted;
|
||||
session.MacrosAcceptedAt = DateTime.UtcNow;
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await MapSessionAsync(sessionId, ct);
|
||||
}
|
||||
|
||||
public async Task<DietGeneratorSessionDto?> GeneratePlanAsync(Guid userId, int sessionId, CancellationToken ct = default)
|
||||
{
|
||||
var session = await GetEditableSessionAsync(userId, sessionId, ct);
|
||||
if (session is null || !session.ProposedCalories.HasValue) return null;
|
||||
|
||||
var profile = await _db.DietUserProfiles.AsNoTracking().FirstAsync(p => p.UserId == userId, ct);
|
||||
var pool = await LoadRecipePoolAsync(userId, ct);
|
||||
if (pool.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("No recipes with complete macros are available for planning.");
|
||||
}
|
||||
|
||||
var mealCategories = MealPlanOptimizer.MealCategoriesFromMask(profile.MealsPerDayMask);
|
||||
var requiredUnique = MealPlanOptimizer.RequiredUniqueRecipes(session.PlanDayCount, mealCategories);
|
||||
if (pool.Count < requiredUnique)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Need at least {requiredUnique} unique recipes for a {session.PlanDayCount}-day plan " +
|
||||
$"({mealCategories.Count} meals/day), but only {pool.Count} are available.");
|
||||
}
|
||||
|
||||
var start = session.PlanStartDate ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var usedRecipes = new HashSet<int>();
|
||||
var allSlots = new List<DraftMealSlot>();
|
||||
|
||||
if (_openAi.IsConfigured)
|
||||
{
|
||||
try
|
||||
{
|
||||
allSlots = await GeneratePlanWithAiAsync(session, pool, mealCategories, start, usedRecipes, ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
allSlots = BuildGreedyPlan(session, pool, mealCategories, start, usedRecipes);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
allSlots = BuildGreedyPlan(session, pool, mealCategories, start, usedRecipes);
|
||||
}
|
||||
|
||||
MealPlanOptimizer.EnsureWeeklyUniqueRecipes(
|
||||
allSlots,
|
||||
pool,
|
||||
mealCategories,
|
||||
session.ProposedCalories!.Value,
|
||||
session.CalorieToleranceKcal);
|
||||
|
||||
_db.DietGeneratorDraftMeals.RemoveRange(
|
||||
await _db.DietGeneratorDraftMeals.Where(m => m.SessionId == sessionId).ToListAsync(ct));
|
||||
|
||||
var recipes = pool.ToDictionary(r => r.Id);
|
||||
foreach (var slot in allSlots)
|
||||
{
|
||||
if (!recipes.TryGetValue(slot.RecipeId, out var recipe)) continue;
|
||||
_db.DietGeneratorDraftMeals.Add(new DietGeneratorDraftMeal
|
||||
{
|
||||
SessionId = sessionId,
|
||||
PlanDate = slot.PlanDate,
|
||||
MealCategory = slot.MealCategory,
|
||||
RecipeId = slot.RecipeId,
|
||||
Portions = slot.Portions,
|
||||
Calories = slot.TotalCalories,
|
||||
ProteinG = Math.Round(slot.TotalProtein, 2),
|
||||
FatG = Math.Round(slot.TotalFat, 2),
|
||||
CarbsG = Math.Round(slot.TotalCarbs, 2),
|
||||
GenerationVersion = 1,
|
||||
});
|
||||
}
|
||||
|
||||
session.Status = DietGeneratorStatuses.PlanDraft;
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var draftMeals = await _db.DietGeneratorDraftMeals
|
||||
.Where(m => m.SessionId == sessionId)
|
||||
.ToListAsync(ct);
|
||||
foreach (var draftMeal in draftMeals)
|
||||
{
|
||||
await SyncDraftMealIngredientsAsync(draftMeal.Id, draftMeal.RecipeId, draftMeal.Portions, ct);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await MapSessionAsync(sessionId, ct);
|
||||
}
|
||||
|
||||
public async Task<DietGeneratorSessionDto?> RegenerateMealAsync(
|
||||
Guid userId,
|
||||
int sessionId,
|
||||
RegenerateDietMealDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = await GetEditableSessionAsync(userId, sessionId, ct);
|
||||
if (session is null || !session.ProposedCalories.HasValue) return null;
|
||||
|
||||
var draftMeals = await _db.DietGeneratorDraftMeals
|
||||
.Where(m => m.SessionId == sessionId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var dayMeals = draftMeals.Where(m => m.PlanDate == dto.PlanDate).ToList();
|
||||
var target = session.ProposedCalories.Value;
|
||||
var lockedCalories = dayMeals.Where(m => m.IsLocked && m.PlanDate == dto.PlanDate).Sum(m => m.Calories ?? 0);
|
||||
var remaining = Math.Max(200, target - lockedCalories);
|
||||
var unlockedSlots = dayMeals.Count(m => !m.IsLocked);
|
||||
var slotTarget = unlockedSlots > 0 ? remaining / unlockedSlots : remaining;
|
||||
|
||||
var pool = await LoadRecipePoolAsync(userId, ct);
|
||||
|
||||
var existing = dayMeals.FirstOrDefault(m => m.MealCategory == dto.MealCategory);
|
||||
if (existing is null) return null;
|
||||
if (existing.IsLocked) return await MapSessionAsync(sessionId, ct);
|
||||
|
||||
var excluded = draftMeals
|
||||
.Where(m => m.Id != existing.Id)
|
||||
.Select(m => m.RecipeId)
|
||||
.ToHashSet();
|
||||
excluded.Add(existing.RecipeId);
|
||||
|
||||
if (!MealPlanOptimizer.TryPickGreedyMeal(pool, dto.MealCategory, slotTarget, excluded, out var picked))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"No other recipes are available for this meal. All suitable options are already used in the plan.");
|
||||
}
|
||||
|
||||
var recipe = pool.First(r => r.Id == picked.RecipeId);
|
||||
|
||||
existing.RecipeId = picked.RecipeId;
|
||||
existing.Portions = picked.Portions;
|
||||
existing.Calories = picked.TotalCalories;
|
||||
existing.ProteinG = Math.Round(picked.TotalProtein, 2);
|
||||
existing.FatG = Math.Round(picked.TotalFat, 2);
|
||||
existing.CarbsG = Math.Round(picked.TotalCarbs, 2);
|
||||
existing.GenerationVersion++;
|
||||
|
||||
var slots = dayMeals.Select(m => new DraftMealSlot
|
||||
{
|
||||
PlanDate = m.PlanDate,
|
||||
MealCategory = m.MealCategory,
|
||||
RecipeId = m.RecipeId,
|
||||
Portions = m.Portions,
|
||||
CaloriesPerPortion = recipe.Calories,
|
||||
FatPerPortion = recipe.Fat,
|
||||
ProteinPerPortion = recipe.Protein,
|
||||
CarbsPerPortion = recipe.Carbs,
|
||||
IsLocked = m.IsLocked,
|
||||
}).ToList();
|
||||
|
||||
foreach (var slot in slots)
|
||||
{
|
||||
var r = pool.First(x => x.Id == slot.RecipeId);
|
||||
slot.CaloriesPerPortion = r.Calories;
|
||||
slot.ProteinPerPortion = r.Protein;
|
||||
slot.FatPerPortion = r.Fat;
|
||||
slot.CarbsPerPortion = r.Carbs;
|
||||
}
|
||||
|
||||
MealPlanOptimizer.BalanceDay(slots, target, session.CalorieToleranceKcal);
|
||||
foreach (var slot in slots)
|
||||
{
|
||||
var entity = dayMeals.First(m => m.MealCategory == slot.MealCategory);
|
||||
var r = pool.First(x => x.Id == slot.RecipeId);
|
||||
entity.Portions = slot.Portions;
|
||||
entity.Calories = slot.TotalCalories;
|
||||
entity.ProteinG = Math.Round(slot.TotalProtein, 2);
|
||||
entity.FatG = Math.Round(slot.TotalFat, 2);
|
||||
entity.CarbsG = Math.Round(slot.TotalCarbs, 2);
|
||||
}
|
||||
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await SyncDraftMealIngredientsAsync(existing.Id, existing.RecipeId, existing.Portions, ct);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await MapSessionAsync(sessionId, ct);
|
||||
}
|
||||
|
||||
public async Task<DietGeneratorSessionDto?> UpdateDraftMealAsync(
|
||||
Guid userId,
|
||||
int sessionId,
|
||||
int mealId,
|
||||
UpdateDraftMealDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = await GetEditableSessionAsync(userId, sessionId, ct);
|
||||
if (session is null) return null;
|
||||
|
||||
var meal = await _db.DietGeneratorDraftMeals
|
||||
.FirstOrDefaultAsync(m => m.Id == mealId && m.SessionId == sessionId, ct);
|
||||
if (meal is null) return null;
|
||||
|
||||
if (dto.IsLocked.HasValue) meal.IsLocked = dto.IsLocked.Value;
|
||||
if (dto.RecipeId.HasValue)
|
||||
{
|
||||
var recipe = await _db.Recipes.AsNoTracking()
|
||||
.FirstOrDefaultAsync(r => r.Id == dto.RecipeId && r.IsActive && !r.IsDraft, ct);
|
||||
if (recipe is null || !recipe.Calories.HasValue) return null;
|
||||
meal.RecipeId = recipe.Id;
|
||||
meal.Calories = (int)Math.Round(recipe.Calories.Value * meal.Portions);
|
||||
meal.ProteinG = recipe.Protein.HasValue ? Math.Round(recipe.Protein.Value * meal.Portions, 2) : null;
|
||||
meal.FatG = recipe.Fat.HasValue ? Math.Round(recipe.Fat.Value * meal.Portions, 2) : null;
|
||||
meal.CarbsG = recipe.Carbs.HasValue ? Math.Round(recipe.Carbs.Value * meal.Portions, 2) : null;
|
||||
}
|
||||
|
||||
if (dto.Portions.HasValue)
|
||||
{
|
||||
meal.Portions = dto.Portions.Value;
|
||||
var recipe = await _db.Recipes.AsNoTracking().FirstAsync(r => r.Id == meal.RecipeId, ct);
|
||||
meal.Calories = recipe.Calories.HasValue
|
||||
? (int)Math.Round(recipe.Calories.Value * meal.Portions)
|
||||
: null;
|
||||
meal.ProteinG = recipe.Protein.HasValue ? Math.Round(recipe.Protein.Value * meal.Portions, 2) : null;
|
||||
meal.FatG = recipe.Fat.HasValue ? Math.Round(recipe.Fat.Value * meal.Portions, 2) : null;
|
||||
meal.CarbsG = recipe.Carbs.HasValue ? Math.Round(recipe.Carbs.Value * meal.Portions, 2) : null;
|
||||
}
|
||||
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await SyncDraftMealIngredientsAsync(meal.Id, meal.RecipeId, meal.Portions, ct);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await MapSessionAsync(sessionId, ct);
|
||||
}
|
||||
|
||||
public async Task<CommitDietPlanResultDto?> CommitAsync(Guid userId, int sessionId, CancellationToken ct = default)
|
||||
{
|
||||
var session = await _db.DietGeneratorSessions
|
||||
.Include(s => s.DraftMeals)
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId, ct);
|
||||
|
||||
if (session is null || session.Status != DietGeneratorStatuses.PlanDraft) return null;
|
||||
if (!session.ProposedCalories.HasValue || session.DraftMeals.Count == 0) return null;
|
||||
|
||||
var start = session.PlanStartDate ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var end = start.AddDays(session.PlanDayCount - 1);
|
||||
|
||||
var goals = await _db.UserDailyGoals.FirstOrDefaultAsync(g => g.UserId == userId, ct);
|
||||
if (goals is null)
|
||||
{
|
||||
goals = new UserDailyGoal { UserId = userId, CreatedAt = DateTime.UtcNow };
|
||||
_db.UserDailyGoals.Add(goals);
|
||||
}
|
||||
|
||||
goals.CalorieGoal = session.ProposedCalories;
|
||||
goals.ProteinGoalG = session.ProposedProteinG;
|
||||
goals.FatGoalG = session.ProposedFatG;
|
||||
goals.CarbsGoalG = session.ProposedCarbsG;
|
||||
goals.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
var existingEntries = await _db.MealPlanEntries
|
||||
.Where(e => e.UserId == userId && e.PlanDate >= start && e.PlanDate <= end)
|
||||
.ToListAsync(ct);
|
||||
_db.MealPlanEntries.RemoveRange(existingEntries);
|
||||
|
||||
foreach (var draft in session.DraftMeals)
|
||||
{
|
||||
_db.MealPlanEntries.Add(new MealPlanEntry
|
||||
{
|
||||
UserId = userId,
|
||||
PlanDate = draft.PlanDate,
|
||||
MealCategory = draft.MealCategory,
|
||||
RecipeId = draft.RecipeId,
|
||||
Portions = draft.Portions,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
|
||||
session.Status = DietGeneratorStatuses.Committed;
|
||||
session.CommittedAt = DateTime.UtcNow;
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return new CommitDietPlanResultDto
|
||||
{
|
||||
SessionId = sessionId,
|
||||
PlanStartDate = start,
|
||||
PlanEndDate = end,
|
||||
MealsWritten = session.DraftMeals.Count,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task BackfillMissingDraftIngredientsAsync(Guid userId, int sessionId, CancellationToken ct)
|
||||
{
|
||||
var session = await _db.DietGeneratorSessions.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId, ct);
|
||||
if (session?.Status != DietGeneratorStatuses.PlanDraft) return;
|
||||
|
||||
var meals = await _db.DietGeneratorDraftMeals
|
||||
.Where(m => m.SessionId == sessionId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (meals.Count == 0) return;
|
||||
|
||||
foreach (var meal in meals)
|
||||
{
|
||||
await SyncDraftMealIngredientsAsync(meal.Id, meal.RecipeId, meal.Portions, ct);
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task SyncDraftMealIngredientsAsync(
|
||||
int draftMealId,
|
||||
int recipeId,
|
||||
decimal portions,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var existing = await _db.DietGeneratorDraftMealIngredients
|
||||
.Where(i => i.DraftMealId == draftMealId)
|
||||
.ToListAsync(ct);
|
||||
_db.DietGeneratorDraftMealIngredients.RemoveRange(existing);
|
||||
|
||||
var recipeIngredients = await _db.Ingredients.AsNoTracking()
|
||||
.Where(i => i.RecipeId == recipeId)
|
||||
.OrderBy(i => i.SortOrder)
|
||||
.ThenBy(i => i.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var ingredient in recipeIngredients)
|
||||
{
|
||||
var (scaledGrams, scaledUnit) = IngredientScalingHelper.ScaleForDietPlan(
|
||||
ingredient.AmountGrams,
|
||||
ingredient.Unit,
|
||||
portions);
|
||||
|
||||
_db.DietGeneratorDraftMealIngredients.Add(new DietGeneratorDraftMealIngredient
|
||||
{
|
||||
DraftMealId = draftMealId,
|
||||
SourceIngredientId = ingredient.Id,
|
||||
Name = ingredient.Name,
|
||||
AmountGrams = scaledGrams,
|
||||
Unit = scaledUnit,
|
||||
SortOrder = ingredient.SortOrder,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyCalculatedMacros(DietGeneratorSession session, TdeeCalculator.MacroTargets calculated)
|
||||
{
|
||||
session.ProposedCalories = calculated.Calories;
|
||||
session.ProposedProteinG = calculated.ProteinG;
|
||||
session.ProposedFatG = calculated.FatG;
|
||||
session.ProposedCarbsG = calculated.CarbsG;
|
||||
session.MacroRationale = calculated.Rationale;
|
||||
}
|
||||
|
||||
private List<DraftMealSlot> BuildGreedyPlan(
|
||||
DietGeneratorSession session,
|
||||
IReadOnlyList<RecipeCandidate> pool,
|
||||
IReadOnlyList<int> mealCategories,
|
||||
DateOnly start,
|
||||
HashSet<int> usedRecipes)
|
||||
{
|
||||
var allSlots = new List<DraftMealSlot>();
|
||||
for (var day = 0; day < session.PlanDayCount; day++)
|
||||
{
|
||||
var date = start.AddDays(day);
|
||||
var daySlots = MealPlanOptimizer.BuildGreedyDay(
|
||||
date,
|
||||
pool,
|
||||
mealCategories,
|
||||
session.ProposedCalories!.Value,
|
||||
session.CalorieToleranceKcal,
|
||||
usedRecipes);
|
||||
allSlots.AddRange(daySlots);
|
||||
}
|
||||
|
||||
return allSlots;
|
||||
}
|
||||
|
||||
private async Task<List<DraftMealSlot>> GeneratePlanWithAiAsync(
|
||||
DietGeneratorSession session,
|
||||
IReadOnlyList<RecipeCandidate> pool,
|
||||
IReadOnlyList<int> mealCategories,
|
||||
DateOnly start,
|
||||
HashSet<int> usedRecipes,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var compact = pool.Take(120).Select(r => new
|
||||
{
|
||||
r.Id,
|
||||
r.Name,
|
||||
r.MealCategory,
|
||||
r.Calories,
|
||||
r.Protein,
|
||||
r.Fat,
|
||||
r.Carbs,
|
||||
});
|
||||
|
||||
var system = """
|
||||
You are a meal planner. Pick ONLY recipe ids from the provided catalog.
|
||||
Respond JSON: {"days":[{"date":"YYYY-MM-DD","meals":[{"mealCategory":0,"recipeId":1,"portions":1.0}]}]}
|
||||
Each day must use allowed meal categories only.
|
||||
Never reuse the same recipeId anywhere in the plan — each recipe may appear at most once across all days.
|
||||
""";
|
||||
var user = JsonSerializer.Serialize(new
|
||||
{
|
||||
targetCaloriesPerDay = session.ProposedCalories,
|
||||
toleranceKcal = session.CalorieToleranceKcal,
|
||||
mealCategories,
|
||||
planStartDate = start.ToString("yyyy-MM-dd"),
|
||||
planDayCount = session.PlanDayCount,
|
||||
recipes = compact,
|
||||
});
|
||||
|
||||
var json = await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Plan, ct);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var poolMap = pool.ToDictionary(r => r.Id);
|
||||
var allSlots = new List<DraftMealSlot>();
|
||||
|
||||
if (!doc.RootElement.TryGetProperty("days", out var daysEl)) return BuildGreedyPlan(session, pool, mealCategories, start, usedRecipes);
|
||||
|
||||
foreach (var dayEl in daysEl.EnumerateArray())
|
||||
{
|
||||
var dateStr = dayEl.GetProperty("date").GetString();
|
||||
if (!DateOnly.TryParse(dateStr, out var date)) continue;
|
||||
|
||||
var daySlots = new List<DraftMealSlot>();
|
||||
var perSlot = mealCategories.Count > 0
|
||||
? session.ProposedCalories!.Value / mealCategories.Count
|
||||
: session.ProposedCalories!.Value;
|
||||
foreach (var mealEl in dayEl.GetProperty("meals").EnumerateArray())
|
||||
{
|
||||
var category = mealEl.GetProperty("mealCategory").GetInt32();
|
||||
var recipeId = mealEl.GetProperty("recipeId").GetInt32();
|
||||
DraftMealSlot slot;
|
||||
|
||||
if (!poolMap.TryGetValue(recipeId, out var recipe) || usedRecipes.Contains(recipeId))
|
||||
{
|
||||
slot = MealPlanOptimizer.PickGreedyMeal(pool, category, perSlot, usedRecipes);
|
||||
slot.PlanDate = date;
|
||||
}
|
||||
else
|
||||
{
|
||||
var portions = mealEl.TryGetProperty("portions", out var p) ? p.GetDecimal() : 1m;
|
||||
slot = new DraftMealSlot
|
||||
{
|
||||
PlanDate = date,
|
||||
MealCategory = category,
|
||||
RecipeId = recipeId,
|
||||
Portions = Math.Clamp(portions, 0.5m, 2.5m),
|
||||
CaloriesPerPortion = recipe.Calories,
|
||||
ProteinPerPortion = recipe.Protein,
|
||||
FatPerPortion = recipe.Fat,
|
||||
CarbsPerPortion = recipe.Carbs,
|
||||
};
|
||||
}
|
||||
|
||||
daySlots.Add(slot);
|
||||
usedRecipes.Add(slot.RecipeId);
|
||||
}
|
||||
|
||||
if (daySlots.Count == 0) continue;
|
||||
MealPlanOptimizer.BalanceDay(daySlots, session.ProposedCalories!.Value, session.CalorieToleranceKcal);
|
||||
allSlots.AddRange(daySlots);
|
||||
}
|
||||
|
||||
if (allSlots.Count == 0) return BuildGreedyPlan(session, pool, mealCategories, start, usedRecipes);
|
||||
return allSlots;
|
||||
}
|
||||
|
||||
private async Task<List<RecipeCandidate>> LoadRecipePoolAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
return await _db.Recipes.AsNoTracking()
|
||||
.Where(r => r.IsActive && !r.IsDraft && r.Calories.HasValue)
|
||||
.Where(r => r.CreatedByUserId == null || r.CreatedByUserId == userId)
|
||||
.Select(r => new RecipeCandidate(
|
||||
r.Id,
|
||||
r.Name,
|
||||
r.MealCategory,
|
||||
r.Calories!.Value,
|
||||
r.Protein ?? 0,
|
||||
r.Fat ?? 0,
|
||||
r.Carbs ?? 0))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<DietGeneratorSession?> GetEditableSessionAsync(Guid userId, int sessionId, CancellationToken ct) =>
|
||||
await _db.DietGeneratorSessions.FirstOrDefaultAsync(
|
||||
s => s.Id == sessionId && s.UserId == userId && s.Status != DietGeneratorStatuses.Committed, ct);
|
||||
|
||||
private Task<DietGeneratorSessionDto?> MapSessionAsync(int sessionId, CancellationToken ct) =>
|
||||
MapSessionAsync(sessionId, null, ct);
|
||||
|
||||
private async Task<DietGeneratorSessionDto?> MapSessionAsync(int sessionId, Guid? userId, CancellationToken ct)
|
||||
{
|
||||
var query = _db.DietGeneratorSessions.AsNoTracking()
|
||||
.Include(s => s.DraftMeals)
|
||||
.ThenInclude(m => m.Recipe)
|
||||
.ThenInclude(r => r!.Steps)
|
||||
.Include(s => s.DraftMeals)
|
||||
.ThenInclude(m => m.PlannedIngredients)
|
||||
.Where(s => s.Id == sessionId);
|
||||
|
||||
if (userId.HasValue) query = query.Where(s => s.UserId == userId.Value);
|
||||
|
||||
var session = await query.FirstOrDefaultAsync(ct);
|
||||
if (session is null) return null;
|
||||
|
||||
var meals = session.DraftMeals
|
||||
.OrderBy(m => m.PlanDate)
|
||||
.ThenBy(m => m.MealCategory)
|
||||
.Select(m => new DietGeneratorDraftMealDto
|
||||
{
|
||||
Id = m.Id,
|
||||
PlanDate = m.PlanDate,
|
||||
MealCategory = m.MealCategory,
|
||||
RecipeId = m.RecipeId,
|
||||
RecipeName = m.Recipe?.Name ?? string.Empty,
|
||||
Portions = m.Portions,
|
||||
Calories = m.Calories,
|
||||
ProteinG = m.ProteinG,
|
||||
FatG = m.FatG,
|
||||
CarbsG = m.CarbsG,
|
||||
IsLocked = m.IsLocked,
|
||||
PrepTimeMinutes = m.Recipe?.PrepTimeMinutes,
|
||||
Ingredients = m.PlannedIngredients
|
||||
.OrderBy(i => i.SortOrder)
|
||||
.ThenBy(i => i.Id)
|
||||
.Select(i => new DietGeneratorDraftMealIngredientDto
|
||||
{
|
||||
Id = i.Id,
|
||||
Name = i.Name,
|
||||
AmountGrams = i.AmountGrams,
|
||||
Unit = i.Unit,
|
||||
SortOrder = i.SortOrder,
|
||||
SourceIngredientId = i.SourceIngredientId,
|
||||
})
|
||||
.ToList(),
|
||||
Steps = (m.Recipe?.Steps ?? [])
|
||||
.OrderBy(s => s.StepNumber)
|
||||
.Select(s => new DietGeneratorDraftMealStepDto
|
||||
{
|
||||
Id = s.Id,
|
||||
StepNumber = s.StepNumber,
|
||||
Description = s.Description,
|
||||
})
|
||||
.ToList(),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var summaries = meals
|
||||
.GroupBy(m => m.PlanDate)
|
||||
.Select(g => new DietGeneratorDaySummaryDto
|
||||
{
|
||||
PlanDate = g.Key,
|
||||
TotalCalories = g.Sum(x => x.Calories ?? 0),
|
||||
TargetCalories = session.ProposedCalories ?? 0,
|
||||
WithinTolerance = session.ProposedCalories.HasValue &&
|
||||
Math.Abs(g.Sum(x => x.Calories ?? 0) - session.ProposedCalories.Value) <= session.CalorieToleranceKcal,
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return new DietGeneratorSessionDto
|
||||
{
|
||||
Id = session.Id,
|
||||
Status = session.Status,
|
||||
ProposedMacros = session.ProposedCalories.HasValue
|
||||
? new MacroProposalDto
|
||||
{
|
||||
Calories = session.ProposedCalories.Value,
|
||||
ProteinG = session.ProposedProteinG ?? 0,
|
||||
FatG = session.ProposedFatG ?? 0,
|
||||
CarbsG = session.ProposedCarbsG ?? 0,
|
||||
Rationale = session.MacroRationale,
|
||||
}
|
||||
: null,
|
||||
PlanStartDate = session.PlanStartDate,
|
||||
PlanDayCount = session.PlanDayCount,
|
||||
CalorieToleranceKcal = session.CalorieToleranceKcal,
|
||||
DraftMeals = meals,
|
||||
DaySummaries = summaries,
|
||||
};
|
||||
}
|
||||
|
||||
private static DietUserProfileDto MapProfile(DietUserProfile profile) => new()
|
||||
{
|
||||
HeightCm = profile.HeightCm,
|
||||
WeightKg = profile.WeightKg,
|
||||
Age = profile.Age,
|
||||
Sex = profile.Sex,
|
||||
ActivityLevel = profile.ActivityLevel,
|
||||
Goal = profile.Goal,
|
||||
AllergiesNotes = profile.AllergiesNotes,
|
||||
DislikedFoods = profile.DislikedFoods,
|
||||
MealsPerDayMask = profile.MealsPerDayMask,
|
||||
};
|
||||
}
|
||||
64
MealPlan.Api/Services/Generators/IngredientScalingHelper.cs
Normal file
64
MealPlan.Api/Services/Generators/IngredientScalingHelper.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
namespace MealPlan.Api.Services.Generators;
|
||||
|
||||
public static class IngredientScalingHelper
|
||||
{
|
||||
private const decimal CountAmountThreshold = 10m;
|
||||
|
||||
public static (decimal? AmountGrams, string? Unit) ScaleForDietPlan(
|
||||
decimal? amountGrams,
|
||||
string? unit,
|
||||
decimal portions)
|
||||
{
|
||||
if (IsNonQuantifiedUnit(unit))
|
||||
{
|
||||
return (amountGrams, unit);
|
||||
}
|
||||
|
||||
if (amountGrams is not > 0)
|
||||
{
|
||||
return (null, unit);
|
||||
}
|
||||
|
||||
if (IsCountUnit(unit) && amountGrams.Value <= CountAmountThreshold)
|
||||
{
|
||||
var gramsPerUnit = GramsPerCountUnit(unit!);
|
||||
var totalGrams = amountGrams.Value * gramsPerUnit * portions;
|
||||
return (Math.Round(totalGrams, 1), null);
|
||||
}
|
||||
|
||||
// AmountGrams stores weight in grams (even when the display unit says szt./ząbek).
|
||||
return (Math.Round(amountGrams.Value * portions, 1), null);
|
||||
}
|
||||
|
||||
public static bool IsNonQuantifiedUnit(string? unit)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(unit)) return false;
|
||||
var u = unit.Trim().ToLowerInvariant();
|
||||
return u is "do smaku" or "optional" or "opcjonalnie" or "szczypta" or "to taste";
|
||||
}
|
||||
|
||||
private static bool IsCountUnit(string? unit)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(unit)) return false;
|
||||
var u = NormalizeUnit(unit);
|
||||
return u is "szt" or "sztuka" or "sztuki"
|
||||
or "ząbek" or "zabek" or "zabki" or "clove" or "cloves"
|
||||
or "łyżka" or "lyzka" or "łyżeczka" or "lyzeczka"
|
||||
or "jajko" or "jajka" or "egg" or "eggs";
|
||||
}
|
||||
|
||||
private static decimal GramsPerCountUnit(string unit)
|
||||
{
|
||||
var u = NormalizeUnit(unit);
|
||||
return u switch
|
||||
{
|
||||
"ząbek" or "zabek" or "zabki" or "clove" or "cloves" => 4m,
|
||||
"łyżka" or "lyzka" => 15m,
|
||||
"łyżeczka" or "lyzeczka" => 5m,
|
||||
_ => 60m, // szt., jajko — typical single egg / piece default
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeUnit(string unit) =>
|
||||
unit.Trim().ToLowerInvariant().TrimEnd('.');
|
||||
}
|
||||
221
MealPlan.Api/Services/Generators/MealPlanOptimizer.cs
Normal file
221
MealPlan.Api/Services/Generators/MealPlanOptimizer.cs
Normal file
@@ -0,0 +1,221 @@
|
||||
namespace MealPlan.Api.Services.Generators;
|
||||
|
||||
public record RecipeCandidate(
|
||||
int Id,
|
||||
string Name,
|
||||
int MealCategory,
|
||||
int Calories,
|
||||
decimal Protein,
|
||||
decimal Fat,
|
||||
decimal Carbs);
|
||||
|
||||
public class DraftMealSlot
|
||||
{
|
||||
public DateOnly PlanDate { get; set; }
|
||||
public int MealCategory { get; set; }
|
||||
public int RecipeId { get; set; }
|
||||
public decimal Portions { get; set; } = 1m;
|
||||
public int CaloriesPerPortion { get; set; }
|
||||
public decimal ProteinPerPortion { get; set; }
|
||||
public decimal FatPerPortion { get; set; }
|
||||
public decimal CarbsPerPortion { get; set; }
|
||||
public bool IsLocked { get; set; }
|
||||
|
||||
public int TotalCalories => (int)Math.Round(CaloriesPerPortion * Portions);
|
||||
public decimal TotalProtein => ProteinPerPortion * Portions;
|
||||
public decimal TotalFat => FatPerPortion * Portions;
|
||||
public decimal TotalCarbs => CarbsPerPortion * Portions;
|
||||
|
||||
public DraftMealSlot CloneForDate(DateOnly planDate) =>
|
||||
new()
|
||||
{
|
||||
PlanDate = planDate,
|
||||
MealCategory = MealCategory,
|
||||
RecipeId = RecipeId,
|
||||
Portions = Portions,
|
||||
CaloriesPerPortion = CaloriesPerPortion,
|
||||
ProteinPerPortion = ProteinPerPortion,
|
||||
FatPerPortion = FatPerPortion,
|
||||
CarbsPerPortion = CarbsPerPortion,
|
||||
IsLocked = IsLocked,
|
||||
};
|
||||
}
|
||||
|
||||
public static class MealPlanOptimizer
|
||||
{
|
||||
public static IReadOnlyList<int> MealCategoriesFromMask(int mask) =>
|
||||
Enumerable.Range(0, 5).Where(i => (mask & (1 << i)) != 0).ToList();
|
||||
|
||||
public static void BalanceDay(IList<DraftMealSlot> meals, int targetCalories, int tolerance)
|
||||
{
|
||||
if (meals.Count == 0) return;
|
||||
|
||||
var unlocked = meals.Where(m => !m.IsLocked).ToList();
|
||||
if (unlocked.Count == 0) return;
|
||||
|
||||
var current = meals.Sum(m => m.TotalCalories);
|
||||
if (Math.Abs(current - targetCalories) <= tolerance) return;
|
||||
|
||||
if (current <= 0)
|
||||
{
|
||||
foreach (var meal in unlocked)
|
||||
{
|
||||
meal.Portions = 1m;
|
||||
}
|
||||
|
||||
current = meals.Sum(m => m.TotalCalories);
|
||||
}
|
||||
|
||||
if (current <= 0) return;
|
||||
|
||||
var scale = (decimal)targetCalories / current;
|
||||
foreach (var meal in unlocked)
|
||||
{
|
||||
meal.Portions = Math.Clamp(Math.Round(meal.Portions * scale, 2), 0.5m, 2.5m);
|
||||
}
|
||||
|
||||
current = meals.Sum(m => m.TotalCalories);
|
||||
if (Math.Abs(current - targetCalories) <= tolerance) return;
|
||||
|
||||
var diff = targetCalories - current;
|
||||
var adjustable = unlocked.OrderByDescending(m => m.CaloriesPerPortion).FirstOrDefault();
|
||||
if (adjustable is null || adjustable.CaloriesPerPortion <= 0) return;
|
||||
|
||||
var portionDelta = diff / (decimal)adjustable.CaloriesPerPortion;
|
||||
adjustable.Portions = Math.Clamp(Math.Round(adjustable.Portions + portionDelta, 2), 0.5m, 2.5m);
|
||||
}
|
||||
|
||||
public static bool IsDayWithinTolerance(IEnumerable<DraftMealSlot> meals, int targetCalories, int tolerance) =>
|
||||
Math.Abs(meals.Sum(m => m.TotalCalories) - targetCalories) <= tolerance;
|
||||
|
||||
private static RecipeCandidate? FindGreedyCandidate(
|
||||
IReadOnlyList<RecipeCandidate> pool,
|
||||
int mealCategory,
|
||||
int targetCaloriesForSlot,
|
||||
HashSet<int> excludedRecipeIds)
|
||||
{
|
||||
var candidates = pool
|
||||
.Where(r => r.MealCategory == mealCategory && !excludedRecipeIds.Contains(r.Id))
|
||||
.OrderBy(r => Math.Abs(r.Calories - targetCaloriesForSlot))
|
||||
.ToList();
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
candidates = pool
|
||||
.Where(r => !excludedRecipeIds.Contains(r.Id))
|
||||
.OrderBy(r => Math.Abs(r.Calories - targetCaloriesForSlot))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return candidates.FirstOrDefault();
|
||||
}
|
||||
|
||||
public static bool TryPickGreedyMeal(
|
||||
IReadOnlyList<RecipeCandidate> pool,
|
||||
int mealCategory,
|
||||
int targetCaloriesForSlot,
|
||||
HashSet<int> excludedRecipeIds,
|
||||
out DraftMealSlot slot)
|
||||
{
|
||||
var recipe = FindGreedyCandidate(pool, mealCategory, targetCaloriesForSlot, excludedRecipeIds);
|
||||
if (recipe is null)
|
||||
{
|
||||
slot = null!;
|
||||
return false;
|
||||
}
|
||||
|
||||
var portions = recipe.Calories > 0
|
||||
? Math.Clamp(Math.Round((decimal)targetCaloriesForSlot / recipe.Calories, 2), 0.5m, 2.5m)
|
||||
: 1m;
|
||||
|
||||
slot = new DraftMealSlot
|
||||
{
|
||||
MealCategory = mealCategory,
|
||||
RecipeId = recipe.Id,
|
||||
Portions = portions,
|
||||
CaloriesPerPortion = recipe.Calories,
|
||||
ProteinPerPortion = recipe.Protein,
|
||||
FatPerPortion = recipe.Fat,
|
||||
CarbsPerPortion = recipe.Carbs,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
public static DraftMealSlot PickGreedyMeal(
|
||||
IReadOnlyList<RecipeCandidate> pool,
|
||||
int mealCategory,
|
||||
int targetCaloriesForSlot,
|
||||
HashSet<int> excludedRecipeIds)
|
||||
{
|
||||
if (TryPickGreedyMeal(pool, mealCategory, targetCaloriesForSlot, excludedRecipeIds, out var slot))
|
||||
{
|
||||
return slot;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("No recipes available for meal planning.");
|
||||
}
|
||||
|
||||
public static IReadOnlyList<DraftMealSlot> BuildGreedyDay(
|
||||
DateOnly planDate,
|
||||
IReadOnlyList<RecipeCandidate> pool,
|
||||
IReadOnlyList<int> mealCategories,
|
||||
int targetCalories,
|
||||
int tolerance,
|
||||
HashSet<int> excludedRecipeIds)
|
||||
{
|
||||
var perSlot = mealCategories.Count > 0 ? targetCalories / mealCategories.Count : targetCalories;
|
||||
var meals = new List<DraftMealSlot>();
|
||||
|
||||
foreach (var category in mealCategories)
|
||||
{
|
||||
var slot = PickGreedyMeal(pool, category, perSlot, excludedRecipeIds);
|
||||
slot.PlanDate = planDate;
|
||||
meals.Add(slot);
|
||||
excludedRecipeIds.Add(slot.RecipeId);
|
||||
}
|
||||
|
||||
BalanceDay(meals, targetCalories, tolerance);
|
||||
return meals;
|
||||
}
|
||||
|
||||
public static int RequiredUniqueRecipes(int planDayCount, IReadOnlyList<int> mealCategories) =>
|
||||
planDayCount * mealCategories.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Replaces duplicate recipe picks so each recipe appears at most once in the plan.
|
||||
/// </summary>
|
||||
public static void EnsureWeeklyUniqueRecipes(
|
||||
IList<DraftMealSlot> slots,
|
||||
IReadOnlyList<RecipeCandidate> pool,
|
||||
IReadOnlyList<int> mealCategories,
|
||||
int targetCalories,
|
||||
int tolerance)
|
||||
{
|
||||
if (slots.Count == 0) return;
|
||||
|
||||
var used = new HashSet<int>();
|
||||
var perSlot = mealCategories.Count > 0 ? targetCalories / mealCategories.Count : targetCalories;
|
||||
|
||||
foreach (var slot in slots.OrderBy(s => s.PlanDate).ThenBy(s => s.MealCategory))
|
||||
{
|
||||
if (used.Add(slot.RecipeId)) continue;
|
||||
|
||||
var replacement = PickGreedyMeal(pool, slot.MealCategory, perSlot, used);
|
||||
CopyRecipeIntoSlot(slot, replacement);
|
||||
used.Add(slot.RecipeId);
|
||||
|
||||
var daySlots = slots.Where(s => s.PlanDate == slot.PlanDate).ToList();
|
||||
BalanceDay(daySlots, targetCalories, tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyRecipeIntoSlot(DraftMealSlot target, DraftMealSlot source)
|
||||
{
|
||||
target.RecipeId = source.RecipeId;
|
||||
target.Portions = source.Portions;
|
||||
target.CaloriesPerPortion = source.CaloriesPerPortion;
|
||||
target.ProteinPerPortion = source.ProteinPerPortion;
|
||||
target.FatPerPortion = source.FatPerPortion;
|
||||
target.CarbsPerPortion = source.CarbsPerPortion;
|
||||
}
|
||||
}
|
||||
683
MealPlan.Api/Services/Generators/RecipeGeneratorService.cs
Normal file
683
MealPlan.Api/Services/Generators/RecipeGeneratorService.cs
Normal file
@@ -0,0 +1,683 @@
|
||||
using System.Text.Json;
|
||||
using MealPlan.Api.Data;
|
||||
using MealPlan.Api.DTOs.Generators;
|
||||
using MealPlan.Api.DTOs.Recipe;
|
||||
using MealPlan.Api.Models;
|
||||
using MealPlan.Api.Services.AI;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Npgsql;
|
||||
|
||||
namespace MealPlan.Api.Services.Generators;
|
||||
|
||||
public interface IRecipeGeneratorService
|
||||
{
|
||||
Task<RecipeGeneratorSessionDto> CreateSessionAsync(Guid userId, RecipeGeneratorConstraintsDto dto, CancellationToken ct = default);
|
||||
Task<RecipeGeneratorSessionDto?> GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default);
|
||||
Task<RecipeGeneratorSessionDto?> GenerateAsync(Guid userId, int sessionId, GenerateRecipesRequestDto? request, CancellationToken ct = default);
|
||||
Task<RecipeGeneratorSessionDto?> RegeneratePartAsync(Guid userId, int sessionId, RegenerateRecipePartDto dto, CancellationToken ct = default);
|
||||
Task<RecipeGeneratorSessionDto?> UpdateDraftAsync(Guid userId, int sessionId, GeneratedRecipeDraftDto dto, CancellationToken ct = default);
|
||||
Task<RecipeGeneratorSessionDto?> RemoveDraftAsync(Guid userId, int sessionId, string draftId, CancellationToken ct = default);
|
||||
Task<CommitRecipesResultDto?> CommitAsync(Guid userId, int sessionId, CommitRecipesRequestDto? request, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public class RecipeGeneratorService : IRecipeGeneratorService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IOpenAiChatService _openAi;
|
||||
private readonly ICatalogMatchingService _catalogMatching;
|
||||
private readonly IRecipeManagementService _recipeManagement;
|
||||
|
||||
public RecipeGeneratorService(
|
||||
AppDbContext db,
|
||||
IOpenAiChatService openAi,
|
||||
ICatalogMatchingService catalogMatching,
|
||||
IRecipeManagementService recipeManagement)
|
||||
{
|
||||
_db = db;
|
||||
_openAi = openAi;
|
||||
_catalogMatching = catalogMatching;
|
||||
_recipeManagement = recipeManagement;
|
||||
}
|
||||
|
||||
public async Task<RecipeGeneratorSessionDto> CreateSessionAsync(
|
||||
Guid userId,
|
||||
RecipeGeneratorConstraintsDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = new RecipeGeneratorSession
|
||||
{
|
||||
UserId = userId,
|
||||
Status = RecipeGeneratorStatuses.Draft,
|
||||
ConstraintsJson = JsonSerializer.Serialize(dto),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_db.RecipeGeneratorSessions.Add(session);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return MapSession(session, dto, []);
|
||||
}
|
||||
|
||||
public async Task<RecipeGeneratorSessionDto?> GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default)
|
||||
{
|
||||
var session = await _db.RecipeGeneratorSessions.AsNoTracking()
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId, ct);
|
||||
if (session is null) return null;
|
||||
var constraints = DeserializeConstraints(session.ConstraintsJson);
|
||||
var drafts = DeserializeDrafts(session.DraftJson);
|
||||
return MapSession(session, constraints, drafts);
|
||||
}
|
||||
|
||||
public async Task<RecipeGeneratorSessionDto?> GenerateAsync(
|
||||
Guid userId,
|
||||
int sessionId,
|
||||
GenerateRecipesRequestDto? request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = await _db.RecipeGeneratorSessions
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
|
||||
if (session is null) return null;
|
||||
|
||||
if (!_openAi.IsConfigured)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"OpenAI is not configured. Set OpenAI__ApiKey in appsettings.Local.json.");
|
||||
}
|
||||
|
||||
var count = Math.Clamp(request?.Count ?? 3, 1, 8);
|
||||
var constraints = DeserializeConstraints(session.ConstraintsJson);
|
||||
if (string.IsNullOrWhiteSpace(constraints.Prompt))
|
||||
{
|
||||
throw new InvalidOperationException("Recipe prompt is required.");
|
||||
}
|
||||
|
||||
var catalogSample = await _db.IngredientNutritionCatalog.AsNoTracking()
|
||||
.Where(i => i.IsActive)
|
||||
.OrderBy(i => i.Name)
|
||||
.Take(80)
|
||||
.Select(i => new { i.Name, i.NamePl, i.CaloriesPer100G, i.ProteinGPer100G, i.FatGPer100G, i.CarbsGPer100G })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var drafts = await GenerateDistinctDraftsAsync(constraints, catalogSample, count, ct);
|
||||
if (drafts.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("AI did not return any valid recipes. Try again.");
|
||||
}
|
||||
|
||||
var enriched = new List<GeneratedRecipeDraftDto>();
|
||||
foreach (var draft in drafts)
|
||||
{
|
||||
enriched.Add(await EnrichDraftAsync(EnsureDraftId(draft), constraints, ct));
|
||||
}
|
||||
|
||||
session.DraftJson = SerializeDrafts(enriched);
|
||||
session.GenerationVersion++;
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return MapSession(session, constraints, enriched);
|
||||
}
|
||||
|
||||
public async Task<RecipeGeneratorSessionDto?> RegeneratePartAsync(
|
||||
Guid userId,
|
||||
int sessionId,
|
||||
RegenerateRecipePartDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = await _db.RecipeGeneratorSessions
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
|
||||
if (session is null) return null;
|
||||
|
||||
var constraints = DeserializeConstraints(session.ConstraintsJson);
|
||||
var drafts = DeserializeDrafts(session.DraftJson);
|
||||
if (string.IsNullOrWhiteSpace(dto.DraftId))
|
||||
return MapSession(session, constraints, drafts);
|
||||
|
||||
var index = drafts.FindIndex(d => d.DraftId == dto.DraftId);
|
||||
if (index < 0) return null;
|
||||
|
||||
var current = drafts[index];
|
||||
if (!_openAi.IsConfigured)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"OpenAI is not configured. Set OpenAI__ApiKey in appsettings.Local.json.");
|
||||
}
|
||||
|
||||
var mode = dto.Mode.ToLowerInvariant();
|
||||
var system = mode switch
|
||||
{
|
||||
"steps" => """
|
||||
Rewrite ONLY cooking steps for the recipe. JSON: {"steps":[{"stepNumber":1,"description":"..."}]}
|
||||
Keep ingredients unchanged.
|
||||
""",
|
||||
"ingredients" => """
|
||||
Rewrite ONLY ingredients list. JSON: {"ingredients":[{"name":"","amountGrams":0,"unit":null,"catalogName":""}]}
|
||||
Keep name and steps unchanged.
|
||||
""",
|
||||
_ => """
|
||||
Improve the full recipe. Respond ONLY JSON with name, mealCategory, prepTimeMinutes, ingredients, steps.
|
||||
Use JSON numbers (not strings) for mealCategory, prepTimeMinutes, amountGrams, stepNumber.
|
||||
""",
|
||||
};
|
||||
|
||||
var user = JsonSerializer.Serialize(new
|
||||
{
|
||||
instruction = dto.Instruction ?? constraints.Prompt,
|
||||
current,
|
||||
constraints,
|
||||
});
|
||||
|
||||
var json = await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Recipe, ct);
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (mode == "steps" && root.TryGetProperty("steps", out var stepsEl))
|
||||
{
|
||||
current.Steps = ParseSteps(stepsEl);
|
||||
}
|
||||
else if (mode == "ingredients" && root.TryGetProperty("ingredients", out var ingEl))
|
||||
{
|
||||
current.Ingredients = await ParseIngredientsAsync(ingEl, ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
var parsed = ParseDraft(json, constraints.MealCategory);
|
||||
parsed.DraftId = current.DraftId;
|
||||
current = parsed;
|
||||
}
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
throw new InvalidOperationException("Could not parse AI recipe response. Try again.", ex);
|
||||
}
|
||||
|
||||
current = await EnrichDraftAsync(EnsureDraftId(current), constraints, ct);
|
||||
drafts[index] = current;
|
||||
session.DraftJson = SerializeDrafts(drafts);
|
||||
session.GenerationVersion++;
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return MapSession(session, constraints, drafts);
|
||||
}
|
||||
|
||||
public async Task<RecipeGeneratorSessionDto?> UpdateDraftAsync(
|
||||
Guid userId,
|
||||
int sessionId,
|
||||
GeneratedRecipeDraftDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = await _db.RecipeGeneratorSessions
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
|
||||
if (session is null) return null;
|
||||
|
||||
var drafts = DeserializeDrafts(session.DraftJson);
|
||||
var constraints = DeserializeConstraints(session.ConstraintsJson);
|
||||
var enriched = await EnrichDraftAsync(EnsureDraftId(dto), constraints, ct);
|
||||
var index = drafts.FindIndex(d => d.DraftId == enriched.DraftId);
|
||||
if (index < 0)
|
||||
{
|
||||
drafts.Add(enriched);
|
||||
}
|
||||
else
|
||||
{
|
||||
drafts[index] = enriched;
|
||||
}
|
||||
|
||||
session.DraftJson = SerializeDrafts(drafts);
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return MapSession(session, constraints, drafts);
|
||||
}
|
||||
|
||||
public async Task<RecipeGeneratorSessionDto?> RemoveDraftAsync(
|
||||
Guid userId,
|
||||
int sessionId,
|
||||
string draftId,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = await _db.RecipeGeneratorSessions
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
|
||||
if (session is null) return null;
|
||||
|
||||
var drafts = DeserializeDrafts(session.DraftJson);
|
||||
var removed = drafts.RemoveAll(d => d.DraftId == draftId);
|
||||
if (removed == 0) return null;
|
||||
|
||||
session.DraftJson = SerializeDrafts(drafts);
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
var constraints = DeserializeConstraints(session.ConstraintsJson);
|
||||
return MapSession(session, constraints, drafts);
|
||||
}
|
||||
|
||||
public async Task<CommitRecipesResultDto?> CommitAsync(
|
||||
Guid userId,
|
||||
int sessionId,
|
||||
CommitRecipesRequestDto? request,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var session = await _db.RecipeGeneratorSessions
|
||||
.FirstOrDefaultAsync(s => s.Id == sessionId && s.UserId == userId && s.Status == RecipeGeneratorStatuses.Draft, ct);
|
||||
if (session is null || string.IsNullOrWhiteSpace(session.DraftJson)) return null;
|
||||
|
||||
var drafts = DeserializeDrafts(session.DraftJson);
|
||||
if (drafts.Count == 0) return null;
|
||||
|
||||
var targetIds = request?.DraftIds?.Where(id => !string.IsNullOrWhiteSpace(id)).Distinct().ToList();
|
||||
var toSave = targetIds is { Count: > 0 }
|
||||
? drafts.Where(d => targetIds.Contains(d.DraftId)).ToList()
|
||||
: drafts.ToList();
|
||||
|
||||
if (toSave.Count == 0) return null;
|
||||
|
||||
var saved = new List<CommitRecipeResultDto>();
|
||||
foreach (var draft in toSave)
|
||||
{
|
||||
if (draft.Ingredients.Count == 0 || draft.Steps.Count == 0) continue;
|
||||
|
||||
var input = new CreateRecipeDto
|
||||
{
|
||||
Name = draft.Name,
|
||||
MealCategory = draft.MealCategory,
|
||||
Calories = draft.Calories,
|
||||
Protein = draft.Protein,
|
||||
Fat = draft.Fat,
|
||||
Carbs = draft.Carbs,
|
||||
PrepTimeMinutes = draft.PrepTimeMinutes,
|
||||
Ingredients = draft.Ingredients.Select((i, index) => new CreateIngredientDto
|
||||
{
|
||||
Name = i.Name,
|
||||
AmountGrams = i.AmountGrams,
|
||||
Unit = i.Unit,
|
||||
SortOrder = index,
|
||||
CatalogItemId = i.CatalogItemId,
|
||||
}).ToList(),
|
||||
Steps = draft.Steps.Select(s => new CreateRecipeStepDto
|
||||
{
|
||||
StepNumber = s.StepNumber,
|
||||
Description = s.Description,
|
||||
}).ToList(),
|
||||
};
|
||||
|
||||
CommitRecipeResultDto savedEntry;
|
||||
try
|
||||
{
|
||||
var created = await _recipeManagement.CreateRecipeAsync(input, ct);
|
||||
var entity = await _db.Recipes.FirstAsync(r => r.Id == created.Id, ct);
|
||||
entity.Source = "ai_generated";
|
||||
entity.CreatedByUserId = userId;
|
||||
entity.IsDraft = false;
|
||||
|
||||
savedEntry = new CommitRecipeResultDto
|
||||
{
|
||||
SessionId = sessionId,
|
||||
RecipeId = created.Id,
|
||||
RecipeName = created.Name,
|
||||
DraftId = draft.DraftId,
|
||||
};
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException is PostgresException { SqlState: "23505" })
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Could not save the recipe because the database recipe ID sequence is out of sync. " +
|
||||
"Run: dotnet run --project MealPlan.Api -- --fix-sequences",
|
||||
ex);
|
||||
}
|
||||
|
||||
saved.Add(savedEntry);
|
||||
}
|
||||
|
||||
if (saved.Count == 0) return null;
|
||||
|
||||
var savedIds = saved.Select(s => s.DraftId).ToHashSet();
|
||||
drafts.RemoveAll(d => savedIds.Contains(d.DraftId));
|
||||
session.DraftJson = SerializeDrafts(drafts);
|
||||
session.SavedRecipeId = saved[^1].RecipeId;
|
||||
session.Status = drafts.Count == 0 ? RecipeGeneratorStatuses.Saved : RecipeGeneratorStatuses.Draft;
|
||||
session.UpdatedAt = DateTime.UtcNow;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return new CommitRecipesResultDto
|
||||
{
|
||||
SessionId = sessionId,
|
||||
Saved = saved,
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<GeneratedRecipeDraftDto> EnrichDraftAsync(
|
||||
GeneratedRecipeDraftDto draft,
|
||||
RecipeGeneratorConstraintsDto constraints,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var ingredients = new List<GeneratedIngredientDto>();
|
||||
foreach (var ing in draft.Ingredients)
|
||||
{
|
||||
var catalogId = ing.CatalogItemId;
|
||||
if (!catalogId.HasValue)
|
||||
{
|
||||
catalogId = await _catalogMatching.ResolveCatalogItemIdAsync(
|
||||
ing.CatalogName ?? ing.Name, ct);
|
||||
}
|
||||
|
||||
ingredients.Add(new GeneratedIngredientDto
|
||||
{
|
||||
Name = ing.Name,
|
||||
AmountGrams = ing.AmountGrams,
|
||||
Unit = ing.Unit,
|
||||
CatalogItemId = catalogId,
|
||||
CatalogName = ing.CatalogName,
|
||||
IsLinked = catalogId.HasValue,
|
||||
});
|
||||
}
|
||||
|
||||
draft.Ingredients = ingredients;
|
||||
draft.UnlinkedIngredientCount = ingredients.Count(i =>
|
||||
i.AmountGrams is > 0 &&
|
||||
!IsSpiceUnit(i.Unit) &&
|
||||
!i.IsLinked);
|
||||
|
||||
await ScaleDraftToTargetCaloriesAsync(draft, constraints, ct);
|
||||
await ApplyComputedMacrosAsync(draft, ct);
|
||||
return draft;
|
||||
}
|
||||
|
||||
private async Task ApplyComputedMacrosAsync(GeneratedRecipeDraftDto draft, CancellationToken ct)
|
||||
{
|
||||
var macros = await ComputeMacrosAsync(draft.Ingredients, ct);
|
||||
if (!macros.HasValue) return;
|
||||
|
||||
draft.Calories = macros.Value.Calories;
|
||||
draft.Protein = macros.Value.Protein;
|
||||
draft.Fat = macros.Value.Fat;
|
||||
draft.Carbs = macros.Value.Carbs;
|
||||
}
|
||||
|
||||
private async Task ScaleDraftToTargetCaloriesAsync(
|
||||
GeneratedRecipeDraftDto draft,
|
||||
RecipeGeneratorConstraintsDto constraints,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (constraints.TargetCalories is not > 0) return;
|
||||
|
||||
var target = constraints.TargetCalories.Value;
|
||||
var tolerance = constraints.CalorieToleranceKcal > 0 ? constraints.CalorieToleranceKcal : 10;
|
||||
var currentMacros = await ComputeMacrosAsync(draft.Ingredients, ct);
|
||||
if (currentMacros is null || currentMacros.Value.Calories <= 0) return;
|
||||
if (Math.Abs(currentMacros.Value.Calories - target) <= tolerance) return;
|
||||
|
||||
var scale = Math.Clamp((decimal)target / currentMacros.Value.Calories, 0.25m, 4m);
|
||||
foreach (var ing in draft.Ingredients)
|
||||
{
|
||||
if (ing.CatalogItemId is null || ing.AmountGrams is not > 0 || IsSpiceUnit(ing.Unit)) continue;
|
||||
ing.AmountGrams = Math.Round(ing.AmountGrams.Value * scale, 1);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(int Calories, decimal Protein, decimal Fat, decimal Carbs)?> ComputeMacrosAsync(
|
||||
IReadOnlyList<GeneratedIngredientDto> ingredients,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var ids = ingredients.Where(i => i.CatalogItemId.HasValue && i.AmountGrams is > 0 && !IsSpiceUnit(i.Unit))
|
||||
.Select(i => i.CatalogItemId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
if (ids.Count == 0) return null;
|
||||
|
||||
var catalog = await _db.IngredientNutritionCatalog.AsNoTracking()
|
||||
.Where(i => ids.Contains(i.Id))
|
||||
.ToDictionaryAsync(i => i.Id, ct);
|
||||
|
||||
int calories = 0;
|
||||
decimal protein = 0, fat = 0, carbs = 0;
|
||||
foreach (var ing in ingredients)
|
||||
{
|
||||
if (ing.CatalogItemId is null || ing.AmountGrams is not > 0 || IsSpiceUnit(ing.Unit)) continue;
|
||||
if (!catalog.TryGetValue(ing.CatalogItemId.Value, out var cat)) continue;
|
||||
var scale = ing.AmountGrams.Value / 100m;
|
||||
calories += (int)Math.Round(cat.CaloriesPer100G * scale);
|
||||
protein += cat.ProteinGPer100G * scale;
|
||||
fat += cat.FatGPer100G * scale;
|
||||
carbs += cat.CarbsGPer100G * scale;
|
||||
}
|
||||
|
||||
return (calories, Math.Round(protein, 2), Math.Round(fat, 2), Math.Round(carbs, 2));
|
||||
}
|
||||
|
||||
private async Task<List<GeneratedRecipeDraftDto>> GenerateDistinctDraftsAsync(
|
||||
RecipeGeneratorConstraintsDto constraints,
|
||||
object catalogSample,
|
||||
int count,
|
||||
CancellationToken ct)
|
||||
{
|
||||
const int maxAttempts = 4;
|
||||
var drafts = new List<GeneratedRecipeDraftDto>();
|
||||
|
||||
for (var attempt = 0; attempt < maxAttempts && drafts.Count < count; attempt++)
|
||||
{
|
||||
var need = count - drafts.Count;
|
||||
var json = await RequestRecipeBatchJsonAsync(
|
||||
constraints,
|
||||
catalogSample,
|
||||
need,
|
||||
drafts.Select(d => d.Name).ToList(),
|
||||
count,
|
||||
ct);
|
||||
var parsed = ParseDrafts(json, constraints.MealCategory);
|
||||
|
||||
foreach (var draft in parsed)
|
||||
{
|
||||
if (IsDuplicateDraft(draft, drafts)) continue;
|
||||
drafts.Add(draft);
|
||||
if (drafts.Count >= count) break;
|
||||
}
|
||||
}
|
||||
|
||||
return drafts.Take(count).ToList();
|
||||
}
|
||||
|
||||
private async Task<string> RequestRecipeBatchJsonAsync(
|
||||
RecipeGeneratorConstraintsDto constraints,
|
||||
object catalogSample,
|
||||
int recipeCount,
|
||||
IReadOnlyList<string> excludeNames,
|
||||
int totalRequested,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var system = """
|
||||
You create realistic Polish home-cooking recipes for a meal planning app.
|
||||
Respond ONLY JSON:
|
||||
{"recipes":[{"name":"string","mealCategory":0,"prepTimeMinutes":20,"ingredients":[{"name":"Polish name","amountGrams":100,"unit":null,"catalogName":"English catalog name"}],"steps":[{"stepNumber":1,"description":"..."}]}]}
|
||||
Rules:
|
||||
- Follow constraints.prompt strictly for every recipe (dish type, main ingredients, style).
|
||||
- If constraints.targetCalories is set, each recipe MUST total about that many kcal using realistic ingredient grams.
|
||||
Aim within ±constraints.calorieToleranceKcal kcal. Do NOT put the calorie target only in the name — scale amounts.
|
||||
- Use catalogName from the provided catalog when possible; grams required for macro ingredients.
|
||||
- Spices may use unit "do smaku" with null amountGrams.
|
||||
- mealCategory: 0=Breakfast, 1=SecondBreakfast, 2=Lunch, 3=Dinner, 4=Snack.
|
||||
- Return exactly recipeCount recipes in the recipes array.
|
||||
- Each recipe MUST be clearly different (unique name, different ingredient mix, different preparation).
|
||||
- Do NOT repeat the same recipe with only a number suffix.
|
||||
- Do NOT copy any name from excludeExistingNames.
|
||||
""";
|
||||
var user = JsonSerializer.Serialize(new
|
||||
{
|
||||
constraints,
|
||||
catalog = catalogSample,
|
||||
recipeCount,
|
||||
totalRequested,
|
||||
excludeExistingNames = excludeNames,
|
||||
});
|
||||
return await _openAi.CompleteJsonAsync(system, user, OpenAiUseCase.Recipe, ct);
|
||||
}
|
||||
|
||||
private static bool IsDuplicateDraft(GeneratedRecipeDraftDto candidate, IReadOnlyList<GeneratedRecipeDraftDto> existing) =>
|
||||
existing.Any(existingDraft => DraftFingerprint(existingDraft) == DraftFingerprint(candidate));
|
||||
|
||||
private static string DraftFingerprint(GeneratedRecipeDraftDto draft)
|
||||
{
|
||||
var ingredients = string.Join(
|
||||
"|",
|
||||
draft.Ingredients
|
||||
.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(i => $"{i.Name.Trim().ToLowerInvariant()}:{i.AmountGrams}:{i.Unit}"));
|
||||
return $"{NormalizeRecipeName(draft.Name)}::{ingredients}";
|
||||
}
|
||||
|
||||
private static string NormalizeRecipeName(string name) =>
|
||||
name.Trim().ToLowerInvariant();
|
||||
|
||||
private GeneratedRecipeDraftDto ParseDraft(string json, int defaultCategory)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
return EnsureDraftId(new GeneratedRecipeDraftDto
|
||||
{
|
||||
Name = root.TryGetProperty("name", out var n) ? AiRecipeJsonParser.GetString(n) ?? "Nowy przepis" : "Nowy przepis",
|
||||
MealCategory = root.TryGetProperty("mealCategory", out var mc)
|
||||
? AiRecipeJsonParser.GetInt32(mc, defaultCategory)
|
||||
: defaultCategory,
|
||||
PrepTimeMinutes = root.TryGetProperty("prepTimeMinutes", out var pt)
|
||||
? AiRecipeJsonParser.GetNullableInt32(pt)
|
||||
: null,
|
||||
Ingredients = root.TryGetProperty("ingredients", out var ing) ? ParseIngredientsSync(ing) : [],
|
||||
Steps = root.TryGetProperty("steps", out var st) ? ParseSteps(st) : [],
|
||||
});
|
||||
}
|
||||
|
||||
private List<GeneratedRecipeDraftDto> ParseDrafts(string json, int defaultCategory)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
var list = new List<GeneratedRecipeDraftDto>();
|
||||
|
||||
if (root.TryGetProperty("recipes", out var recipesEl) && recipesEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in recipesEl.EnumerateArray())
|
||||
{
|
||||
list.Add(EnsureDraftId(ParseDraft(item.GetRawText(), defaultCategory)));
|
||||
}
|
||||
}
|
||||
else if (root.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in root.EnumerateArray())
|
||||
{
|
||||
list.Add(EnsureDraftId(ParseDraft(item.GetRawText(), defaultCategory)));
|
||||
}
|
||||
}
|
||||
else if (root.TryGetProperty("name", out _))
|
||||
{
|
||||
list.Add(ParseDraft(json, defaultCategory));
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private List<GeneratedIngredientDto> ParseIngredientsSync(JsonElement ingEl)
|
||||
{
|
||||
var list = new List<GeneratedIngredientDto>();
|
||||
foreach (var item in ingEl.EnumerateArray())
|
||||
{
|
||||
list.Add(new GeneratedIngredientDto
|
||||
{
|
||||
Name = item.TryGetProperty("name", out var nameEl)
|
||||
? AiRecipeJsonParser.GetString(nameEl) ?? ""
|
||||
: "",
|
||||
AmountGrams = item.TryGetProperty("amountGrams", out var g)
|
||||
? AiRecipeJsonParser.GetNullableDecimal(g)
|
||||
: null,
|
||||
Unit = item.TryGetProperty("unit", out var u) && u.ValueKind != JsonValueKind.Null
|
||||
? AiRecipeJsonParser.GetString(u)
|
||||
: null,
|
||||
CatalogName = item.TryGetProperty("catalogName", out var c)
|
||||
? AiRecipeJsonParser.GetString(c)
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private async Task<List<GeneratedIngredientDto>> ParseIngredientsAsync(JsonElement ingEl, CancellationToken ct)
|
||||
{
|
||||
var list = ParseIngredientsSync(ingEl);
|
||||
foreach (var item in list)
|
||||
{
|
||||
item.CatalogItemId = await _catalogMatching.ResolveCatalogItemIdAsync(item.CatalogName ?? item.Name, ct);
|
||||
item.IsLinked = item.CatalogItemId.HasValue;
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static List<GeneratedStepDto> ParseSteps(JsonElement stepsEl)
|
||||
{
|
||||
var list = new List<GeneratedStepDto>();
|
||||
foreach (var item in stepsEl.EnumerateArray())
|
||||
{
|
||||
var stepNumber = item.TryGetProperty("stepNumber", out var stepEl)
|
||||
? AiRecipeJsonParser.GetInt32(stepEl, list.Count + 1)
|
||||
: list.Count + 1;
|
||||
list.Add(new GeneratedStepDto
|
||||
{
|
||||
StepNumber = stepNumber,
|
||||
Description = item.TryGetProperty("description", out var descEl)
|
||||
? AiRecipeJsonParser.GetString(descEl) ?? ""
|
||||
: "",
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
private static bool IsSpiceUnit(string? unit)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(unit)) return false;
|
||||
var u = unit.Trim().ToLowerInvariant();
|
||||
return u is "do smaku" or "optional" or "opcjonalnie" or "szczypta";
|
||||
}
|
||||
|
||||
private static RecipeGeneratorConstraintsDto DeserializeConstraints(string json) =>
|
||||
JsonSerializer.Deserialize<RecipeGeneratorConstraintsDto>(json) ?? new RecipeGeneratorConstraintsDto();
|
||||
|
||||
private static List<GeneratedRecipeDraftDto> DeserializeDrafts(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return [];
|
||||
|
||||
var trimmed = json.TrimStart();
|
||||
if (trimmed.StartsWith('['))
|
||||
{
|
||||
return (JsonSerializer.Deserialize<List<GeneratedRecipeDraftDto>>(json) ?? [])
|
||||
.Select(EnsureDraftId)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
var single = JsonSerializer.Deserialize<GeneratedRecipeDraftDto>(json);
|
||||
return single is null ? [] : [EnsureDraftId(single)];
|
||||
}
|
||||
|
||||
private static string SerializeDrafts(IReadOnlyList<GeneratedRecipeDraftDto> drafts) =>
|
||||
JsonSerializer.Serialize(drafts);
|
||||
|
||||
private static GeneratedRecipeDraftDto EnsureDraftId(GeneratedRecipeDraftDto draft)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(draft.DraftId))
|
||||
{
|
||||
draft.DraftId = Guid.NewGuid().ToString();
|
||||
}
|
||||
|
||||
return draft;
|
||||
}
|
||||
|
||||
private static RecipeGeneratorSessionDto MapSession(
|
||||
RecipeGeneratorSession session,
|
||||
RecipeGeneratorConstraintsDto constraints,
|
||||
IReadOnlyList<GeneratedRecipeDraftDto> drafts) => new()
|
||||
{
|
||||
Id = session.Id,
|
||||
Status = session.Status,
|
||||
Constraints = constraints,
|
||||
Draft = drafts.FirstOrDefault(),
|
||||
Drafts = drafts,
|
||||
SavedRecipeId = session.SavedRecipeId,
|
||||
GenerationVersion = session.GenerationVersion,
|
||||
};
|
||||
}
|
||||
59
MealPlan.Api/Services/Generators/TdeeCalculator.cs
Normal file
59
MealPlan.Api/Services/Generators/TdeeCalculator.cs
Normal file
@@ -0,0 +1,59 @@
|
||||
namespace MealPlan.Api.Services.Generators;
|
||||
|
||||
public static class TdeeCalculator
|
||||
{
|
||||
public record MacroTargets(int Calories, decimal ProteinG, decimal FatG, decimal CarbsG, string Rationale);
|
||||
|
||||
public static MacroTargets Calculate(
|
||||
decimal heightCm,
|
||||
decimal weightKg,
|
||||
int age,
|
||||
string sex,
|
||||
string activityLevel,
|
||||
string goal)
|
||||
{
|
||||
var bmr = sex.ToLowerInvariant() switch
|
||||
{
|
||||
"female" => 10m * weightKg + 6.25m * heightCm - 5m * age - 161m,
|
||||
_ => 10m * weightKg + 6.25m * heightCm - 5m * age + 5m,
|
||||
};
|
||||
|
||||
var multiplier = activityLevel.ToLowerInvariant() switch
|
||||
{
|
||||
"sedentary" => 1.2m,
|
||||
"light" => 1.375m,
|
||||
"moderate" => 1.55m,
|
||||
"active" => 1.725m,
|
||||
"very_active" => 1.9m,
|
||||
_ => 1.55m,
|
||||
};
|
||||
|
||||
var tdee = bmr * multiplier;
|
||||
var adjustment = goal.ToLowerInvariant() switch
|
||||
{
|
||||
"lose_weight" => -500,
|
||||
"gain_muscle" => 300,
|
||||
"recomp" => -200,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
var calories = Math.Max(1200, (int)Math.Round(tdee + adjustment));
|
||||
|
||||
var (proteinPct, fatPct, carbsPct) = goal.ToLowerInvariant() switch
|
||||
{
|
||||
"gain_muscle" => (0.30m, 0.25m, 0.45m),
|
||||
"lose_weight" => (0.35m, 0.25m, 0.40m),
|
||||
"recomp" => (0.32m, 0.28m, 0.40m),
|
||||
_ => (0.25m, 0.30m, 0.45m),
|
||||
};
|
||||
|
||||
var proteinG = Math.Round(calories * proteinPct / 4m, 1);
|
||||
var fatG = Math.Round(calories * fatPct / 9m, 1);
|
||||
var carbsG = Math.Round(calories * carbsPct / 4m, 1);
|
||||
|
||||
var rationale =
|
||||
$"TDEE ≈ {(int)Math.Round(tdee)} kcal ({activityLevel}, {goal}). Target {calories} kcal after goal adjustment.";
|
||||
|
||||
return new MacroTargets(calories, proteinG, fatG, carbsG, rationale);
|
||||
}
|
||||
}
|
||||
33
MealPlan.Api/Services/IDietTrackingService.cs
Normal file
33
MealPlan.Api/Services/IDietTrackingService.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
using MealPlan.Api.DTOs.Diet;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
public interface IDietTrackingService
|
||||
{
|
||||
Task<UserDailyGoalsDto?> GetGoalsAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<UserDailyGoalsDto> UpsertGoalsAsync(Guid userId, UserDailyGoalsDto dto, CancellationToken ct = default);
|
||||
Task<DailySummaryDto> GetDailySummaryAsync(Guid userId, DateOnly date, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<MealConsumptionDto>> GetMealsAsync(Guid userId, DateOnly date, CancellationToken ct = default);
|
||||
Task<MealConsumptionDto?> LogMealAsync(Guid userId, LogMealDto dto, CancellationToken ct = default);
|
||||
Task<MealPreviewDto?> PreviewMealAsync(Guid userId, DateOnly date, LogMealDto dto, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<DietSuggestionDto>> GetMealSuggestionsAsync(Guid userId, DateOnly date, int limit = 5, CancellationToken ct = default);
|
||||
Task<bool> DeleteMealAsync(Guid userId, int id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<DrinkCatalogItemDto>> GetDrinkCatalogAsync(CancellationToken ct = default);
|
||||
Task<IReadOnlyList<DrinkConsumptionDto>> GetDrinksAsync(Guid userId, DateOnly date, CancellationToken ct = default);
|
||||
Task<DrinkConsumptionDto?> LogDrinkAsync(Guid userId, LogDrinkDto dto, CancellationToken ct = default);
|
||||
Task<bool> DeleteDrinkAsync(Guid userId, int id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<DietReminderDto>> GetRemindersAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<DietReminderDto?> CreateReminderAsync(Guid userId, CreateDietReminderDto dto, CancellationToken ct = default);
|
||||
Task<DietReminderDto?> UpdateReminderAsync(Guid userId, int id, UpdateDietReminderDto dto, CancellationToken ct = default);
|
||||
Task<bool> DeleteReminderAsync(Guid userId, int id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<DueReminderDto>> GetDueRemindersAsync(Guid userId, DateOnly? date, TimeOnly? time, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<HistoryDayDto>> GetHistoryAsync(Guid userId, DateOnly from, DateOnly to, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<RecentMealDto>> GetRecentMealsAsync(Guid userId, int limit = 10, CancellationToken ct = default);
|
||||
Task<CopyMealsResultDto?> CopyYesterdayMealsAsync(Guid userId, DateOnly targetDate, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<FavoriteRecipeDto>> GetFavoriteRecipesAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<FavoriteRecipeDto?> AddFavoriteRecipeAsync(Guid userId, int recipeId, CancellationToken ct = default);
|
||||
Task<bool> RemoveFavoriteRecipeAsync(Guid userId, int recipeId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<FavoriteCatalogItemDto>> GetFavoriteCatalogItemsAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<FavoriteCatalogItemDto?> AddFavoriteCatalogItemAsync(Guid userId, int catalogItemId, CancellationToken ct = default);
|
||||
Task<bool> RemoveFavoriteCatalogItemAsync(Guid userId, int catalogItemId, CancellationToken ct = default);
|
||||
}
|
||||
16
MealPlan.Api/Services/IIngredientCatalogService.cs
Normal file
16
MealPlan.Api/Services/IIngredientCatalogService.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
using MealPlan.Api.DTOs.IngredientCatalog;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
public interface IIngredientCatalogService
|
||||
{
|
||||
Task<IReadOnlyList<IngredientCategoryDto>> GetCategoriesAsync(CancellationToken ct = default);
|
||||
Task<IReadOnlyList<IngredientNutritionItemDto>> GetItemsAsync(
|
||||
string? categoryCode,
|
||||
string? search,
|
||||
CancellationToken ct = default);
|
||||
Task<IngredientNutritionItemDto?> GetItemByIdAsync(int id, CancellationToken ct = default);
|
||||
Task<IngredientNutritionItemDto?> CreateItemAsync(UpsertIngredientNutritionItemDto dto, CancellationToken ct = default);
|
||||
Task<IngredientNutritionItemDto?> UpdateItemAsync(int id, UpsertIngredientNutritionItemDto dto, CancellationToken ct = default);
|
||||
Task<bool> DeleteItemAsync(int id, CancellationToken ct = default);
|
||||
}
|
||||
11
MealPlan.Api/Services/IMealPlanService.cs
Normal file
11
MealPlan.Api/Services/IMealPlanService.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using MealPlan.Api.DTOs.MealPlan;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
public interface IMealPlanService
|
||||
{
|
||||
Task<IReadOnlyList<MealPlanEntryDto>> GetEntriesAsync(Guid userId, DateOnly from, DateOnly to, CancellationToken ct = default);
|
||||
Task<MealPlanEntryDto?> UpsertEntryAsync(Guid userId, UpsertMealPlanEntryDto dto, CancellationToken ct = default);
|
||||
Task<bool> DeleteEntryAsync(Guid userId, int id, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<ShoppingListItemDto>> GetShoppingListAsync(Guid userId, DateOnly from, DateOnly to, CancellationToken ct = default);
|
||||
}
|
||||
12
MealPlan.Api/Services/IRecipeExcelService.cs
Normal file
12
MealPlan.Api/Services/IRecipeExcelService.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using MealPlan.Api.DTOs.Recipe;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
public interface IRecipeExcelService
|
||||
{
|
||||
/// <summary>Generates a blank .xlsx template with Recipes, Ingredients, and Steps sheets.</summary>
|
||||
byte[] GenerateTemplate();
|
||||
|
||||
/// <summary>Parses an uploaded workbook and creates recipes in the database.</summary>
|
||||
Task<RecipeImportResultDto> ImportAsync(Stream fileStream, CancellationToken ct = default);
|
||||
}
|
||||
12
MealPlan.Api/Services/IRecipeManagementService.cs
Normal file
12
MealPlan.Api/Services/IRecipeManagementService.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
using MealPlan.Api.DTOs.Recipe;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
public interface IRecipeManagementService
|
||||
{
|
||||
Task<RecipeDetailDto> CreateRecipeAsync(CreateRecipeDto dto, CancellationToken ct = default);
|
||||
|
||||
Task<RecipeDetailDto?> UpdateRecipeAsync(int id, CreateRecipeDto dto, CancellationToken ct = default);
|
||||
|
||||
Task<RecipeMacroRecalcResultDto> RecalculateAllMacrosFromCatalogAsync(CancellationToken ct = default);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ namespace MealPlan.Api.Services;
|
||||
|
||||
public interface IRecipeService
|
||||
{
|
||||
Task<IReadOnlyList<RecipeListItemDto>> GetRecipesAsync(int? category, string? search, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<RecipeListItemDto>> GetRecipesAsync(int? category, string? search, string? ingredient, CancellationToken ct = default);
|
||||
|
||||
Task<RecipeDetailDto?> GetRecipeByIdAsync(int id, CancellationToken ct = default);
|
||||
|
||||
|
||||
153
MealPlan.Api/Services/IngredientCatalogService.cs
Normal file
153
MealPlan.Api/Services/IngredientCatalogService.cs
Normal file
@@ -0,0 +1,153 @@
|
||||
using MealPlan.Api.Data;
|
||||
using MealPlan.Api.DTOs.IngredientCatalog;
|
||||
using MealPlan.Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
public class IngredientCatalogService : IIngredientCatalogService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
|
||||
public IngredientCatalogService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<IReadOnlyList<IngredientCategoryDto>> GetCategoriesAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _db.IngredientCategories.AsNoTracking()
|
||||
.Where(c => c.IsActive)
|
||||
.OrderBy(c => c.SortOrder)
|
||||
.ThenBy(c => c.Name)
|
||||
.Select(c => new IngredientCategoryDto
|
||||
{
|
||||
Id = c.Id,
|
||||
Code = c.Code,
|
||||
Name = c.Name,
|
||||
ItemCount = c.Items.Count(i => i.IsActive),
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<IngredientNutritionItemDto>> GetItemsAsync(
|
||||
string? categoryCode,
|
||||
string? search,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var query = _db.IngredientNutritionCatalog.AsNoTracking()
|
||||
.Include(i => i.Category)
|
||||
.Where(i => i.IsActive && i.Category!.IsActive);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(categoryCode))
|
||||
{
|
||||
var code = categoryCode.Trim().ToLowerInvariant();
|
||||
query = query.Where(i => i.Category!.Code == code);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
{
|
||||
var term = search.Trim().ToLowerInvariant();
|
||||
query = query.Where(i =>
|
||||
i.Name.ToLower().Contains(term) ||
|
||||
(i.NamePl != null && i.NamePl.ToLower().Contains(term)));
|
||||
}
|
||||
|
||||
var rows = await query
|
||||
.OrderBy(i => i.Category!.SortOrder)
|
||||
.ThenBy(i => i.SortOrder)
|
||||
.ThenBy(i => i.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return rows.Select(MapItem).ToList();
|
||||
}
|
||||
|
||||
public async Task<IngredientNutritionItemDto?> GetItemByIdAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var row = await _db.IngredientNutritionCatalog.AsNoTracking()
|
||||
.Include(i => i.Category)
|
||||
.FirstOrDefaultAsync(i => i.Id == id && i.IsActive, ct);
|
||||
return row is null ? null : MapItem(row);
|
||||
}
|
||||
|
||||
public async Task<IngredientNutritionItemDto?> CreateItemAsync(
|
||||
UpsertIngredientNutritionItemDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var category = await _db.IngredientCategories.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == dto.CategoryId && c.IsActive, ct);
|
||||
if (category is null) return null;
|
||||
|
||||
var entity = new IngredientNutritionCatalogItem
|
||||
{
|
||||
CategoryId = dto.CategoryId,
|
||||
Name = dto.Name.Trim(),
|
||||
NamePl = string.IsNullOrWhiteSpace(dto.NamePl) ? null : dto.NamePl.Trim(),
|
||||
CaloriesPer100G = dto.CaloriesPer100G,
|
||||
ProteinGPer100G = dto.ProteinGPer100G,
|
||||
FatGPer100G = dto.FatGPer100G,
|
||||
CarbsGPer100G = dto.CarbsGPer100G,
|
||||
FiberGPer100G = dto.FiberGPer100G,
|
||||
SortOrder = dto.SortOrder,
|
||||
IsActive = dto.IsActive,
|
||||
};
|
||||
|
||||
_db.IngredientNutritionCatalog.Add(entity);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
entity.Category = category;
|
||||
return MapItem(entity);
|
||||
}
|
||||
|
||||
public async Task<IngredientNutritionItemDto?> UpdateItemAsync(
|
||||
int id,
|
||||
UpsertIngredientNutritionItemDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _db.IngredientNutritionCatalog
|
||||
.Include(i => i.Category)
|
||||
.FirstOrDefaultAsync(i => i.Id == id, ct);
|
||||
if (entity is null) return null;
|
||||
|
||||
var category = await _db.IngredientCategories.AsNoTracking()
|
||||
.FirstOrDefaultAsync(c => c.Id == dto.CategoryId && c.IsActive, ct);
|
||||
if (category is null) return null;
|
||||
|
||||
entity.CategoryId = dto.CategoryId;
|
||||
entity.Name = dto.Name.Trim();
|
||||
entity.NamePl = string.IsNullOrWhiteSpace(dto.NamePl) ? null : dto.NamePl.Trim();
|
||||
entity.CaloriesPer100G = dto.CaloriesPer100G;
|
||||
entity.ProteinGPer100G = dto.ProteinGPer100G;
|
||||
entity.FatGPer100G = dto.FatGPer100G;
|
||||
entity.CarbsGPer100G = dto.CarbsGPer100G;
|
||||
entity.FiberGPer100G = dto.FiberGPer100G;
|
||||
entity.SortOrder = dto.SortOrder;
|
||||
entity.IsActive = dto.IsActive;
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
entity.Category = category;
|
||||
return MapItem(entity);
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteItemAsync(int id, CancellationToken ct = default)
|
||||
{
|
||||
var entity = await _db.IngredientNutritionCatalog.FirstOrDefaultAsync(i => i.Id == id, ct);
|
||||
if (entity is null) return false;
|
||||
|
||||
entity.IsActive = false;
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static IngredientNutritionItemDto MapItem(IngredientNutritionCatalogItem item) => new()
|
||||
{
|
||||
Id = item.Id,
|
||||
CategoryId = item.CategoryId,
|
||||
CategoryCode = item.Category?.Code ?? string.Empty,
|
||||
CategoryName = item.Category?.Name ?? string.Empty,
|
||||
Name = item.Name,
|
||||
NamePl = item.NamePl,
|
||||
CaloriesPer100G = item.CaloriesPer100G,
|
||||
ProteinGPer100G = item.ProteinGPer100G,
|
||||
FatGPer100G = item.FatGPer100G,
|
||||
CarbsGPer100G = item.CarbsGPer100G,
|
||||
FiberGPer100G = item.FiberGPer100G,
|
||||
};
|
||||
}
|
||||
197
MealPlan.Api/Services/MealPlanService.cs
Normal file
197
MealPlan.Api/Services/MealPlanService.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using MealPlan.Api.Data;
|
||||
using MealPlan.Api.DTOs.MealPlan;
|
||||
using MealPlan.Api.Services.Generators;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
public class MealPlanService : IMealPlanService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IRecipeService _recipeService;
|
||||
|
||||
public MealPlanService(AppDbContext db, IRecipeService recipeService)
|
||||
{
|
||||
_db = db;
|
||||
_recipeService = recipeService;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MealPlanEntryDto>> GetEntriesAsync(
|
||||
Guid userId,
|
||||
DateOnly from,
|
||||
DateOnly to,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return await _db.MealPlanEntries.AsNoTracking()
|
||||
.Include(e => e.Recipe)
|
||||
.Where(e => e.UserId == userId && e.PlanDate >= from && e.PlanDate <= to)
|
||||
.OrderBy(e => e.PlanDate)
|
||||
.ThenBy(e => e.MealCategory)
|
||||
.Select(e => new MealPlanEntryDto
|
||||
{
|
||||
Id = e.Id,
|
||||
PlanDate = e.PlanDate,
|
||||
MealCategory = e.MealCategory,
|
||||
RecipeId = e.RecipeId,
|
||||
RecipeName = e.Recipe!.Name,
|
||||
Portions = e.Portions,
|
||||
Calories = e.Recipe!.Calories,
|
||||
})
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<MealPlanEntryDto?> UpsertEntryAsync(
|
||||
Guid userId,
|
||||
UpsertMealPlanEntryDto dto,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var recipeExists = await _db.Recipes.AnyAsync(r => r.Id == dto.RecipeId && r.IsActive, ct);
|
||||
if (!recipeExists) return null;
|
||||
|
||||
var existing = await _db.MealPlanEntries
|
||||
.Include(e => e.Recipe)
|
||||
.FirstOrDefaultAsync(
|
||||
e => e.UserId == userId && e.PlanDate == dto.PlanDate && e.MealCategory == dto.MealCategory,
|
||||
ct);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new Models.MealPlanEntry
|
||||
{
|
||||
UserId = userId,
|
||||
PlanDate = dto.PlanDate,
|
||||
MealCategory = dto.MealCategory,
|
||||
RecipeId = dto.RecipeId,
|
||||
Portions = dto.Portions,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_db.MealPlanEntries.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.RecipeId = dto.RecipeId;
|
||||
existing.Portions = dto.Portions;
|
||||
existing.UpdatedAt = DateTime.UtcNow;
|
||||
if (existing.Recipe is null)
|
||||
{
|
||||
await _db.Entry(existing).Reference(e => e.Recipe).LoadAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
await _db.Entry(existing).Reference(e => e.Recipe).LoadAsync(ct);
|
||||
|
||||
return new MealPlanEntryDto
|
||||
{
|
||||
Id = existing.Id,
|
||||
PlanDate = existing.PlanDate,
|
||||
MealCategory = existing.MealCategory,
|
||||
RecipeId = existing.RecipeId,
|
||||
RecipeName = existing.Recipe?.Name ?? string.Empty,
|
||||
Portions = existing.Portions,
|
||||
Calories = existing.Recipe?.Calories,
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteEntryAsync(Guid userId, int id, CancellationToken ct = default)
|
||||
{
|
||||
var entry = await _db.MealPlanEntries.FirstOrDefaultAsync(e => e.Id == id && e.UserId == userId, ct);
|
||||
if (entry is null) return false;
|
||||
_db.MealPlanEntries.Remove(entry);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ShoppingListItemDto>> GetShoppingListAsync(
|
||||
Guid userId,
|
||||
DateOnly from,
|
||||
DateOnly to,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var entries = await _db.MealPlanEntries.AsNoTracking()
|
||||
.Where(e => e.UserId == userId && e.PlanDate >= from && e.PlanDate <= to)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var recipeIds = entries.Select(e => e.RecipeId).Distinct().ToList();
|
||||
var recipes = new List<DTOs.Recipe.RecipeDetailDto>();
|
||||
foreach (var id in recipeIds)
|
||||
{
|
||||
var recipe = await _recipeService.GetRecipeByIdAsync(id, ct);
|
||||
if (recipe is not null) recipes.Add(recipe);
|
||||
}
|
||||
|
||||
var buckets = new Dictionary<string, ShoppingBucket>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var recipe = recipes.FirstOrDefault(r => r.Id == entry.RecipeId);
|
||||
if (recipe is null) continue;
|
||||
|
||||
foreach (var ing in recipe.Ingredients)
|
||||
{
|
||||
if (IngredientScalingHelper.IsNonQuantifiedUnit(ing.Unit))
|
||||
continue;
|
||||
|
||||
var (scaledGrams, _) = IngredientScalingHelper.ScaleForDietPlan(
|
||||
ing.AmountGrams, ing.Unit, entry.Portions);
|
||||
|
||||
if (scaledGrams is not > 0)
|
||||
continue;
|
||||
|
||||
var key = $"g:{NormalizeName(ing.Name)}";
|
||||
|
||||
if (!buckets.TryGetValue(key, out var bucket))
|
||||
{
|
||||
bucket = new ShoppingBucket(ing.Name, null);
|
||||
buckets[key] = bucket;
|
||||
}
|
||||
|
||||
bucket.Add(recipe.Name, scaledGrams, null);
|
||||
}
|
||||
}
|
||||
|
||||
return buckets.Values
|
||||
.Select(b => new ShoppingListItemDto
|
||||
{
|
||||
Name = b.Name,
|
||||
Unit = b.Unit,
|
||||
TotalGrams = b.TotalGrams,
|
||||
TotalAmount = b.TotalAmount,
|
||||
Breakdown = b.Breakdown,
|
||||
})
|
||||
.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeName(string name) => name.Trim().ToLowerInvariant();
|
||||
|
||||
private sealed class ShoppingBucket
|
||||
{
|
||||
public string Name { get; }
|
||||
public string? Unit { get; }
|
||||
public decimal? TotalGrams { get; private set; }
|
||||
public decimal? TotalAmount { get; private set; }
|
||||
public List<string> Breakdown { get; } = new();
|
||||
|
||||
public ShoppingBucket(string name, string? unit)
|
||||
{
|
||||
Name = name;
|
||||
Unit = unit;
|
||||
}
|
||||
|
||||
public void Add(string recipeName, decimal? grams, decimal? amount)
|
||||
{
|
||||
if (Unit is null && grams.HasValue)
|
||||
{
|
||||
TotalGrams = (TotalGrams ?? 0) + grams.Value;
|
||||
Breakdown.Add($"{recipeName}: {grams.Value:0.##}g");
|
||||
}
|
||||
else if (amount.HasValue)
|
||||
{
|
||||
TotalAmount = (TotalAmount ?? 0) + amount.Value;
|
||||
var suffix = Unit is null ? "g" : $" {Unit}";
|
||||
Breakdown.Add($"{recipeName}: {amount.Value:0.##}{suffix}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
306
MealPlan.Api/Services/RecipeExcelService.cs
Normal file
306
MealPlan.Api/Services/RecipeExcelService.cs
Normal file
@@ -0,0 +1,306 @@
|
||||
using ClosedXML.Excel;
|
||||
using MealPlan.Api.Data;
|
||||
using MealPlan.Api.DTOs.Recipe;
|
||||
using MealPlan.Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Imports recipes from a three-sheet Excel workbook (Recipes, Ingredients, Steps).
|
||||
/// Also generates a downloadable template in the same format.
|
||||
/// </summary>
|
||||
public class RecipeExcelService : IRecipeExcelService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ILogger<RecipeExcelService> _logger;
|
||||
|
||||
public RecipeExcelService(AppDbContext db, ILogger<RecipeExcelService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public byte[] GenerateTemplate()
|
||||
{
|
||||
using var workbook = new XLWorkbook();
|
||||
|
||||
var recipes = workbook.Worksheets.Add("Recipes");
|
||||
recipes.Cell(1, 1).Value = "Name";
|
||||
recipes.Cell(1, 2).Value = "MealCategory";
|
||||
recipes.Cell(1, 3).Value = "Calories";
|
||||
recipes.Cell(1, 4).Value = "Protein";
|
||||
recipes.Cell(1, 5).Value = "Fat";
|
||||
recipes.Cell(1, 6).Value = "Carbs";
|
||||
recipes.Cell(1, 7).Value = "PrepTimeMinutes";
|
||||
recipes.Row(1).Style.Font.Bold = true;
|
||||
recipes.Cell(2, 1).Value = "Oatmeal with berries";
|
||||
recipes.Cell(2, 2).Value = "Breakfast";
|
||||
recipes.Cell(2, 3).Value = 320;
|
||||
recipes.Cell(2, 4).Value = 12;
|
||||
recipes.Cell(2, 5).Value = 8;
|
||||
recipes.Cell(2, 6).Value = 45;
|
||||
recipes.Cell(2, 7).Value = 10;
|
||||
recipes.Columns().AdjustToContents();
|
||||
|
||||
var ingredients = workbook.Worksheets.Add("Ingredients");
|
||||
ingredients.Cell(1, 1).Value = "RecipeName";
|
||||
ingredients.Cell(1, 2).Value = "Name";
|
||||
ingredients.Cell(1, 3).Value = "AmountGrams";
|
||||
ingredients.Cell(1, 4).Value = "Unit";
|
||||
ingredients.Cell(1, 5).Value = "SortOrder";
|
||||
ingredients.Row(1).Style.Font.Bold = true;
|
||||
ingredients.Cell(2, 1).Value = "Oatmeal with berries";
|
||||
ingredients.Cell(2, 2).Value = "Rolled oats";
|
||||
ingredients.Cell(2, 3).Value = 80;
|
||||
ingredients.Cell(2, 4).Value = "g";
|
||||
ingredients.Cell(2, 5).Value = 0;
|
||||
ingredients.Cell(3, 1).Value = "Oatmeal with berries";
|
||||
ingredients.Cell(3, 2).Value = "Blueberries";
|
||||
ingredients.Cell(3, 3).Value = 100;
|
||||
ingredients.Cell(3, 4).Value = "g";
|
||||
ingredients.Cell(3, 5).Value = 1;
|
||||
ingredients.Columns().AdjustToContents();
|
||||
|
||||
var steps = workbook.Worksheets.Add("Steps");
|
||||
steps.Cell(1, 1).Value = "RecipeName";
|
||||
steps.Cell(1, 2).Value = "StepNumber";
|
||||
steps.Cell(1, 3).Value = "Description";
|
||||
steps.Row(1).Style.Font.Bold = true;
|
||||
steps.Cell(2, 1).Value = "Oatmeal with berries";
|
||||
steps.Cell(2, 2).Value = 1;
|
||||
steps.Cell(2, 3).Value = "Cook oats in milk for 5 minutes.";
|
||||
steps.Cell(3, 1).Value = "Oatmeal with berries";
|
||||
steps.Cell(3, 2).Value = 2;
|
||||
steps.Cell(3, 3).Value = "Top with berries and serve.";
|
||||
steps.Columns().AdjustToContents();
|
||||
|
||||
var help = workbook.Worksheets.Add("Help");
|
||||
help.Cell(1, 1).Value = "MealCategory values";
|
||||
help.Cell(2, 1).Value = "Breakfast, SecondBreakfast (or Second Breakfast), Lunch, Dinner";
|
||||
help.Cell(2, 2).Value = "Or numeric: 0, 1, 2, 3";
|
||||
help.Cell(4, 1).Value = "RecipeName in Ingredients/Steps must match Name in Recipes sheet exactly.";
|
||||
help.Columns().AdjustToContents();
|
||||
|
||||
using var stream = new MemoryStream();
|
||||
workbook.SaveAs(stream);
|
||||
return stream.ToArray();
|
||||
}
|
||||
|
||||
public async Task<RecipeImportResultDto> ImportAsync(Stream fileStream, CancellationToken ct = default)
|
||||
{
|
||||
var result = new RecipeImportResultDto();
|
||||
|
||||
using var workbook = new XLWorkbook(fileStream);
|
||||
var recipeSheet = workbook.Worksheets.FirstOrDefault(w =>
|
||||
w.Name.Equals("Recipes", StringComparison.OrdinalIgnoreCase));
|
||||
if (recipeSheet is null)
|
||||
{
|
||||
result.Errors.Add("Worksheet 'Recipes' not found.");
|
||||
return result;
|
||||
}
|
||||
|
||||
var ingredientSheet = workbook.Worksheets.FirstOrDefault(w =>
|
||||
w.Name.Equals("Ingredients", StringComparison.OrdinalIgnoreCase));
|
||||
var stepSheet = workbook.Worksheets.FirstOrDefault(w =>
|
||||
w.Name.Equals("Steps", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var ingredientRows = ParseIngredients(ingredientSheet, result.Errors);
|
||||
var stepRows = ParseSteps(stepSheet, result.Errors);
|
||||
|
||||
var recipeRows = recipeSheet.RowsUsed().Skip(1);
|
||||
foreach (var row in recipeRows)
|
||||
{
|
||||
var name = row.Cell(1).GetString().Trim();
|
||||
if (string.IsNullOrEmpty(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!TryParseCategory(row.Cell(2).GetString(), out var category, out var categoryError))
|
||||
{
|
||||
result.Errors.Add($"Recipe '{name}': {categoryError}");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var dto = new CreateRecipeDto
|
||||
{
|
||||
Name = name,
|
||||
MealCategory = category,
|
||||
Calories = ParseNullableInt(row.Cell(3)),
|
||||
Protein = ParseNullableDecimal(row.Cell(4)),
|
||||
Fat = ParseNullableDecimal(row.Cell(5)),
|
||||
Carbs = ParseNullableDecimal(row.Cell(6)),
|
||||
PrepTimeMinutes = ParseNullableInt(row.Cell(7)),
|
||||
Ingredients = ingredientRows
|
||||
.Where(i => i.RecipeName.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(i => new CreateIngredientDto
|
||||
{
|
||||
Name = i.Name,
|
||||
AmountGrams = i.AmountGrams,
|
||||
Unit = i.Unit,
|
||||
SortOrder = i.SortOrder,
|
||||
})
|
||||
.ToList(),
|
||||
Steps = stepRows
|
||||
.Where(s => s.RecipeName.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(s => s.StepNumber)
|
||||
.Select(s => new CreateRecipeStepDto
|
||||
{
|
||||
StepNumber = s.StepNumber,
|
||||
Description = s.Description,
|
||||
})
|
||||
.ToList(),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var exists = await _db.Recipes.AnyAsync(
|
||||
r => r.IsActive && r.Name.ToLower() == name.ToLower(), ct);
|
||||
if (exists)
|
||||
{
|
||||
result.Errors.Add($"Recipe '{name}' already exists — skipped.");
|
||||
result.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var entity = RecipeManagementService.MapToEntity(dto);
|
||||
entity.CreatedAt = DateTime.UtcNow;
|
||||
entity.IsActive = true;
|
||||
_db.Recipes.Add(entity);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
result.CreatedCount++;
|
||||
result.CreatedRecipeIds.Add(entity.Id);
|
||||
_logger.LogInformation("Imported recipe {RecipeId} '{Name}' from Excel", entity.Id, name);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to import recipe '{Name}'", name);
|
||||
result.Errors.Add($"Recipe '{name}': {ex.Message}");
|
||||
result.SkippedCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<IngredientRow> ParseIngredients(IXLWorksheet? sheet, List<string> errors)
|
||||
{
|
||||
var rows = new List<IngredientRow>();
|
||||
if (sheet is null)
|
||||
{
|
||||
return rows;
|
||||
}
|
||||
|
||||
foreach (var row in sheet.RowsUsed().Skip(1))
|
||||
{
|
||||
var recipeName = row.Cell(1).GetString().Trim();
|
||||
var name = row.Cell(2).GetString().Trim();
|
||||
if (string.IsNullOrEmpty(recipeName) || string.IsNullOrEmpty(name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.Add(new IngredientRow(
|
||||
recipeName,
|
||||
name,
|
||||
ParseNullableDecimal(row.Cell(3)),
|
||||
NullIfEmpty(row.Cell(4).GetString()),
|
||||
ParseNullableInt(row.Cell(5)) ?? rows.Count(r =>
|
||||
r.RecipeName.Equals(recipeName, StringComparison.OrdinalIgnoreCase))));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static List<StepRow> ParseSteps(IXLWorksheet? sheet, List<string> errors)
|
||||
{
|
||||
var rows = new List<StepRow>();
|
||||
if (sheet is null)
|
||||
{
|
||||
return rows;
|
||||
}
|
||||
|
||||
foreach (var row in sheet.RowsUsed().Skip(1))
|
||||
{
|
||||
var recipeName = row.Cell(1).GetString().Trim();
|
||||
var stepNumber = ParseNullableInt(row.Cell(2));
|
||||
var description = row.Cell(3).GetString().Trim();
|
||||
if (string.IsNullOrEmpty(recipeName) || stepNumber is null or < 1 || string.IsNullOrEmpty(description))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.Add(new StepRow(recipeName, stepNumber.Value, description));
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
internal static bool TryParseCategory(string raw, out int category, out string error)
|
||||
{
|
||||
error = string.Empty;
|
||||
category = 0;
|
||||
var value = raw.Trim();
|
||||
|
||||
if (int.TryParse(value, out category) && category is >= 0 and <= 3)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
category = value.ToLowerInvariant().Replace(" ", "") switch
|
||||
{
|
||||
"breakfast" => 0,
|
||||
"secondbreakfast" => 1,
|
||||
"lunch" => 2,
|
||||
"dinner" => 3,
|
||||
_ => -1,
|
||||
};
|
||||
|
||||
if (category < 0)
|
||||
{
|
||||
error = $"Invalid MealCategory '{raw}'. Use Breakfast, SecondBreakfast, Lunch, Dinner or 0–3.";
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int? ParseNullableInt(IXLCell cell)
|
||||
{
|
||||
if (cell.IsEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cell.TryGetValue(out int intValue))
|
||||
{
|
||||
return intValue;
|
||||
}
|
||||
|
||||
return int.TryParse(cell.GetString(), out intValue) ? intValue : null;
|
||||
}
|
||||
|
||||
private static decimal? ParseNullableDecimal(IXLCell cell)
|
||||
{
|
||||
if (cell.IsEmpty())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (cell.TryGetValue(out double doubleValue))
|
||||
{
|
||||
return (decimal)doubleValue;
|
||||
}
|
||||
|
||||
return decimal.TryParse(cell.GetString(), out var decimalValue) ? decimalValue : null;
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string value) =>
|
||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private sealed record IngredientRow(string RecipeName, string Name, decimal? AmountGrams, string? Unit, int SortOrder);
|
||||
|
||||
private sealed record StepRow(string RecipeName, int StepNumber, string Description);
|
||||
}
|
||||
190
MealPlan.Api/Services/RecipeManagementService.cs
Normal file
190
MealPlan.Api/Services/RecipeManagementService.cs
Normal file
@@ -0,0 +1,190 @@
|
||||
using MealPlan.Api.Data;
|
||||
using MealPlan.Api.DTOs.Recipe;
|
||||
using MealPlan.Api.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace MealPlan.Api.Services;
|
||||
|
||||
/// <summary>Creates and updates recipes in the existing PostgreSQL schema.</summary>
|
||||
public class RecipeManagementService : IRecipeManagementService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IRecipeService _recipeService;
|
||||
|
||||
public RecipeManagementService(AppDbContext db, IRecipeService recipeService)
|
||||
{
|
||||
_db = db;
|
||||
_recipeService = recipeService;
|
||||
}
|
||||
|
||||
public async Task<RecipeDetailDto> CreateRecipeAsync(CreateRecipeDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var recipe = MapToEntity(dto);
|
||||
recipe.CreatedAt = DateTime.UtcNow;
|
||||
recipe.IsActive = true;
|
||||
|
||||
await ApplyMacrosFromCatalogAsync(recipe, ct);
|
||||
|
||||
_db.Recipes.Add(recipe);
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
return (await _recipeService.GetRecipeByIdAsync(recipe.Id, ct))!;
|
||||
}
|
||||
|
||||
public async Task<RecipeDetailDto?> UpdateRecipeAsync(int id, CreateRecipeDto dto, CancellationToken ct = default)
|
||||
{
|
||||
var recipe = await _db.Recipes
|
||||
.Include(r => r.Ingredients)
|
||||
.Include(r => r.Steps)
|
||||
.FirstOrDefaultAsync(r => r.Id == id && r.IsActive, ct);
|
||||
|
||||
if (recipe is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
recipe.Name = dto.Name.Trim();
|
||||
recipe.MealCategory = dto.MealCategory;
|
||||
recipe.Calories = dto.Calories;
|
||||
recipe.Protein = dto.Protein;
|
||||
recipe.Fat = dto.Fat;
|
||||
recipe.Carbs = dto.Carbs;
|
||||
recipe.PrepTimeMinutes = dto.PrepTimeMinutes;
|
||||
recipe.UpdatedAt = DateTime.UtcNow;
|
||||
|
||||
_db.Ingredients.RemoveRange(recipe.Ingredients);
|
||||
_db.RecipeSteps.RemoveRange(recipe.Steps);
|
||||
|
||||
recipe.Ingredients = MapIngredients(dto.Ingredients);
|
||||
recipe.Steps = MapSteps(dto.Steps);
|
||||
|
||||
await ApplyMacrosFromCatalogAsync(recipe, ct);
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
return await _recipeService.GetRecipeByIdAsync(id, ct);
|
||||
}
|
||||
|
||||
public async Task<RecipeMacroRecalcResultDto> RecalculateAllMacrosFromCatalogAsync(CancellationToken ct = default)
|
||||
{
|
||||
var catalog = await _db.IngredientNutritionCatalog.AsNoTracking()
|
||||
.Where(i => i.IsActive)
|
||||
.ToDictionaryAsync(i => i.Id, ct);
|
||||
|
||||
var recipes = await _db.Recipes
|
||||
.Include(r => r.Ingredients)
|
||||
.Where(r => r.IsActive)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var updated = 0;
|
||||
foreach (var recipe in recipes)
|
||||
{
|
||||
if (TrySetMacrosFromCatalog(recipe, catalog, overwrite: true))
|
||||
{
|
||||
recipe.UpdatedAt = DateTime.UtcNow;
|
||||
updated++;
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(ct);
|
||||
|
||||
var unlinked = await _db.Ingredients.CountAsync(i =>
|
||||
i.AmountGrams > 0 &&
|
||||
i.CatalogItemId == null &&
|
||||
(i.Unit == null || !HasNonMacroUnit(i.Unit)), ct);
|
||||
|
||||
return new RecipeMacroRecalcResultDto
|
||||
{
|
||||
RecipesProcessed = recipes.Count,
|
||||
RecipesUpdated = updated,
|
||||
UnlinkedIngredientRows = unlinked,
|
||||
};
|
||||
}
|
||||
|
||||
internal static Recipe MapToEntity(CreateRecipeDto dto) =>
|
||||
new()
|
||||
{
|
||||
Name = dto.Name.Trim(),
|
||||
MealCategory = dto.MealCategory,
|
||||
Calories = dto.Calories,
|
||||
Protein = dto.Protein,
|
||||
Fat = dto.Fat,
|
||||
Carbs = dto.Carbs,
|
||||
PrepTimeMinutes = dto.PrepTimeMinutes,
|
||||
Ingredients = MapIngredients(dto.Ingredients),
|
||||
Steps = MapSteps(dto.Steps),
|
||||
};
|
||||
|
||||
private static List<Ingredient> MapIngredients(IEnumerable<CreateIngredientDto> items) =>
|
||||
items.Select((item, index) => new Ingredient
|
||||
{
|
||||
Name = item.Name.Trim(),
|
||||
AmountGrams = item.AmountGrams,
|
||||
Unit = string.IsNullOrWhiteSpace(item.Unit) ? null : item.Unit.Trim(),
|
||||
SortOrder = item.SortOrder > 0 ? item.SortOrder : index,
|
||||
CatalogItemId = item.CatalogItemId,
|
||||
}).ToList();
|
||||
|
||||
private async Task ApplyMacrosFromCatalogAsync(Recipe recipe, CancellationToken ct)
|
||||
{
|
||||
var catalogIds = recipe.Ingredients
|
||||
.Where(i => i.CatalogItemId.HasValue && i.AmountGrams is > 0 && !HasNonMacroUnit(i.Unit))
|
||||
.Select(i => i.CatalogItemId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (catalogIds.Count == 0) return;
|
||||
|
||||
var catalog = await _db.IngredientNutritionCatalog.AsNoTracking()
|
||||
.Where(i => catalogIds.Contains(i.Id) && i.IsActive)
|
||||
.ToDictionaryAsync(i => i.Id, ct);
|
||||
|
||||
TrySetMacrosFromCatalog(recipe, catalog, overwrite: false);
|
||||
}
|
||||
|
||||
private static bool TrySetMacrosFromCatalog(
|
||||
Recipe recipe,
|
||||
IReadOnlyDictionary<int, IngredientNutritionCatalogItem> catalog,
|
||||
bool overwrite)
|
||||
{
|
||||
int totalCalories = 0;
|
||||
decimal totalProtein = 0;
|
||||
decimal totalFat = 0;
|
||||
decimal totalCarbs = 0;
|
||||
var hasAny = false;
|
||||
|
||||
foreach (var ing in recipe.Ingredients)
|
||||
{
|
||||
if (ing.CatalogItemId is null || ing.AmountGrams is not > 0 || HasNonMacroUnit(ing.Unit)) continue;
|
||||
if (!catalog.TryGetValue(ing.CatalogItemId.Value, out var cat)) continue;
|
||||
|
||||
var scale = ing.AmountGrams.Value / 100m;
|
||||
totalCalories += (int)Math.Round(cat.CaloriesPer100G * scale);
|
||||
totalProtein += cat.ProteinGPer100G * scale;
|
||||
totalFat += cat.FatGPer100G * scale;
|
||||
totalCarbs += cat.CarbsGPer100G * scale;
|
||||
hasAny = true;
|
||||
}
|
||||
|
||||
if (!hasAny) return false;
|
||||
|
||||
if (overwrite || !recipe.Calories.HasValue) recipe.Calories = totalCalories;
|
||||
if (overwrite || !recipe.Protein.HasValue) recipe.Protein = Math.Round(totalProtein, 2);
|
||||
if (overwrite || !recipe.Fat.HasValue) recipe.Fat = Math.Round(totalFat, 2);
|
||||
if (overwrite || !recipe.Carbs.HasValue) recipe.Carbs = Math.Round(totalCarbs, 2);
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool HasNonMacroUnit(string? unit)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(unit)) return false;
|
||||
var u = unit.Trim().ToLowerInvariant();
|
||||
return u is "do smaku" or "optional" or "opcjonalnie" or "szczypta";
|
||||
}
|
||||
|
||||
private static List<RecipeStep> MapSteps(IEnumerable<CreateRecipeStepDto> items) =>
|
||||
items.Select(item => new RecipeStep
|
||||
{
|
||||
StepNumber = item.StepNumber,
|
||||
Description = item.Description.Trim(),
|
||||
}).ToList();
|
||||
}
|
||||
@@ -16,7 +16,7 @@ public class RecipeService : IRecipeService
|
||||
public RecipeService(AppDbContext db) => _db = db;
|
||||
|
||||
public async Task<IReadOnlyList<RecipeListItemDto>> GetRecipesAsync(
|
||||
int? category, string? search, CancellationToken ct = default)
|
||||
int? category, string? search, string? ingredient, CancellationToken ct = default)
|
||||
{
|
||||
var query = _db.Recipes.AsNoTracking().Where(r => r.IsActive);
|
||||
|
||||
@@ -31,6 +31,13 @@ public class RecipeService : IRecipeService
|
||||
query = query.Where(r => EF.Functions.ILike(r.Name, $"%{term}%"));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(ingredient))
|
||||
{
|
||||
var ingredientTerm = ingredient.Trim();
|
||||
query = query.Where(r =>
|
||||
r.Ingredients.Any(i => EF.Functions.ILike(i.Name, $"%{ingredientTerm}%")));
|
||||
}
|
||||
|
||||
return await query
|
||||
.OrderBy(r => r.Name)
|
||||
.Select(r => new RecipeListItemDto
|
||||
@@ -69,6 +76,7 @@ public class RecipeService : IRecipeService
|
||||
AmountGrams = i.AmountGrams,
|
||||
Unit = i.Unit,
|
||||
SortOrder = i.SortOrder,
|
||||
CatalogItemId = i.CatalogItemId,
|
||||
})
|
||||
.ToList(),
|
||||
Steps = r.Steps
|
||||
|
||||
36
MealPlan.Api/Validators/CreateRecipeValidator.cs
Normal file
36
MealPlan.Api/Validators/CreateRecipeValidator.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using FluentValidation;
|
||||
using MealPlan.Api.DTOs.Recipe;
|
||||
|
||||
namespace MealPlan.Api.Validators;
|
||||
|
||||
public class CreateRecipeValidator : AbstractValidator<CreateRecipeDto>
|
||||
{
|
||||
public CreateRecipeValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(255);
|
||||
RuleFor(x => x.MealCategory).InclusiveBetween(0, 3);
|
||||
RuleFor(x => x.Calories).GreaterThanOrEqualTo(0).When(x => x.Calories.HasValue);
|
||||
RuleFor(x => x.PrepTimeMinutes).GreaterThanOrEqualTo(0).When(x => x.PrepTimeMinutes.HasValue);
|
||||
RuleForEach(x => x.Ingredients).SetValidator(new CreateIngredientValidator());
|
||||
RuleForEach(x => x.Steps).SetValidator(new CreateRecipeStepValidator());
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateIngredientValidator : AbstractValidator<CreateIngredientDto>
|
||||
{
|
||||
public CreateIngredientValidator()
|
||||
{
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(255);
|
||||
RuleFor(x => x.Unit).MaximumLength(50).When(x => x.Unit != null);
|
||||
RuleFor(x => x.AmountGrams).GreaterThanOrEqualTo(0).When(x => x.AmountGrams.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateRecipeStepValidator : AbstractValidator<CreateRecipeStepDto>
|
||||
{
|
||||
public CreateRecipeStepValidator()
|
||||
{
|
||||
RuleFor(x => x.StepNumber).GreaterThan(0);
|
||||
RuleFor(x => x.Description).NotEmpty();
|
||||
}
|
||||
}
|
||||
64
MealPlan.Api/Validators/DietValidators.cs
Normal file
64
MealPlan.Api/Validators/DietValidators.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using FluentValidation;
|
||||
using MealPlan.Api.DTOs.Diet;
|
||||
|
||||
namespace MealPlan.Api.Validators;
|
||||
|
||||
public class UserDailyGoalsValidator : AbstractValidator<UserDailyGoalsDto>
|
||||
{
|
||||
public UserDailyGoalsValidator()
|
||||
{
|
||||
RuleFor(x => x.CalorieGoal).GreaterThanOrEqualTo(0).When(x => x.CalorieGoal.HasValue);
|
||||
RuleFor(x => x.ProteinGoalG).GreaterThanOrEqualTo(0).When(x => x.ProteinGoalG.HasValue);
|
||||
RuleFor(x => x.FatGoalG).GreaterThanOrEqualTo(0).When(x => x.FatGoalG.HasValue);
|
||||
RuleFor(x => x.CarbsGoalG).GreaterThanOrEqualTo(0).When(x => x.CarbsGoalG.HasValue);
|
||||
RuleFor(x => x.WaterGoalMl).GreaterThan(0).When(x => x.WaterGoalMl.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
public class LogMealValidator : AbstractValidator<LogMealDto>
|
||||
{
|
||||
public LogMealValidator()
|
||||
{
|
||||
RuleFor(x => x.MealCategory).InclusiveBetween(0, 4);
|
||||
RuleFor(x => x.Portions).GreaterThan(0);
|
||||
RuleFor(x => x.Name).MaximumLength(255).When(x => x.Name != null);
|
||||
RuleFor(x => x)
|
||||
.Must(x => x.RecipeId.HasValue || (x.CatalogItemId.HasValue && x.Grams is > 0) || !string.IsNullOrWhiteSpace(x.Name))
|
||||
.WithMessage("Provide recipeId, catalogItemId with grams, or name.");
|
||||
RuleFor(x => x.Grams).GreaterThan(0).When(x => x.CatalogItemId.HasValue);
|
||||
RuleFor(x => x.Calories).GreaterThanOrEqualTo(0).When(x => x.Calories.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
public class LogDrinkValidator : AbstractValidator<LogDrinkDto>
|
||||
{
|
||||
public LogDrinkValidator()
|
||||
{
|
||||
RuleFor(x => x.VolumeMl).GreaterThan(0);
|
||||
RuleFor(x => x.Name).MaximumLength(255).When(x => x.Name != null);
|
||||
RuleFor(x => x)
|
||||
.Must(x => x.DrinkCatalogId.HasValue || !string.IsNullOrWhiteSpace(x.Name))
|
||||
.WithMessage("Provide either drinkCatalogId or name.");
|
||||
RuleFor(x => x.Calories).GreaterThanOrEqualTo(0).When(x => x.Calories.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
public class CreateDietReminderValidator : AbstractValidator<CreateDietReminderDto>
|
||||
{
|
||||
public CreateDietReminderValidator()
|
||||
{
|
||||
RuleFor(x => x.ReminderType).InclusiveBetween(0, 3);
|
||||
RuleFor(x => x.Title).NotEmpty().MaximumLength(120);
|
||||
RuleFor(x => x.Message).MaximumLength(500).When(x => x.Message != null);
|
||||
RuleFor(x => x.DaysOfWeekMask).InclusiveBetween(1, 127);
|
||||
RuleFor(x => x.MealCategory).InclusiveBetween(0, 4).When(x => x.MealCategory.HasValue);
|
||||
}
|
||||
}
|
||||
|
||||
public class UpdateDietReminderValidator : AbstractValidator<UpdateDietReminderDto>
|
||||
{
|
||||
public UpdateDietReminderValidator()
|
||||
{
|
||||
Include(new CreateDietReminderValidator());
|
||||
}
|
||||
}
|
||||
19
MealPlan.Api/Validators/IngredientCatalogValidators.cs
Normal file
19
MealPlan.Api/Validators/IngredientCatalogValidators.cs
Normal file
@@ -0,0 +1,19 @@
|
||||
using FluentValidation;
|
||||
using MealPlan.Api.DTOs.IngredientCatalog;
|
||||
|
||||
namespace MealPlan.Api.Validators;
|
||||
|
||||
public class UpsertIngredientNutritionItemValidator : AbstractValidator<UpsertIngredientNutritionItemDto>
|
||||
{
|
||||
public UpsertIngredientNutritionItemValidator()
|
||||
{
|
||||
RuleFor(x => x.CategoryId).GreaterThan(0);
|
||||
RuleFor(x => x.Name).NotEmpty().MaximumLength(255);
|
||||
RuleFor(x => x.NamePl).MaximumLength(255).When(x => x.NamePl != null);
|
||||
RuleFor(x => x.CaloriesPer100G).GreaterThanOrEqualTo(0);
|
||||
RuleFor(x => x.ProteinGPer100G).GreaterThanOrEqualTo(0);
|
||||
RuleFor(x => x.FatGPer100G).GreaterThanOrEqualTo(0);
|
||||
RuleFor(x => x.CarbsGPer100G).GreaterThanOrEqualTo(0);
|
||||
RuleFor(x => x.FiberGPer100G).GreaterThanOrEqualTo(0).When(x => x.FiberGPer100G.HasValue);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=your-db-host;Port=5432;Database=mealplan;Username=user;Password=secret"
|
||||
},
|
||||
"OpenAI": {
|
||||
"ApiKey": "sk-your-openai-key",
|
||||
"Model": "gpt-4.1-mini",
|
||||
"MacrosModel": "gpt-4.1-mini",
|
||||
"PlanModel": "gpt-4.1-mini",
|
||||
"RecipeModel": "gpt-4.1",
|
||||
"MaxTokens": 4096
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,13 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
"AllowedHosts": "*",
|
||||
"OpenAI": {
|
||||
"ApiKey": "",
|
||||
"Model": "gpt-4.1-mini",
|
||||
"MacrosModel": "gpt-4.1-mini",
|
||||
"PlanModel": "gpt-4.1-mini",
|
||||
"RecipeModel": "gpt-4.1",
|
||||
"MaxTokens": 4096
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user