* 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;
|
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]
|
[HttpGet]
|
||||||
[ProducesResponseType(typeof(IReadOnlyList<RecipeListItemDto>), StatusCodes.Status200OK)]
|
[ProducesResponseType(typeof(IReadOnlyList<RecipeListItemDto>), StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> GetRecipes(
|
public async Task<IActionResult> GetRecipes(
|
||||||
[FromQuery] int? category,
|
[FromQuery] int? category,
|
||||||
[FromQuery] string? search,
|
[FromQuery] string? search,
|
||||||
|
[FromQuery] string? ingredient,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (category is < 0 or > 3)
|
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." });
|
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);
|
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 decimal? AmountGrams { get; set; }
|
||||||
public string? Unit { get; set; }
|
public string? Unit { get; set; }
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
public int? CatalogItemId { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public class RecipeStepDto
|
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<Ingredient> Ingredients => Set<Ingredient>();
|
||||||
public DbSet<RecipeStep> RecipeSteps => Set<RecipeStep>();
|
public DbSet<RecipeStep> RecipeSteps => Set<RecipeStep>();
|
||||||
public DbSet<ApplicationUser> Users => Set<ApplicationUser>();
|
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)
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
@@ -48,6 +65,10 @@ public class AppDbContext : DbContext
|
|||||||
e.HasKey(i => i.Id);
|
e.HasKey(i => i.Id);
|
||||||
e.Property(i => i.Id).UseIdentityByDefaultColumn();
|
e.Property(i => i.Id).UseIdentityByDefaultColumn();
|
||||||
e.Property(i => i.AmountGrams).HasColumnType("numeric(8,2)");
|
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 =>
|
modelBuilder.Entity<RecipeStep>(e =>
|
||||||
@@ -61,5 +82,180 @@ public class AppDbContext : DbContext
|
|||||||
e.HasKey(u => u.Id);
|
e.HasKey(u => u.Id);
|
||||||
e.Property(u => u.Id).HasColumnType("uuid");
|
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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="ClosedXML" Version="0.104.2" />
|
||||||
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
<PackageReference Include="FluentValidation.AspNetCore" Version="11.3.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.8" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity" Version="2.3.1" />
|
<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")]
|
[Column("SortOrder")]
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
[Column("CatalogItemId")]
|
||||||
|
public int? CatalogItemId { get; set; }
|
||||||
|
|
||||||
public Recipe? Recipe { 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")]
|
[Column("IsActive")]
|
||||||
public bool IsActive { get; set; }
|
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<Ingredient> Ingredients { get; set; } = new List<Ingredient>();
|
||||||
public ICollection<RecipeStep> Steps { get; set; } = new List<RecipeStep>();
|
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
|
// Application services
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
builder.Services.AddSingleton<IRefreshTokenStore, InMemoryRefreshTokenStore>();
|
builder.Services.AddSingleton<IRefreshTokenStore, DbRefreshTokenStore>();
|
||||||
builder.Services.AddHostedService<RefreshTokenCleanupService>();
|
builder.Services.AddHostedService<RefreshTokenCleanupService>();
|
||||||
builder.Services.AddSingleton<IPasswordHasher<ApplicationUser>, PasswordHasher<ApplicationUser>>();
|
builder.Services.AddSingleton<IPasswordHasher<ApplicationUser>, PasswordHasher<ApplicationUser>>();
|
||||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||||
builder.Services.AddScoped<IRecipeService, RecipeService>();
|
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)
|
// Authentication / authorization (JWT bearer)
|
||||||
@@ -163,6 +174,19 @@ builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);
|
|||||||
|
|
||||||
var app = builder.Build();
|
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
|
// 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
|
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);
|
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 RecipeService(AppDbContext db) => _db = db;
|
||||||
|
|
||||||
public async Task<IReadOnlyList<RecipeListItemDto>> GetRecipesAsync(
|
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);
|
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}%"));
|
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
|
return await query
|
||||||
.OrderBy(r => r.Name)
|
.OrderBy(r => r.Name)
|
||||||
.Select(r => new RecipeListItemDto
|
.Select(r => new RecipeListItemDto
|
||||||
@@ -69,6 +76,7 @@ public class RecipeService : IRecipeService
|
|||||||
AmountGrams = i.AmountGrams,
|
AmountGrams = i.AmountGrams,
|
||||||
Unit = i.Unit,
|
Unit = i.Unit,
|
||||||
SortOrder = i.SortOrder,
|
SortOrder = i.SortOrder,
|
||||||
|
CatalogItemId = i.CatalogItemId,
|
||||||
})
|
})
|
||||||
.ToList(),
|
.ToList(),
|
||||||
Steps = r.Steps
|
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": {
|
"ConnectionStrings": {
|
||||||
"DefaultConnection": "Host=your-db-host;Port=5432;Database=mealplan;Username=user;Password=secret"
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
README.md
31
README.md
@@ -106,8 +106,22 @@ compatible with hashes produced by ASP.NET Core Identity.
|
|||||||
```bash
|
```bash
|
||||||
cd DailyMeals # repository root (where DailyMeals.sln lives)
|
cd DailyMeals # repository root (where DailyMeals.sln lives)
|
||||||
./scripts/install-node.sh
|
./scripts/install-node.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**Angular frontend (recommended rewrite):**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd meal-plan-frontend-angular
|
||||||
|
npm install
|
||||||
|
npm start # http://localhost:4200, proxies /api → localhost:5000
|
||||||
|
```
|
||||||
|
|
||||||
|
**Or React frontend:**
|
||||||
|
|
||||||
|
```bash
|
||||||
cd meal-plan-frontend
|
cd meal-plan-frontend
|
||||||
../scripts/npm install
|
../scripts/npm install
|
||||||
|
../scripts/npm run dev # http://localhost:5173
|
||||||
```
|
```
|
||||||
|
|
||||||
4. In Rider, open the run configuration dropdown (top toolbar). You should see:
|
4. In Rider, open the run configuration dropdown (top toolbar). You should see:
|
||||||
@@ -155,9 +169,13 @@ All `/api/recipes` endpoints require a valid `Authorization: Bearer <accessToken
|
|||||||
| POST | `/api/auth/refresh` | Rotate refresh token → new token pair |
|
| POST | `/api/auth/refresh` | Rotate refresh token → new token pair |
|
||||||
| POST | `/api/auth/logout` | Revoke a refresh token |
|
| POST | `/api/auth/logout` | Revoke a refresh token |
|
||||||
| GET | `/api/auth/me` | Current user info |
|
| GET | `/api/auth/me` | Current user info |
|
||||||
| GET | `/api/recipes` | List recipes — `?category=0..3` and `?search=` filters|
|
| GET | `/api/recipes` | List recipes — `?category=`, `?search=`, `?ingredient=` |
|
||||||
| GET | `/api/recipes/{id}` | Full recipe detail (ingredients + steps) |
|
| GET | `/api/recipes/{id}` | Full recipe detail (ingredients + steps) |
|
||||||
| GET | `/api/recipes/categories` | Categories with active recipe counts |
|
| GET | `/api/recipes/categories` | Categories with active recipe counts |
|
||||||
|
| POST | `/api/recipes` | Create a recipe (ingredients + steps) |
|
||||||
|
| PUT | `/api/recipes/{id}` | Update a recipe |
|
||||||
|
| GET | `/api/recipes/import/template` | Download Excel import template (.xlsx) |
|
||||||
|
| POST | `/api/recipes/import/excel` | Import recipes from uploaded .xlsx file |
|
||||||
|
|
||||||
Meal categories: `0 = Breakfast`, `1 = SecondBreakfast`, `2 = Lunch`, `3 = Dinner`.
|
Meal categories: `0 = Breakfast`, `1 = SecondBreakfast`, `2 = Lunch`, `3 = Dinner`.
|
||||||
|
|
||||||
@@ -228,10 +246,19 @@ DailyMeals/
|
|||||||
│ ├── Middleware/ ExceptionMiddleware
|
│ ├── Middleware/ ExceptionMiddleware
|
||||||
│ ├── Program.cs Composition root / pipeline
|
│ ├── Program.cs Composition root / pipeline
|
||||||
│ └── Dockerfile
|
│ └── Dockerfile
|
||||||
├── meal-plan-frontend/ React + TypeScript SPA
|
├── meal-plan-frontend/ React + TypeScript SPA (original)
|
||||||
│ ├── src/ api, components, pages, hooks, store, types, utils
|
│ ├── src/ api, components, pages, hooks, store, types, utils
|
||||||
│ ├── nginx.conf TLS, gzip, security headers, /api proxy, SPA fallback
|
│ ├── nginx.conf TLS, gzip, security headers, /api proxy, SPA fallback
|
||||||
│ └── Dockerfile
|
│ └── Dockerfile
|
||||||
|
├── meal-plan-frontend-angular/ Angular 19 SPA (rewrite of React frontend)
|
||||||
|
│ ├── src/app/ core, layout, features, shared
|
||||||
|
│ ├── public/i18n/ EN + PL translations
|
||||||
|
│ ├── nginx.conf
|
||||||
|
│ └── Dockerfile
|
||||||
|
├── meal-plan-frontend-mobile/ Expo (React Native) iOS + Android app
|
||||||
|
│ ├── app/ Expo Router screens
|
||||||
|
│ ├── src/ api, context, i18n, theme, components
|
||||||
|
│ └── README.md Run with `npm start` (see mobile README)
|
||||||
├── docker-compose.yml API + frontend (no database container)
|
├── docker-compose.yml API + frontend (no database container)
|
||||||
└── .env.example
|
└── .env.example
|
||||||
```
|
```
|
||||||
|
|||||||
157
database/scripts/001_diet_tracking.sql
Normal file
157
database/scripts/001_diet_tracking.sql
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
-- DailyMeals diet tracking module
|
||||||
|
-- Apply manually on PostgreSQL (database-first; EF does not run migrations).
|
||||||
|
-- Schema: diet (same as existing recipe tables)
|
||||||
|
--
|
||||||
|
-- Inspired by patterns from MyFitnessPal / Lifesum (meal + macro logging),
|
||||||
|
-- WaterMinder / Hydro Coach (hydration goals + quick-add volumes), and
|
||||||
|
-- generic reminder schedules stored server-side for client push/local alarms.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Daily nutrition & hydration targets (one row per user)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS diet."UserDailyGoals" (
|
||||||
|
"UserId" uuid PRIMARY KEY
|
||||||
|
REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
|
||||||
|
"CalorieGoal" int NULL CHECK ("CalorieGoal" IS NULL OR "CalorieGoal" >= 0),
|
||||||
|
"ProteinGoalG" numeric(6,2) NULL CHECK ("ProteinGoalG" IS NULL OR "ProteinGoalG" >= 0),
|
||||||
|
"FatGoalG" numeric(6,2) NULL CHECK ("FatGoalG" IS NULL OR "FatGoalG" >= 0),
|
||||||
|
"CarbsGoalG" numeric(6,2) NULL CHECK ("CarbsGoalG" IS NULL OR "CarbsGoalG" >= 0),
|
||||||
|
"WaterGoalMl" int NULL CHECK ("WaterGoalMl" IS NULL OR "WaterGoalMl" > 0),
|
||||||
|
"CreatedAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"UpdatedAt" timestamptz NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE diet."UserDailyGoals" IS 'Per-user daily calorie, macro, and hydration targets (Lifesum-style goals).';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Drink catalog (reference data; quick-add presets like WaterMinder)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS diet."DrinkCatalog" (
|
||||||
|
"Id" serial PRIMARY KEY,
|
||||||
|
"Code" varchar(32) NOT NULL UNIQUE,
|
||||||
|
"Name" varchar(100) NOT NULL,
|
||||||
|
"DefaultVolumeMl" int NOT NULL CHECK ("DefaultVolumeMl" > 0),
|
||||||
|
"CaloriesPer100Ml" numeric(6,2) NULL CHECK ("CaloriesPer100Ml" IS NULL OR "CaloriesPer100Ml" >= 0),
|
||||||
|
"IconEmoji" varchar(16) NULL,
|
||||||
|
"SortOrder" int NOT NULL DEFAULT 0,
|
||||||
|
"IsActive" boolean NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE diet."DrinkCatalog" IS 'Preset drinks for one-tap logging (water, tea, coffee, etc.).';
|
||||||
|
|
||||||
|
INSERT INTO diet."DrinkCatalog" ("Code", "Name", "DefaultVolumeMl", "CaloriesPer100Ml", "IconEmoji", "SortOrder")
|
||||||
|
VALUES
|
||||||
|
('water', 'Water', 250, 0, '💧', 1),
|
||||||
|
('tea', 'Tea', 200, 1, '🍵', 2),
|
||||||
|
('coffee', 'Coffee', 150, 2, '☕', 3),
|
||||||
|
('juice', 'Juice', 200, 45, '🧃', 4),
|
||||||
|
('milk', 'Milk', 250, 42, '🥛', 5),
|
||||||
|
('soda', 'Soft drink', 330, 42, '🥤', 6),
|
||||||
|
('other', 'Other drink', 250, NULL, '🍶', 99)
|
||||||
|
ON CONFLICT ("Code") DO NOTHING;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Logged meals (MyFitnessPal-style diary entries; optional recipe link)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS diet."MealConsumptions" (
|
||||||
|
"Id" serial PRIMARY KEY,
|
||||||
|
"UserId" uuid NOT NULL
|
||||||
|
REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
|
||||||
|
"RecipeId" int NULL
|
||||||
|
REFERENCES diet."Recipes"("Id") ON DELETE SET NULL,
|
||||||
|
"MealCategory" int NOT NULL CHECK ("MealCategory" BETWEEN 0 AND 4),
|
||||||
|
"LogDate" date NOT NULL,
|
||||||
|
"ConsumedAt" timestamptz NOT NULL,
|
||||||
|
"Name" varchar(255) NOT NULL,
|
||||||
|
"Portions" numeric(4,2) NOT NULL DEFAULT 1 CHECK ("Portions" > 0),
|
||||||
|
"Calories" int NULL CHECK ("Calories" IS NULL OR "Calories" >= 0),
|
||||||
|
"ProteinG" numeric(6,2) NULL CHECK ("ProteinG" IS NULL OR "ProteinG" >= 0),
|
||||||
|
"FatG" numeric(6,2) NULL CHECK ("FatG" IS NULL OR "FatG" >= 0),
|
||||||
|
"CarbsG" numeric(6,2) NULL CHECK ("CarbsG" IS NULL OR "CarbsG" >= 0),
|
||||||
|
"Notes" text NULL,
|
||||||
|
"CreatedAt" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE diet."MealConsumptions" IS 'Meals the user ate; macros snapshotted at log time.';
|
||||||
|
COMMENT ON COLUMN diet."MealConsumptions"."MealCategory" IS '0=Breakfast, 1=SecondBreakfast, 2=Lunch, 3=Dinner, 4=Snack';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_MealConsumptions_UserId_LogDate"
|
||||||
|
ON diet."MealConsumptions" ("UserId", "LogDate" DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_MealConsumptions_UserId_ConsumedAt"
|
||||||
|
ON diet."MealConsumptions" ("UserId", "ConsumedAt" DESC);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Logged drinks (water tracker entries)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS diet."DrinkConsumptions" (
|
||||||
|
"Id" serial PRIMARY KEY,
|
||||||
|
"UserId" uuid NOT NULL
|
||||||
|
REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
|
||||||
|
"DrinkCatalogId" int NULL
|
||||||
|
REFERENCES diet."DrinkCatalog"("Id") ON DELETE SET NULL,
|
||||||
|
"LogDate" date NOT NULL,
|
||||||
|
"ConsumedAt" timestamptz NOT NULL,
|
||||||
|
"Name" varchar(255) NOT NULL,
|
||||||
|
"VolumeMl" int NOT NULL CHECK ("VolumeMl" > 0),
|
||||||
|
"Calories" int NULL CHECK ("Calories" IS NULL OR "Calories" >= 0),
|
||||||
|
"CreatedAt" timestamptz NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE diet."DrinkConsumptions" IS 'Hydration and beverage intake log.';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_DrinkConsumptions_UserId_LogDate"
|
||||||
|
ON diet."DrinkConsumptions" ("UserId", "LogDate" DESC);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Reminders (server-stored schedules; clients handle local notifications)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS diet."DietReminders" (
|
||||||
|
"Id" serial PRIMARY KEY,
|
||||||
|
"UserId" uuid NOT NULL
|
||||||
|
REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
|
||||||
|
"ReminderType" int NOT NULL CHECK ("ReminderType" BETWEEN 0 AND 3),
|
||||||
|
"Title" varchar(120) NOT NULL,
|
||||||
|
"Message" varchar(500) NULL,
|
||||||
|
"TimeOfDay" time NOT NULL,
|
||||||
|
"DaysOfWeekMask" int NOT NULL DEFAULT 127 CHECK ("DaysOfWeekMask" BETWEEN 1 AND 127),
|
||||||
|
"MealCategory" int NULL CHECK ("MealCategory" IS NULL OR "MealCategory" BETWEEN 0 AND 4),
|
||||||
|
"IsEnabled" boolean NOT NULL DEFAULT true,
|
||||||
|
"CreatedAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"UpdatedAt" timestamptz NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE diet."DietReminders" IS 'Meal/water/custom reminder schedules (bitmask: Sun=1, Mon=2, Tue=4, Wed=8, Thu=16, Fri=32, Sat=64).';
|
||||||
|
COMMENT ON COLUMN diet."DietReminders"."ReminderType" IS '0=Water, 1=Meal, 2=Snack, 3=Custom';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_DietReminders_UserId_IsEnabled"
|
||||||
|
ON diet."DietReminders" ("UserId", "IsEnabled");
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Optional views for reporting (API computes the same in LINQ)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE OR REPLACE VIEW diet."v_DailyMealTotals" AS
|
||||||
|
SELECT
|
||||||
|
"UserId",
|
||||||
|
"LogDate",
|
||||||
|
COALESCE(SUM("Calories"), 0)::int AS "TotalCalories",
|
||||||
|
COALESCE(SUM("ProteinG"), 0) AS "TotalProteinG",
|
||||||
|
COALESCE(SUM("FatG"), 0) AS "TotalFatG",
|
||||||
|
COALESCE(SUM("CarbsG"), 0) AS "TotalCarbsG",
|
||||||
|
COUNT(*)::int AS "MealCount"
|
||||||
|
FROM diet."MealConsumptions"
|
||||||
|
GROUP BY "UserId", "LogDate";
|
||||||
|
|
||||||
|
CREATE OR REPLACE VIEW diet."v_DailyDrinkTotals" AS
|
||||||
|
SELECT
|
||||||
|
"UserId",
|
||||||
|
"LogDate",
|
||||||
|
COALESCE(SUM("VolumeMl"), 0)::int AS "TotalWaterMl",
|
||||||
|
COALESCE(SUM("Calories"), 0)::int AS "TotalDrinkCalories",
|
||||||
|
COUNT(*)::int AS "DrinkCount"
|
||||||
|
FROM diet."DrinkConsumptions"
|
||||||
|
GROUP BY "UserId", "LogDate";
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
144
database/scripts/002_ingredient_nutrition_catalog.sql
Normal file
144
database/scripts/002_ingredient_nutrition_catalog.sql
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
-- DailyMeals ingredient nutrition catalog
|
||||||
|
-- Reference data: macros per 100 g, grouped by food category (meat, vegetables, etc.)
|
||||||
|
-- Apply manually: psql "$DATABASE_URL" -f database/scripts/002_ingredient_nutrition_catalog.sql
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS diet."IngredientCategories" (
|
||||||
|
"Id" serial PRIMARY KEY,
|
||||||
|
"Code" varchar(32) NOT NULL UNIQUE,
|
||||||
|
"Name" varchar(100) NOT NULL,
|
||||||
|
"SortOrder" int NOT NULL DEFAULT 0,
|
||||||
|
"IsActive" boolean NOT NULL DEFAULT true
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE diet."IngredientCategories" IS 'Food groups for the nutrition catalog (meat, vegetables, etc.).';
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS diet."IngredientNutritionCatalog" (
|
||||||
|
"Id" serial PRIMARY KEY,
|
||||||
|
"CategoryId" int NOT NULL
|
||||||
|
REFERENCES diet."IngredientCategories"("Id") ON DELETE RESTRICT,
|
||||||
|
"Name" varchar(255) NOT NULL,
|
||||||
|
"CaloriesPer100G" int NOT NULL CHECK ("CaloriesPer100G" >= 0),
|
||||||
|
"ProteinGPer100G" numeric(6,2) NOT NULL DEFAULT 0 CHECK ("ProteinGPer100G" >= 0),
|
||||||
|
"FatGPer100G" numeric(6,2) NOT NULL DEFAULT 0 CHECK ("FatGPer100G" >= 0),
|
||||||
|
"CarbsGPer100G" numeric(6,2) NOT NULL DEFAULT 0 CHECK ("CarbsGPer100G" >= 0),
|
||||||
|
"FiberGPer100G" numeric(6,2) NULL CHECK ("FiberGPer100G" IS NULL OR "FiberGPer100G" >= 0),
|
||||||
|
"SortOrder" int NOT NULL DEFAULT 0,
|
||||||
|
"IsActive" boolean NOT NULL DEFAULT true,
|
||||||
|
CONSTRAINT "UQ_IngredientNutritionCatalog_Category_Name" UNIQUE ("CategoryId", "Name")
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON TABLE diet."IngredientNutritionCatalog" IS 'Ingredient nutrition values per 100 g (calories, protein, fat, carbs).';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_IngredientNutritionCatalog_CategoryId"
|
||||||
|
ON diet."IngredientNutritionCatalog" ("CategoryId", "SortOrder", "Name");
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_IngredientNutritionCatalog_Name"
|
||||||
|
ON diet."IngredientNutritionCatalog" (lower("Name"));
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientCategories" ("Code", "Name", "SortOrder")
|
||||||
|
VALUES
|
||||||
|
('meat', 'Meat', 1),
|
||||||
|
('poultry', 'Poultry', 2),
|
||||||
|
('fish', 'Fish', 3),
|
||||||
|
('seafood', 'Seafood', 4),
|
||||||
|
('vegetables', 'Vegetables', 5),
|
||||||
|
('fruits', 'Fruits', 6),
|
||||||
|
('dairy', 'Dairy', 7),
|
||||||
|
('eggs', 'Eggs', 8),
|
||||||
|
('grains', 'Grains', 9),
|
||||||
|
('legumes', 'Legumes', 10),
|
||||||
|
('nuts', 'Nuts & seeds', 11),
|
||||||
|
('oils', 'Oils & fats', 12)
|
||||||
|
ON CONFLICT ("Code") DO NOTHING;
|
||||||
|
|
||||||
|
-- Helper: insert items per category code
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", v."Name", v."Calories", v."Protein", v."Fat", v."Carbs", v."Fiber", v."SortOrder"
|
||||||
|
FROM (VALUES
|
||||||
|
-- meat
|
||||||
|
('meat', 'Beef sirloin', 271, 25.80, 18.00, 0.00, NULL, 1),
|
||||||
|
('meat', 'Pork loin', 242, 27.30, 14.00, 0.00, NULL, 2),
|
||||||
|
('meat', 'Lamb leg', 258, 25.60, 16.50, 0.00, NULL, 3),
|
||||||
|
('meat', 'Ground beef (15% fat)', 250, 26.00, 15.00, 0.00, NULL, 4),
|
||||||
|
('meat', 'Ham (cooked)', 145, 21.00, 6.00, 1.50, NULL, 5),
|
||||||
|
-- poultry
|
||||||
|
('poultry', 'Chicken breast', 165, 31.00, 3.60, 0.00, NULL, 1),
|
||||||
|
('poultry', 'Chicken thigh', 209, 26.00, 10.90, 0.00, NULL, 2),
|
||||||
|
('poultry', 'Turkey breast', 135, 30.00, 1.00, 0.00, NULL, 3),
|
||||||
|
('poultry', 'Duck breast', 202, 23.50, 11.20, 0.00, NULL, 4),
|
||||||
|
-- fish
|
||||||
|
('fish', 'Salmon', 208, 20.40, 13.40, 0.00, NULL, 1),
|
||||||
|
('fish', 'Cod', 82, 17.80, 0.70, 0.00, NULL, 2),
|
||||||
|
('fish', 'Tuna (canned in water)', 116, 25.50, 0.80, 0.00, NULL, 3),
|
||||||
|
('fish', 'Trout', 148, 20.80, 6.60, 0.00, NULL, 4),
|
||||||
|
('fish', 'Mackerel', 205, 18.60, 13.90, 0.00, NULL, 5),
|
||||||
|
-- seafood
|
||||||
|
('seafood', 'Shrimp', 99, 24.00, 0.30, 0.20, NULL, 1),
|
||||||
|
('seafood', 'Mussels', 172, 23.80, 4.50, 7.40, NULL, 2),
|
||||||
|
('seafood', 'Squid', 92, 15.60, 1.40, 3.10, NULL, 3),
|
||||||
|
('seafood', 'Crab', 97, 19.40, 1.50, 0.00, NULL, 4),
|
||||||
|
-- vegetables
|
||||||
|
('vegetables', 'Tomato', 18, 0.90, 0.20, 3.90, 1.20, 1),
|
||||||
|
('vegetables', 'Carrot', 41, 0.90, 0.20, 9.60, 2.80, 2),
|
||||||
|
('vegetables', 'Broccoli', 34, 2.80, 0.40, 6.60, 2.60, 3),
|
||||||
|
('vegetables', 'Spinach', 23, 2.90, 0.40, 3.60, 2.20, 4),
|
||||||
|
('vegetables', 'Potato', 77, 2.00, 0.10, 17.50, 2.20, 5),
|
||||||
|
('vegetables', 'Cucumber', 15, 0.70, 0.10, 3.60, 0.50, 6),
|
||||||
|
('vegetables', 'Bell pepper', 31, 1.00, 0.30, 6.00, 2.10, 7),
|
||||||
|
('vegetables', 'Onion', 40, 1.10, 0.10, 9.30, 1.70, 8),
|
||||||
|
('vegetables', 'Zucchini', 17, 1.20, 0.30, 3.10, 1.00, 9),
|
||||||
|
('vegetables', 'Mushrooms', 22, 3.10, 0.30, 3.30, 1.00, 10),
|
||||||
|
-- fruits
|
||||||
|
('fruits', 'Apple', 52, 0.30, 0.20, 13.80, 2.40, 1),
|
||||||
|
('fruits', 'Banana', 89, 1.10, 0.30, 22.80, 2.60, 2),
|
||||||
|
('fruits', 'Orange', 47, 0.90, 0.10, 11.80, 2.40, 3),
|
||||||
|
('fruits', 'Strawberries', 32, 0.70, 0.30, 7.70, 2.00, 4),
|
||||||
|
('fruits', 'Blueberries', 57, 0.70, 0.30, 14.50, 2.40, 5),
|
||||||
|
('fruits', 'Grapes', 69, 0.70, 0.20, 18.10, 0.90, 6),
|
||||||
|
('fruits', 'Avocado', 160, 2.00, 14.70, 8.50, 6.70, 7),
|
||||||
|
-- dairy
|
||||||
|
('dairy', 'Whole milk', 61, 3.20, 3.30, 4.80, NULL, 1),
|
||||||
|
('dairy', 'Skim milk', 34, 3.40, 0.10, 5.00, NULL, 2),
|
||||||
|
('dairy', 'Greek yogurt (plain)', 59, 10.00, 0.40, 3.60, NULL, 3),
|
||||||
|
('dairy', 'Cheddar cheese', 403, 25.00, 33.00, 1.30, NULL, 4),
|
||||||
|
('dairy', 'Mozzarella', 280, 28.00, 17.00, 3.10, NULL, 5),
|
||||||
|
('dairy', 'Cottage cheese', 98, 11.10, 4.30, 3.40, NULL, 6),
|
||||||
|
-- eggs
|
||||||
|
('eggs', 'Chicken egg (whole)', 155, 12.60, 10.60, 1.10, NULL, 1),
|
||||||
|
('eggs', 'Egg white', 52, 10.90, 0.20, 0.70, NULL, 2),
|
||||||
|
('eggs', 'Egg yolk', 322, 15.90, 26.50, 3.60, NULL, 3),
|
||||||
|
-- grains
|
||||||
|
('grains', 'White rice (cooked)', 130, 2.70, 0.30, 28.20, 0.40, 1),
|
||||||
|
('grains', 'Brown rice (cooked)', 123, 2.70, 1.00, 25.60, 1.60, 2),
|
||||||
|
('grains', 'Pasta (cooked)', 131, 5.00, 1.10, 25.00, 1.80, 3),
|
||||||
|
('grains', 'Oats (dry)', 389, 16.90, 6.90, 66.30, 10.60, 4),
|
||||||
|
('grains', 'Whole wheat bread', 247, 13.00, 3.40, 41.00, 6.00, 5),
|
||||||
|
('grains', 'White bread', 265, 9.00, 3.20, 49.00, 2.70, 6),
|
||||||
|
('grains', 'Quinoa (cooked)', 120, 4.40, 1.90, 21.30, 2.80, 7),
|
||||||
|
-- legumes
|
||||||
|
('legumes', 'Lentils (cooked)', 116, 9.00, 0.40, 20.10, 7.90, 1),
|
||||||
|
('legumes', 'Chickpeas (cooked)', 164, 8.90, 2.60, 27.40, 7.60, 2),
|
||||||
|
('legumes', 'Black beans (cooked)', 132, 8.90, 0.50, 23.70, 8.70, 3),
|
||||||
|
('legumes', 'Kidney beans (cooked)', 127, 8.70, 0.50, 22.80, 6.40, 4),
|
||||||
|
('legumes', 'Green peas (cooked)', 84, 5.40, 0.20, 15.60, 5.50, 5),
|
||||||
|
('legumes', 'Tofu (firm)', 76, 8.10, 4.80, 1.90, 0.30, 6),
|
||||||
|
-- nuts
|
||||||
|
('nuts', 'Almonds', 579, 21.20, 49.90, 21.60, 12.50, 1),
|
||||||
|
('nuts', 'Walnuts', 654, 15.20, 65.20, 13.70, 6.70, 2),
|
||||||
|
('nuts', 'Peanuts', 567, 25.80, 49.20, 16.10, 8.50, 3),
|
||||||
|
('nuts', 'Cashews', 553, 18.20, 43.80, 30.20, 3.30, 4),
|
||||||
|
('nuts', 'Sunflower seeds', 584, 20.80, 51.50, 20.00, 8.60, 5),
|
||||||
|
('nuts', 'Chia seeds', 486, 16.50, 30.70, 42.10, 34.40, 6),
|
||||||
|
-- oils
|
||||||
|
('oils', 'Olive oil', 884, 0.00, 100.00, 0.00, NULL, 1),
|
||||||
|
('oils', 'Butter', 717, 0.90, 81.00, 0.10, NULL, 2),
|
||||||
|
('oils', 'Coconut oil', 862, 0.00, 100.00, 0.00, NULL, 3),
|
||||||
|
('oils', 'Rapeseed oil', 884, 0.00, 100.00, 0.00, NULL, 4)
|
||||||
|
) AS v("CategoryCode", "Name", "Calories", "Protein", "Fat", "Carbs", "Fiber", "SortOrder")
|
||||||
|
JOIN diet."IngredientCategories" c ON c."Code" = v."CategoryCode"
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
83
database/scripts/003_ingredient_catalog_polish_names.sql
Normal file
83
database/scripts/003_ingredient_catalog_polish_names.sql
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
-- Add Polish display names to the ingredient nutrition catalog.
|
||||||
|
-- Safe to re-run. Apply after 002_ingredient_nutrition_catalog.sql:
|
||||||
|
-- psql "$DATABASE_URL" -f database/scripts/003_ingredient_catalog_polish_names.sql
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE diet."IngredientNutritionCatalog"
|
||||||
|
ADD COLUMN IF NOT EXISTS "NamePl" varchar(255) NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN diet."IngredientNutritionCatalog"."NamePl" IS 'Polish display name for the ingredient.';
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_IngredientNutritionCatalog_NamePl"
|
||||||
|
ON diet."IngredientNutritionCatalog" (lower("NamePl"));
|
||||||
|
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Polędwica wołowa' WHERE "Name" = 'Beef sirloin';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Schab wieprzowy' WHERE "Name" = 'Pork loin';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Udziec jagnięcy' WHERE "Name" = 'Lamb leg';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Mięso mielone wołowe (15% tł.)' WHERE "Name" = 'Ground beef (15% fat)';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Szynka (gotowana)' WHERE "Name" = 'Ham (cooked)';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Pierś z kurczaka' WHERE "Name" = 'Chicken breast';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Udo z kurczaka' WHERE "Name" = 'Chicken thigh';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Pierś z indyka' WHERE "Name" = 'Turkey breast';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Pierś z kaczki' WHERE "Name" = 'Duck breast';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Łosoś' WHERE "Name" = 'Salmon';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Dorsz' WHERE "Name" = 'Cod';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Tuńczyk (w wodzie)' WHERE "Name" = 'Tuna (canned in water)';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Pstrąg' WHERE "Name" = 'Trout';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Makrela' WHERE "Name" = 'Mackerel';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Krewetki' WHERE "Name" = 'Shrimp';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Małże' WHERE "Name" = 'Mussels';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Kałamarnica' WHERE "Name" = 'Squid';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Krab' WHERE "Name" = 'Crab';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Pomidor' WHERE "Name" = 'Tomato';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Marchew' WHERE "Name" = 'Carrot';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Brokuły' WHERE "Name" = 'Broccoli';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Szpinak' WHERE "Name" = 'Spinach';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Ziemniak' WHERE "Name" = 'Potato';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Ogórek' WHERE "Name" = 'Cucumber';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Papryka' WHERE "Name" = 'Bell pepper';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Cebula' WHERE "Name" = 'Onion';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Cukinia' WHERE "Name" = 'Zucchini';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Pieczarki' WHERE "Name" = 'Mushrooms';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Jabłko' WHERE "Name" = 'Apple';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Banan' WHERE "Name" = 'Banana';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Pomarańcza' WHERE "Name" = 'Orange';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Truskawki' WHERE "Name" = 'Strawberries';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Borówki' WHERE "Name" = 'Blueberries';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Winogrona' WHERE "Name" = 'Grapes';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Awokado' WHERE "Name" = 'Avocado';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Mleko pełne' WHERE "Name" = 'Whole milk';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Mleko odtłuszczone' WHERE "Name" = 'Skim milk';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Jogurt grecki (naturalny)' WHERE "Name" = 'Greek yogurt (plain)';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Ser cheddar' WHERE "Name" = 'Cheddar cheese';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Mozzarella' WHERE "Name" = 'Mozzarella';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Twaróg' WHERE "Name" = 'Cottage cheese';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Jajko kurze (całe)' WHERE "Name" = 'Chicken egg (whole)';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Białko jaja' WHERE "Name" = 'Egg white';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Żółtko jaja' WHERE "Name" = 'Egg yolk';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Ryż biały (gotowany)' WHERE "Name" = 'White rice (cooked)';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Ryż brązowy (gotowany)' WHERE "Name" = 'Brown rice (cooked)';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Makaron (gotowany)' WHERE "Name" = 'Pasta (cooked)';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Płatki owsiane (suche)' WHERE "Name" = 'Oats (dry)';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Chleb pełnoziarnisty' WHERE "Name" = 'Whole wheat bread';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Chleb biały' WHERE "Name" = 'White bread';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Komosa ryżowa (gotowana)' WHERE "Name" = 'Quinoa (cooked)';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Soczewica (gotowana)' WHERE "Name" = 'Lentils (cooked)';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Ciecierzyca (gotowana)' WHERE "Name" = 'Chickpeas (cooked)';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Czarna fasola (gotowana)' WHERE "Name" = 'Black beans (cooked)';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Fasola czerwona (gotowana)' WHERE "Name" = 'Kidney beans (cooked)';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Groszek (gotowany)' WHERE "Name" = 'Green peas (cooked)';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Tofu (twarde)' WHERE "Name" = 'Tofu (firm)';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Migdały' WHERE "Name" = 'Almonds';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Orzechy włoskie' WHERE "Name" = 'Walnuts';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Orzeszki ziemne' WHERE "Name" = 'Peanuts';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Orzechy nerkowca' WHERE "Name" = 'Cashews';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Nasiona słonecznika' WHERE "Name" = 'Sunflower seeds';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Nasiona chia' WHERE "Name" = 'Chia seeds';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Oliwa z oliwek' WHERE "Name" = 'Olive oil';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Masło' WHERE "Name" = 'Butter';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Olej kokosowy' WHERE "Name" = 'Coconut oil';
|
||||||
|
UPDATE diet."IngredientNutritionCatalog" SET "NamePl" = 'Olej rzepakowy' WHERE "Name" = 'Rapeseed oil';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
62
database/scripts/004_extended_features.sql
Normal file
62
database/scripts/004_extended_features.sql
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
-- Extended features: refresh tokens, meal planner, favorites, recipe-catalog link
|
||||||
|
-- Apply after 001–003. Schema: diet
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Refresh tokens (persist sessions across API restarts)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS diet."RefreshTokens" (
|
||||||
|
"Token" varchar(128) PRIMARY KEY,
|
||||||
|
"UserId" uuid NOT NULL REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
|
||||||
|
"ExpiresAtUtc" timestamptz NOT NULL,
|
||||||
|
"CreatedAtUtc" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"IsRevoked" boolean NOT NULL DEFAULT false
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_RefreshTokens_UserId" ON diet."RefreshTokens" ("UserId");
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_RefreshTokens_ExpiresAtUtc" ON diet."RefreshTokens" ("ExpiresAtUtc");
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Weekly meal plan (one recipe per meal slot per day)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS diet."MealPlanEntries" (
|
||||||
|
"Id" serial PRIMARY KEY,
|
||||||
|
"UserId" uuid NOT NULL REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
|
||||||
|
"PlanDate" date NOT NULL,
|
||||||
|
"MealCategory" int NOT NULL CHECK ("MealCategory" BETWEEN 0 AND 4),
|
||||||
|
"RecipeId" int NOT NULL REFERENCES diet."Recipes"("Id") ON DELETE CASCADE,
|
||||||
|
"Portions" numeric(4,2) NOT NULL DEFAULT 1 CHECK ("Portions" > 0),
|
||||||
|
"CreatedAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"UpdatedAt" timestamptz NULL,
|
||||||
|
UNIQUE ("UserId", "PlanDate", "MealCategory")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_MealPlanEntries_UserId_PlanDate"
|
||||||
|
ON diet."MealPlanEntries" ("UserId", "PlanDate");
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Favorites (quick logging)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS diet."UserFavoriteRecipes" (
|
||||||
|
"UserId" uuid NOT NULL REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
|
||||||
|
"RecipeId" int NOT NULL REFERENCES diet."Recipes"("Id") ON DELETE CASCADE,
|
||||||
|
"CreatedAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY ("UserId", "RecipeId")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS diet."UserFavoriteCatalogItems" (
|
||||||
|
"UserId" uuid NOT NULL REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
|
||||||
|
"CatalogItemId" int NOT NULL REFERENCES diet."IngredientNutritionCatalog"("Id") ON DELETE CASCADE,
|
||||||
|
"CreatedAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY ("UserId", "CatalogItemId")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Link recipe ingredients to nutrition catalog (auto macros)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
ALTER TABLE diet."Ingredients"
|
||||||
|
ADD COLUMN IF NOT EXISTS "CatalogItemId" int NULL
|
||||||
|
REFERENCES diet."IngredientNutritionCatalog"("Id") ON DELETE SET NULL;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,928 @@
|
|||||||
|
-- 005_catalog_expansion_and_ingredient_linking.sql
|
||||||
|
-- Expands nutrition catalog, links recipe ingredients, recalculates recipe macros.
|
||||||
|
-- Coverage estimate: ~1160/1360 ingredient rows linkable; spice-skip 58; unmapped names 26 (mostly spice mixes)
|
||||||
|
-- Apply after scripts 001-004.
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- 1. Add missing catalog items
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Milk 2%', 'Mleko 2%', 50, 3.4, 2.0, 4.8, NULL, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'dairy'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Feta cheese', 'Ser feta', 264, 14.0, 21.0, 4.0, NULL, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'dairy'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Ricotta', 'Ricotta', 174, 11.0, 13.0, 3.0, NULL, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'dairy'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Parmesan', 'Parmezan', 431, 38.0, 29.0, 4.1, NULL, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'dairy'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Sour cream 12%', 'Śmietana 12%', 130, 2.7, 12.0, 3.0, NULL, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'dairy'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Rye bread whole grain', 'Chleb żytni razowy', 217, 8.5, 1.8, 42.0, 5.5, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Pasta whole grain dry', 'Makaron pełnoziarnisty (suchy)', 348, 12.0, 2.0, 72.0, 3.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'White rice dry', 'Ryż biały (suchy)', 360, 7.0, 0.6, 79.0, 1.3, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Brown rice dry', 'Ryż brązowy (suchy)', 362, 7.5, 2.7, 76.0, 3.4, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Buckwheat groats dry', 'Kasza gryczana (sucha)', 343, 13.0, 3.4, 72.0, 10.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Bulgur dry', 'Kasza bulgur (sucha)', 342, 12.0, 1.3, 76.0, 12.5, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Couscous dry', 'Kuskus (suchy)', 376, 13.0, 0.6, 77.0, 5.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Whole wheat tortilla', 'Tortilla pełnoziarnista', 298, 9.0, 7.0, 48.0, 6.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Wheat flour type 1850', 'Mąka pszenna typ 1850', 340, 10.0, 1.0, 72.0, 2.7, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Breadcrumbs whole grain', 'Bułka tarta pełnoziarnista', 395, 13.0, 5.0, 72.0, 4.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Green beans', 'Fasolka szparagowa', 31, 1.8, 0.2, 7.0, 2.7, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Eggplant', 'Bakłażan', 25, 1.0, 0.2, 6.0, 3.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Radish', 'Rzodkiewka', 16, 0.7, 0.1, 3.4, 1.6, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Lettuce mix', 'Sałata / mix sałat', 15, 1.4, 0.2, 2.9, 1.3, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Leek', 'Por', 61, 1.5, 0.3, 14.0, 1.8, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Celery root', 'Seler korzeniowy', 42, 1.5, 0.3, 9.0, 1.8, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Celery stalk', 'Seler naciowy', 16, 0.7, 0.2, 3.0, 1.6, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Cauliflower', 'Kalafior', 25, 1.9, 0.3, 5.0, 2.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Parsley fresh', 'Pietruszka / natka', 36, 3.0, 0.8, 6.3, 3.3, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Chives', 'Szczypiorek', 30, 3.3, 0.7, 4.4, 2.5, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Dill fresh', 'Koper ogrodowy', 43, 3.5, 1.1, 7.0, 2.1, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Capers', 'Kapary', 23, 2.4, 0.9, 4.9, 3.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Olives black', 'Oliwki czarne', 115, 0.8, 10.7, 6.0, 3.2, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Olives green', 'Oliwki zielone', 145, 1.0, 15.0, 3.8, 3.3, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Garlic', 'Czosnek', 149, 6.4, 0.5, 33.0, 2.1, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Sweet corn canned', 'Kukurydza konserwowa', 86, 2.5, 1.0, 19.0, 2.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Raspberries', 'Maliny', 52, 1.2, 0.7, 12.0, 6.5, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Kiwi', 'Kiwi', 61, 1.1, 0.5, 15.0, 3.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Mango', 'Mango', 60, 0.8, 0.4, 15.0, 1.6, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Peach nectarine', 'Brzoskwinia / nektarynka', 44, 0.9, 0.3, 11.0, 1.5, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Lemon juice', 'Sok cytrynowy / cytryna', 22, 0.4, 0.2, 6.9, 0.3, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Pomegranate seeds', 'Pestki granatu', 83, 1.7, 1.2, 19.0, 4.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Dried apricots', 'Suszone morele', 241, 3.4, 0.5, 63.0, 7.3, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Dried figs', 'Suszone figi', 249, 3.3, 0.9, 63.9, 9.8, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Dried cranberries', 'Żurawina suszona', 308, 0.1, 1.4, 82.0, 5.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Honey', 'Miód', 304, 0.3, 0.0, 82.0, 0.2, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'nuts'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Tahini', 'Tahini', 595, 17.0, 54.0, 21.0, 9.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'nuts'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Hummus', 'Hummus', 166, 8.0, 9.6, 14.0, 6.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'legumes'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Pesto basil', 'Pesto bazyliowe', 420, 5.0, 40.0, 6.0, 2.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'oils'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Tomato passata', 'Passata / pomidory z puszki', 32, 1.5, 0.2, 5.0, 1.5, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Sun-dried tomatoes in oil', 'Suszone pomidory w oliwie', 258, 14.0, 3.0, 55.0, 12.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Jam strawberry low sugar', 'Dżem truskawkowy niskosłodzony', 180, 0.5, 0.1, 45.0, 1.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Ketchup', 'Keczup', 100, 1.0, 0.2, 25.0, 0.5, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Mustard dijon', 'Musztarda dijonska', 66, 4.0, 3.7, 5.0, 3.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Soy sauce', 'Sos sojowy', 53, 8.0, 0.0, 4.9, 0.8, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Carrot juice', 'Sok marchwiowy', 40, 0.9, 0.2, 9.6, 0.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'fruits'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Flaxseed oil', 'Olej lniany', 884, 0.0, 100.0, 0.0, NULL, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'oils'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Flax seeds ground', 'Siemię lniane', 534, 18.0, 42.0, 29.0, 27.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'nuts'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Pumpkin seeds', 'Pestki dyni', 559, 30.0, 49.0, 11.0, 6.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'nuts'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Pistachios', 'Pistacje', 562, 20.0, 45.0, 28.0, 10.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'nuts'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Pine nuts', 'Piniole', 673, 14.0, 68.0, 13.0, 3.7, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'nuts'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Coconut flakes', 'Wiórki kokosowe', 660, 7.0, 65.0, 24.0, 16.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'nuts'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Smoked salmon', 'Łosoś wędzony', 117, 18.0, 4.3, 0.0, NULL, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'fish'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Chicken liver', 'Wątróbka kurczaka', 167, 26.0, 6.5, 1.0, NULL, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'poultry'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Pork tenderloin', 'Polędwica wieprzowa', 143, 26.0, 3.5, 0.0, NULL, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'meat'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Beef stew meat', 'Wołowina gulaszowa', 250, 26.0, 15.0, 0.0, NULL, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'meat'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Beef strips', 'Wołowina (rostbef)', 271, 25.8, 18.0, 0.0, NULL, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'meat'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
-- Herbs, spices, and zero-calorie liquids (for remaining unmapped ingredient rows with grams)
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Water', 'Woda', 0, 0, 0, 0, NULL, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Basil fresh', 'Bazylia świeża', 23, 3.2, 0.6, 2.7, 1.6, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Basil dried', 'Bazylia suszona', 251, 14.4, 4.1, 47.8, 37.7, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Oregano dried', 'Oregano suszone', 265, 9.0, 4.3, 68.9, 42.5, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Thyme dried', 'Tymianek suszony', 276, 9.1, 7.4, 63.9, 37.0, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Rosemary dried', 'Rozmaryn suszony', 331, 4.9, 15.2, 64.1, 42.6, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Cinnamon ground', 'Cynamon mielony', 247, 4.0, 1.2, 80.6, 53.1, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Ginger ground', 'Imbir mielony', 335, 9.0, 4.2, 71.6, 14.1, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Coriander dried', 'Kolendra suszona', 298, 12.4, 17.8, 54.9, 41.9, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Marjoram dried', 'Majeranek suszony', 271, 12.7, 7.0, 60.6, 40.3, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Black pepper ground', 'Pieprz czarny mielony', 251, 10.4, 3.3, 63.9, 25.3, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'vegetables'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
INSERT INTO diet."IngredientNutritionCatalog"
|
||||||
|
("CategoryId", "Name", "NamePl", "CaloriesPer100G", "ProteinGPer100G", "FatGPer100G", "CarbsGPer100G", "FiberGPer100G", "SortOrder")
|
||||||
|
SELECT c."Id", 'Cocoa powder 16% fat', 'Kakao 16% proszek', 228, 19.6, 13.7, 57.9, 33.2, 100
|
||||||
|
FROM diet."IngredientCategories" c WHERE c."Code" = 'grains'
|
||||||
|
ON CONFLICT ("CategoryId", "Name") DO UPDATE SET
|
||||||
|
"NamePl" = EXCLUDED."NamePl", "CaloriesPer100G" = EXCLUDED."CaloriesPer100G",
|
||||||
|
"ProteinGPer100G" = EXCLUDED."ProteinGPer100G", "FatGPer100G" = EXCLUDED."FatGPer100G",
|
||||||
|
"CarbsGPer100G" = EXCLUDED."CarbsGPer100G", "FiberGPer100G" = EXCLUDED."FiberGPer100G", "IsActive" = true;
|
||||||
|
|
||||||
|
CREATE TEMP TABLE _ingredient_name_map (pattern text PRIMARY KEY, catalog_name text NOT NULL);
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Awokado', 'Avocado') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Bakłażan', 'Eggplant') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Bazylia suszona', 'Basil dried') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Bazylia świeża', 'Basil fresh') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Banan', 'Banana') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Bataty', 'Potato') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Borówki amerykańskie', 'Blueberries') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Brokuł', 'Broccoli') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Brokuły', 'Broccoli') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Brzoskwinia', 'Peach nectarine') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Brzoskwinia (świeża lub mrożona)', 'Peach nectarine') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Bułka tarta pełnoziarnista', 'Breadcrumbs whole grain') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Cebula', 'Onion') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Cebula czerwona', 'Onion') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Chleb pita pełnoziarnisty', 'Whole wheat bread') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Chleb żytni razowy', 'Rye bread whole grain') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Ciecierzyca z puszki', 'Chickpeas (cooked)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Ciecierzyca z puszki (odsączona)', 'Chickpeas (cooked)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Cukinia', 'Zucchini') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Cynamon', 'Cinnamon ground') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Cytryna', 'Lemon juice') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Cytryna (sok)', 'Lemon juice') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Czosnek', 'Garlic') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Czosnek granulowany', 'Garlic') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Czosnek, sól, pieprz', 'Garlic') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Czosnek, sół, pieprz', 'Garlic') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Czosnek, tymianek, sól, pieprz', 'Garlic') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Dorsz świeży filety bez skóry', 'Cod') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Dynia pestki łuskane', 'Pumpkin seeds') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Dżem truskawkowy niskosłodzony', 'Strawberries') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Fasola czerwona w zalewie (konserwowa)', 'Kidney beans (cooked)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Fasola szparagowa', 'Green beans') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Fasolka szparagowa', 'Green beans') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Fasolka szparagowa mrożona', 'Green beans') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Filet z dorsza lub dorada', 'Cod') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Filet z piersi indyka', 'Turkey breast') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Filet z piersi indyka (rozbite plastry)', 'Turkey breast') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Filet z łososia', 'Salmon') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Gruszka', 'Peach nectarine') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Hummus', 'Hummus') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Imbir mielony', 'Ginger ground') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Jabłko', 'Apple') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Jaja kurze całe', 'Chicken egg (whole)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Jaja kurze całe (na twardo)', 'Chicken egg (whole)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Jajko', 'Chicken egg (whole)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Jajko (do klopsików)', 'Chicken egg (whole)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Jajko (do panierki)', 'Chicken egg (whole)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Jajko ugotowane na twardo', 'Chicken egg (whole)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Jogurt grecki 2%', 'Greek yogurt (plain)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Jogurt naturalny', 'Greek yogurt (plain)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Kakao 16% proszek', 'Cocoa powder 16% fat') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Kabanosy z kurczaka', 'Chicken breast') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Kalafior', 'Cauliflower') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Kapary', 'Capers') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Kasza bulgur', 'Bulgur dry') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Kasza gryczana', 'Buckwheat groats dry') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Kasza jaglana', 'Buckwheat groats dry') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Keczup', 'Ketchup') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Kiełbasa chorizo', 'Ham (cooked)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Kiełki brokuła', 'Broccoli') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Kiwi', 'Kiwi') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Koncentrat pomidorowy 30%', 'Tomato passata') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Kolendra suszone liście', 'Coriander dried') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Koper ogrodowy', 'Dill fresh') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Kukurydza konserwowa', 'Sweet corn canned') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Kuskus pełnoziarnisty', 'Couscous dry') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Len mielony', 'Flax seeds ground') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Len nasiona', 'Flax seeds ground') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Len, nasiona', 'Flax seeds ground') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Makaron fusilli pełnoziarnisty', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Makaron penne pełnoziarnisty', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Makaron pełnoziarnisty', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Makaron pełnoziarnisty (fusilli)', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Makaron pełnoziarnisty (penne lub spaghetti)', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Makaron pełnoziarnisty (tagliatelle lub penne)', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Makaron rigatoni pełnoziarnisty', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Makaron spaghetti pełnoziarnisty', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Makaron tagliatelle pełnoziarnisty', 'Pasta whole grain dry') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Majeranek', 'Marjoram dried') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Maliny', 'Raspberries') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Maliny (świeże lub mrożone)', 'Raspberries') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Maliny świeże lub mrożone', 'Raspberries') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Mango (świeże lub mrożone)', 'Mango') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Marchew', 'Carrot') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Marchewka', 'Carrot') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Masło', 'Butter') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Masło ekstra', 'Butter') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Masło orzechowe', 'Butter') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Migdały', 'Almonds') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Migdały w płatkach', 'Almonds') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Mix sałat', 'Lettuce mix') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Miód', 'Honey') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Miód pszczeli', 'Honey') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Mięso wołowe (świeżo mielone z rostbefu)', 'Ground beef (15% fat)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Mięso wołowe mielone', 'Ground beef (15% fat)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Mięso z piersi kurczaka bez skóry', 'Chicken breast') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Mięso z podudzia indyka bez skóry', 'Turkey breast') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Mleko 2%', 'Milk 2%') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Mleko spożywcze 2%', 'Milk 2%') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Morszczuk świeży', 'Cod') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Musztarda dijonska', 'Mustard dijon') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Mąka pszenna (do obtoczenia)', 'Wheat flour type 1850') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Mąka pszenna typ 1850', 'Wheat flour type 1850') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Nasiona chia', 'Chia seeds') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Natka pietruszki', 'Parsley fresh') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Nektarynka', 'Peach nectarine') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Noga (udo) kurczaka', 'Chicken breast') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Ocet jabłkowy z dojrzałych jabłek', 'Apple') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Olej lniany tłoczony na zimno', 'Flaxseed oil') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Oliwa z oliwek', 'Olive oil') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Oliwki czarne', 'Olives black') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Oliwki czarne i zielone (mix)', 'Olives black') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Oliwki zielone', 'Olives green') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Oliwki zielone marynowane', 'Olives green') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Oregano suszone', 'Oregano dried') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Oregano, sok cytryny, sól, pieprz', 'Lemon juice') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Oregano, tymianek, czosnek granulowany, sól', 'Garlic') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Orzechy nerkowca', 'Cashews') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Orzechy nerkowca (bez soli)', 'Cashews') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Orzechy włoskie', 'Walnuts') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Papryka czerwona', 'Bell pepper') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Parmezan tarty', 'Parmesan') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Parmezan w płatkach', 'Parmesan') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Passata pomidorowa', 'Tomato passata') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Passata pomidorowa (przecier)', 'Tomato passata') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Passata pomidorowa przecier', 'Tomato passata') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pestki granatu', 'Pomegranate seeds') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pesto bazyliowe', 'Pesto basil') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pieczarka uprawna świeża', 'Mushrooms') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pieczarki świeże', 'Mushrooms') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pieprz czarny mielony', 'Black pepper ground') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pierś z indyka', 'Turkey breast') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pierś z kurczaka', 'Chicken breast') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pierś z kurczaka (rozbita cienko)', 'Chicken breast') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pierś z kurczaka (ugotowana lub z poprzedniego dnia)', 'Chicken breast') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pierś z kurczaka (z poprzedniego dnia lub świeża)', 'Chicken breast') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pietruszka korzeń', 'Celery root') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pietruszka liście', 'Parsley fresh') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pietruszka, liście', 'Parsley fresh') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Piniole', 'Pine nuts') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pistacje (łuskane)', 'Pistachios') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pistacje łuskane', 'Pistachios') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Polędwica sopocka', 'Ham (cooked)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Polędwica wieprzowa', 'Pork tenderloin') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Polędwica wieprzowa surowa', 'Pork tenderloin') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pomarańcza', 'Orange') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pomidor', 'Tomato') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pomidory cherry', 'Tomato') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pomidory koktajlowe', 'Tomato') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pomidory z puszki', 'Tomato passata') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Pomidory z puszki (krojone)', 'Tomato passata') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Por', 'Leek') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Płatki owsiane', 'Oats (dry)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Ricotta', 'Ricotta') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Rozmaryn', 'Rosemary dried') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Rukola', 'Lettuce mix') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Ryż basmati', 'White rice dry') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Ryż brązowy', 'Brown rice dry') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Ryż jaśminowy', 'White rice dry') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Rzodkiewka', 'Radish') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Salami', 'Ham (cooked)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Salami (cienkie plastry)', 'Ham (cooked)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Sałata', 'Lettuce mix') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Sałata lub mix sałat', 'Lettuce mix') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Seler korzeniowy', 'Celery root') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Seler naciowy', 'Celery stalk') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Ser feta', 'Feta cheese') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Ser gouda tłusty', 'Parmesan') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Ser mozzarella', 'Mozzarella') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Ser parmezan (tarty)', 'Parmesan') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Ser parmezan tarty', 'Parmesan') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Ser twarogowy półtłusty', 'Cottage cheese') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Ser typu feta', 'Feta cheese') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Serek wiejski naturalny', 'Greek yogurt (plain)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Siemię lniane mielone', 'Flax seeds ground') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Siemię lniane świeżo mielone', 'Flax seeds ground') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Soczewica zielona', 'Lentils (cooked)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Soczewica zielona (ugotowana)', 'Lentils (cooked)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Sok cytrynowy', 'Lemon juice') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Sok marchwiowy', 'Carrot juice') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Sos sojowy', 'Soy sauce') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Sos sojowy ciemny', 'Soy sauce') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Suszone figi', 'Dried figs') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Suszone morele', 'Dried apricots') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Suszone pomidory (odsączone)', 'Sun-dried tomatoes in oil') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Suszone pomidory (w oleju z ziołami, odsączone)', 'Sun-dried tomatoes in oil') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Suszone pomidory w oliwie (odsączone)', 'Sun-dried tomatoes in oil') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Szczypiorek', 'Chives') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Szpinak', 'Spinach') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Szpinak świeży', 'Spinach') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Szynka parmeńska', 'Ham (cooked)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Szynka parmeńska (Prosciutto)', 'Ham (cooked)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Szynka parmeńska (prosciutto)', 'Ham (cooked)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Szynka z indyka', 'Turkey breast') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Szynka z indyka (bez konserwantów)', 'Turkey breast') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Słonecznik nasiona łuskane', 'Pumpkin seeds') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Tahini', 'Tahini') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Tortilla pełnoziarnista', 'Whole wheat tortilla') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Truskawki', 'Strawberries') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Truskawki (świeże lub mrożone)', 'Strawberries') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Tuńczyk w wodzie (odsączony)', 'Tuna (canned in water)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Twaróg półtłusty', 'Cottage cheese') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Tymianek', 'Thyme dried') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Tymianek, czosnek granulowany, sól', 'Garlic') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Tymianek, czosnek granulowany, sól, pieprz', 'Garlic') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Udka kurczaka (bez skóry)', 'Chicken breast') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Udka kurczaka bez skóry', 'Chicken breast') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Wiórki kokosowe', 'Coconut flakes') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Woda', 'Water') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Wołowina (rostbef lub ligawa, cienkie paski)', 'Beef stew meat') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Wołowina gulaszowa', 'Beef stew meat') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Wołowina gulaszowa (karkówka/łopatka)', 'Beef stew meat') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Wołowina mielona', 'Ground beef (15% fat)') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Wątróbka kurczaka', 'Chicken breast') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Ziemniaki', 'Potato') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Łosoś wędzony', 'Smoked salmon') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Śmietana 12%', 'Sour cream 12%') ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO _ingredient_name_map VALUES ('Żurawina suszona', 'Dried cranberries') ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
UPDATE diet."Ingredients" i
|
||||||
|
SET "CatalogItemId" = c."Id"
|
||||||
|
FROM _ingredient_name_map m
|
||||||
|
JOIN diet."IngredientNutritionCatalog" c ON c."Name" = m.catalog_name AND c."IsActive" = true
|
||||||
|
WHERE i."Name" = m.pattern AND i."AmountGrams" IS NOT NULL AND i."AmountGrams" > 0;
|
||||||
|
|
||||||
|
WITH totals AS (
|
||||||
|
SELECT i."RecipeId",
|
||||||
|
ROUND(SUM(c."CaloriesPer100G" * i."AmountGrams" / 100.0))::int AS calories,
|
||||||
|
ROUND(SUM(c."ProteinGPer100G" * i."AmountGrams" / 100.0), 2) AS protein,
|
||||||
|
ROUND(SUM(c."FatGPer100G" * i."AmountGrams" / 100.0), 2) AS fat,
|
||||||
|
ROUND(SUM(c."CarbsGPer100G" * i."AmountGrams" / 100.0), 2) AS carbs
|
||||||
|
FROM diet."Ingredients" i
|
||||||
|
JOIN diet."IngredientNutritionCatalog" c ON c."Id" = i."CatalogItemId"
|
||||||
|
WHERE i."AmountGrams" IS NOT NULL AND i."AmountGrams" > 0
|
||||||
|
GROUP BY i."RecipeId"
|
||||||
|
)
|
||||||
|
UPDATE diet."Recipes" r SET
|
||||||
|
"Calories" = t.calories, "Protein" = t.protein, "Fat" = t.fat, "Carbs" = t.carbs, "UpdatedAt" = now()
|
||||||
|
FROM totals t WHERE r."Id" = t."RecipeId" AND r."IsActive" = true;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE unlinked int; no_macros int;
|
||||||
|
BEGIN
|
||||||
|
SELECT COUNT(*) INTO unlinked FROM diet."Ingredients"
|
||||||
|
WHERE "AmountGrams" > 0 AND "CatalogItemId" IS NULL AND COALESCE("Unit", '') <> 'do smaku';
|
||||||
|
SELECT COUNT(*) INTO no_macros FROM diet."Recipes" WHERE "IsActive" AND ("Protein" IS NULL OR "Calories" IS NULL);
|
||||||
|
RAISE NOTICE 'Unlinked ingredient rows (grams, non-spice): %', unlinked;
|
||||||
|
RAISE NOTICE 'Recipes still missing macros: %', no_macros;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- Reference mapping: database/scripts/005_ingredient_name_mapping.csv
|
||||||
279
database/scripts/005_ingredient_name_mapping.csv
Normal file
279
database/scripts/005_ingredient_name_mapping.csv
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
IngredientName,CatalogName,Status
|
||||||
|
Awokado,Avocado,mapped
|
||||||
|
Bakłażan,Eggplant,mapped
|
||||||
|
Banan,Banana,mapped
|
||||||
|
Bataty,Potato,mapped
|
||||||
|
Bazylia suszona,Basil dried,mapped
|
||||||
|
"Bazylia suszona, oregano, płatki chili, sół, pieprz",,skip_spice
|
||||||
|
Bazylia świeża,Basil fresh,mapped
|
||||||
|
"Bazylia świeża lub suszona, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
"Bazylia świeża, pieprz",UNMAPPED,unmapped
|
||||||
|
"Bazylia świeża, pieprz czarny",UNMAPPED,unmapped
|
||||||
|
"Bazylia, oregano, pieprz",UNMAPPED,unmapped
|
||||||
|
"Bazylia, oregano, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
"Bazylia, rozmaryn, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
"Bazylia, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
Borówki amerykańskie,Blueberries,mapped
|
||||||
|
Brokuł,Broccoli,mapped
|
||||||
|
Brokuły,Broccoli,mapped
|
||||||
|
Brzoskwinia,Peach nectarine,mapped
|
||||||
|
Brzoskwinia (świeża lub mrożona),Peach nectarine,mapped
|
||||||
|
Bułka tarta pełnoziarnista,Breadcrumbs whole grain,mapped
|
||||||
|
Cebula,Onion,mapped
|
||||||
|
Cebula czerwona,Onion,mapped
|
||||||
|
Chleb pita pełnoziarnisty,Whole wheat bread,mapped
|
||||||
|
Chleb żytni razowy,Rye bread whole grain,mapped
|
||||||
|
Ciecierzyca z puszki,Chickpeas (cooked),mapped
|
||||||
|
Ciecierzyca z puszki (odsączona),Chickpeas (cooked),mapped
|
||||||
|
Cukinia,Zucchini,mapped
|
||||||
|
Cynamon,Cinnamon ground,mapped
|
||||||
|
Cytryna,Lemon juice,mapped
|
||||||
|
Cytryna (sok),Lemon juice,mapped
|
||||||
|
Czosnek,Garlic,mapped
|
||||||
|
Czosnek granulowany,Garlic,mapped
|
||||||
|
"Czosnek, sól, pieprz",Garlic,mapped
|
||||||
|
"Czosnek, sół, pieprz",Garlic,mapped
|
||||||
|
"Czosnek, tymianek, sól, pieprz",Garlic,mapped
|
||||||
|
Dorsz świeży filety bez skóry,Cod,mapped
|
||||||
|
Dynia pestki łuskane,Pumpkin seeds,mapped
|
||||||
|
Dżem truskawkowy niskosłodzony,Strawberries,mapped
|
||||||
|
Erytrol/Erytrytol,,skip_spice
|
||||||
|
Fasola czerwona w zalewie (konserwowa),Kidney beans (cooked),mapped
|
||||||
|
Fasola szparagowa,Green beans,mapped
|
||||||
|
Fasolka szparagowa,Green beans,mapped
|
||||||
|
Fasolka szparagowa mrożona,Green beans,mapped
|
||||||
|
Filet z dorsza lub dorada,Cod,mapped
|
||||||
|
Filet z piersi indyka,Turkey breast,mapped
|
||||||
|
Filet z piersi indyka (rozbite plastry),Turkey breast,mapped
|
||||||
|
Filet z łososia,Salmon,mapped
|
||||||
|
"Gałka muszkatołowa, sól, pieprz",,skip_spice
|
||||||
|
"Gałka muszkatołowa, sół, pieprz",,skip_spice
|
||||||
|
Gruszka,Peach nectarine,mapped
|
||||||
|
Hummus,Hummus,mapped
|
||||||
|
Imbir mielony,Ginger ground,mapped
|
||||||
|
Jabłko,Apple,mapped
|
||||||
|
Jaja kurze całe,Chicken egg (whole),mapped
|
||||||
|
Jaja kurze całe (na twardo),Chicken egg (whole),mapped
|
||||||
|
Jajko,Chicken egg (whole),mapped
|
||||||
|
Jajko (do klopsików),Chicken egg (whole),mapped
|
||||||
|
Jajko (do panierki),Chicken egg (whole),mapped
|
||||||
|
Jajko ugotowane na twardo,Chicken egg (whole),mapped
|
||||||
|
Jogurt grecki 2%,Greek yogurt (plain),mapped
|
||||||
|
Jogurt naturalny,Greek yogurt (plain),mapped
|
||||||
|
Kabanosy z kurczaka,Chicken breast,mapped
|
||||||
|
Kakao 16% proszek,Cocoa powder 16% fat,mapped
|
||||||
|
Kalafior,Cauliflower,mapped
|
||||||
|
Kapary,Capers,mapped
|
||||||
|
Kasza bulgur,Bulgur dry,mapped
|
||||||
|
Kasza gryczana,Buckwheat groats dry,mapped
|
||||||
|
Kasza jaglana,Buckwheat groats dry,mapped
|
||||||
|
Keczup,Ketchup,mapped
|
||||||
|
Kiełbasa chorizo,Ham (cooked),mapped
|
||||||
|
Kiełki brokuła,Broccoli,mapped
|
||||||
|
Kiwi,Kiwi,mapped
|
||||||
|
"Kminek mielony, papryka słodka, sól, pieprz",,skip_spice
|
||||||
|
"Kminek mielony, płatki chili, sól, pieprz",,skip_spice
|
||||||
|
"Kminek, papryka słodka, chili, sól",,skip_spice
|
||||||
|
Kolendra suszone liście,Coriander dried,mapped
|
||||||
|
Koncentrat pomidorowy 30%,Tomato passata,mapped
|
||||||
|
Koper ogrodowy,Dill fresh,mapped
|
||||||
|
Ksylitol,,skip_spice
|
||||||
|
Kukurydza konserwowa,Sweet corn canned,mapped
|
||||||
|
"Kurkuma, kminek, bazylia, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
"Kurkuma, kminek, cynamon, kolendra suszona, imbir mielony, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
"Kurkuma, kminek, cynamon, kolendra suszona, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
"Kurkuma, kminek, kolendra suszona, sól",UNMAPPED,unmapped
|
||||||
|
Kuskus pełnoziarnisty,Couscous dry,mapped
|
||||||
|
Len mielony,Flax seeds ground,mapped
|
||||||
|
Len nasiona,Flax seeds ground,mapped
|
||||||
|
"Len, nasiona",Flax seeds ground,mapped
|
||||||
|
Liść laurowy,,skip_spice
|
||||||
|
Majeranek,Marjoram dried,mapped
|
||||||
|
Makaron fusilli pełnoziarnisty,Pasta whole grain dry,mapped
|
||||||
|
Makaron penne pełnoziarnisty,Pasta whole grain dry,mapped
|
||||||
|
Makaron pełnoziarnisty,Pasta whole grain dry,mapped
|
||||||
|
Makaron pełnoziarnisty (fusilli),Pasta whole grain dry,mapped
|
||||||
|
Makaron pełnoziarnisty (penne lub spaghetti),Pasta whole grain dry,mapped
|
||||||
|
Makaron pełnoziarnisty (tagliatelle lub penne),Pasta whole grain dry,mapped
|
||||||
|
Makaron rigatoni pełnoziarnisty,Pasta whole grain dry,mapped
|
||||||
|
Makaron spaghetti pełnoziarnisty,Pasta whole grain dry,mapped
|
||||||
|
Makaron tagliatelle pełnoziarnisty,Pasta whole grain dry,mapped
|
||||||
|
Maliny,Raspberries,mapped
|
||||||
|
Maliny (świeże lub mrożone),Raspberries,mapped
|
||||||
|
Maliny świeże lub mrożone,Raspberries,mapped
|
||||||
|
Mango (świeże lub mrożone),Mango,mapped
|
||||||
|
Marchew,Carrot,mapped
|
||||||
|
Marchewka,Carrot,mapped
|
||||||
|
Masło,Butter,mapped
|
||||||
|
Masło ekstra,Butter,mapped
|
||||||
|
Masło orzechowe,Butter,mapped
|
||||||
|
Mielona gałka muszkatołowa,,skip_spice
|
||||||
|
Mielona papryka chili,,skip_spice
|
||||||
|
Mielona słodka papryka,,skip_spice
|
||||||
|
Mielony filet z indyka,,skip_spice
|
||||||
|
Mielony filet z piersi indyka (bez skóry),,skip_spice
|
||||||
|
Migdały,Almonds,mapped
|
||||||
|
Migdały w płatkach,Almonds,mapped
|
||||||
|
Mix sałat,Lettuce mix,mapped
|
||||||
|
Miód,Honey,mapped
|
||||||
|
Miód pszczeli,Honey,mapped
|
||||||
|
Mięso wołowe (świeżo mielone z rostbefu),Ground beef (15% fat),mapped
|
||||||
|
Mięso wołowe mielone,Ground beef (15% fat),mapped
|
||||||
|
Mięso z piersi kurczaka bez skóry,Chicken breast,mapped
|
||||||
|
Mięso z podudzia indyka bez skóry,Turkey breast,mapped
|
||||||
|
Mleko 2%,Milk 2%,mapped
|
||||||
|
Mleko spożywcze 2%,Milk 2%,mapped
|
||||||
|
Morszczuk świeży,Cod,mapped
|
||||||
|
Musztarda dijonska,Mustard dijon,mapped
|
||||||
|
Mąka pszenna (do obtoczenia),Wheat flour type 1850,mapped
|
||||||
|
Mąka pszenna typ 1850,Wheat flour type 1850,mapped
|
||||||
|
Nasiona chia,Chia seeds,mapped
|
||||||
|
Natka pietruszki,Parsley fresh,mapped
|
||||||
|
Nektarynka,Peach nectarine,mapped
|
||||||
|
Noga (udo) kurczaka,Chicken breast,mapped
|
||||||
|
Ocet jabłkowy z dojrzałych jabłek,Apple,mapped
|
||||||
|
"Ocet winny biały, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
Olej lniany tłoczony na zimno,Flaxseed oil,mapped
|
||||||
|
Oliwa z oliwek,Olive oil,mapped
|
||||||
|
Oliwki czarne,Olives black,mapped
|
||||||
|
Oliwki czarne i zielone (mix),Olives black,mapped
|
||||||
|
Oliwki zielone,Olives green,mapped
|
||||||
|
Oliwki zielone marynowane,Olives green,mapped
|
||||||
|
Oregano suszone,Oregano dried,mapped
|
||||||
|
"Oregano, bazylia suszona, płatki chili, sól, pieprz",,skip_spice
|
||||||
|
"Oregano, bazylia, sól",UNMAPPED,unmapped
|
||||||
|
"Oregano, bazylia, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
"Oregano, pieprz",UNMAPPED,unmapped
|
||||||
|
"Oregano, płatki chili, sól, pieprz",,skip_spice
|
||||||
|
"Oregano, sok cytryny, sól, pieprz",Lemon juice,mapped
|
||||||
|
"Oregano, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
"Oregano, tymianek, czosnek granulowany, sól",Garlic,mapped
|
||||||
|
"Oregano, tymianek, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
Orzechy nerkowca,Cashews,mapped
|
||||||
|
Orzechy nerkowca (bez soli),Cashews,mapped
|
||||||
|
Orzechy włoskie,Walnuts,mapped
|
||||||
|
Papryka czerwona,Bell pepper,mapped
|
||||||
|
"Papryka słodka, chili, oregano, sól, pieprz",,skip_spice
|
||||||
|
Parmezan tarty,Parmesan,mapped
|
||||||
|
Parmezan w płatkach,Parmesan,mapped
|
||||||
|
Passata pomidorowa,Tomato passata,mapped
|
||||||
|
Passata pomidorowa (przecier),Tomato passata,mapped
|
||||||
|
Passata pomidorowa przecier,Tomato passata,mapped
|
||||||
|
Pestki granatu,Pomegranate seeds,mapped
|
||||||
|
Pesto bazyliowe,Pesto basil,mapped
|
||||||
|
Pieczarka uprawna świeża,Mushrooms,mapped
|
||||||
|
Pieczarki świeże,Mushrooms,mapped
|
||||||
|
Pieprz czarny mielony,Black pepper ground,mapped
|
||||||
|
Pierś z indyka,Turkey breast,mapped
|
||||||
|
Pierś z kurczaka,Chicken breast,mapped
|
||||||
|
Pierś z kurczaka (rozbita cienko),Chicken breast,mapped
|
||||||
|
Pierś z kurczaka (ugotowana lub z poprzedniego dnia),Chicken breast,mapped
|
||||||
|
Pierś z kurczaka (z poprzedniego dnia lub świeża),Chicken breast,mapped
|
||||||
|
Pietruszka korzeń,Celery root,mapped
|
||||||
|
Pietruszka liście,Parsley fresh,mapped
|
||||||
|
"Pietruszka, liście",Parsley fresh,mapped
|
||||||
|
Piniole,Pine nuts,mapped
|
||||||
|
Pistacje (łuskane),Pistachios,mapped
|
||||||
|
Pistacje łuskane,Pistachios,mapped
|
||||||
|
Polędwica sopocka,Ham (cooked),mapped
|
||||||
|
Polędwica wieprzowa,Pork tenderloin,mapped
|
||||||
|
Polędwica wieprzowa surowa,Pork tenderloin,mapped
|
||||||
|
Pomarańcza,Orange,mapped
|
||||||
|
Pomidor,Tomato,mapped
|
||||||
|
Pomidory cherry,Tomato,mapped
|
||||||
|
Pomidory koktajlowe,Tomato,mapped
|
||||||
|
Pomidory z puszki,Tomato passata,mapped
|
||||||
|
Pomidory z puszki (krojone),Tomato passata,mapped
|
||||||
|
Por,Leek,mapped
|
||||||
|
Proszek do pieczenia,,skip_spice
|
||||||
|
Przyprawa do drobiu,,skip_spice
|
||||||
|
"Płatki chili, sól",,skip_spice
|
||||||
|
"Płatki chili, sól, pieprz",,skip_spice
|
||||||
|
Płatki owsiane,Oats (dry),mapped
|
||||||
|
Ricotta,Ricotta,mapped
|
||||||
|
Rozmaryn,Rosemary dried,mapped
|
||||||
|
"Rozmaryn, tymianek, bazylia, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
"Rozmaryn, tymianek, oregano, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
"Rozmaryn, tymianek, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
Rukola,Lettuce mix,mapped
|
||||||
|
Ryż basmati,White rice dry,mapped
|
||||||
|
Ryż brązowy,Brown rice dry,mapped
|
||||||
|
Ryż jaśminowy,White rice dry,mapped
|
||||||
|
Rzodkiewka,Radish,mapped
|
||||||
|
Salami,Ham (cooked),mapped
|
||||||
|
Salami (cienkie plastry),Ham (cooked),mapped
|
||||||
|
Sałata,Lettuce mix,mapped
|
||||||
|
Sałata lub mix sałat,Lettuce mix,mapped
|
||||||
|
Seler korzeniowy,Celery root,mapped
|
||||||
|
Seler naciowy,Celery stalk,mapped
|
||||||
|
Ser feta,Feta cheese,mapped
|
||||||
|
Ser gouda tłusty,Parmesan,mapped
|
||||||
|
Ser mozzarella,Mozzarella,mapped
|
||||||
|
Ser parmezan (tarty),Parmesan,mapped
|
||||||
|
Ser parmezan tarty,Parmesan,mapped
|
||||||
|
Ser twarogowy półtłusty,Cottage cheese,mapped
|
||||||
|
Ser typu feta,Feta cheese,mapped
|
||||||
|
Serek wiejski naturalny,Greek yogurt (plain),mapped
|
||||||
|
Siemię lniane mielone,Flax seeds ground,mapped
|
||||||
|
Siemię lniane świeżo mielone,Flax seeds ground,mapped
|
||||||
|
Soczewica zielona,Lentils (cooked),mapped
|
||||||
|
Soczewica zielona (ugotowana),Lentils (cooked),mapped
|
||||||
|
Sok cytrynowy,Lemon juice,mapped
|
||||||
|
Sok marchwiowy,Carrot juice,mapped
|
||||||
|
Sos sojowy,Soy sauce,mapped
|
||||||
|
Sos sojowy ciemny,Soy sauce,mapped
|
||||||
|
Suszone figi,Dried figs,mapped
|
||||||
|
Suszone morele,Dried apricots,mapped
|
||||||
|
Suszone pomidory (odsączone),Sun-dried tomatoes in oil,mapped
|
||||||
|
"Suszone pomidory (w oleju z ziołami, odsączone)",Sun-dried tomatoes in oil,mapped
|
||||||
|
Suszone pomidory w oliwie (odsączone),Sun-dried tomatoes in oil,mapped
|
||||||
|
Szczypiorek,Chives,mapped
|
||||||
|
Szpinak,Spinach,mapped
|
||||||
|
Szpinak świeży,Spinach,mapped
|
||||||
|
Szynka parmeńska,Ham (cooked),mapped
|
||||||
|
Szynka parmeńska (Prosciutto),Ham (cooked),mapped
|
||||||
|
Szynka parmeńska (prosciutto),Ham (cooked),mapped
|
||||||
|
Szynka z indyka,Turkey breast,mapped
|
||||||
|
Szynka z indyka (bez konserwantów),Turkey breast,mapped
|
||||||
|
Sól himalajska,,skip_spice
|
||||||
|
"Sól, pieprz",UNMAPPED,unmapped
|
||||||
|
"Sól, pieprz, oregano",UNMAPPED,unmapped
|
||||||
|
"Sól, pieprz, płatki chili",,skip_spice
|
||||||
|
"Sól, pieprz, zioła prowansalskie",,skip_spice
|
||||||
|
Sół himalajska,,skip_spice
|
||||||
|
"Sół, pieprz, płatki chili",,skip_spice
|
||||||
|
Słonecznik nasiona łuskane,Pumpkin seeds,mapped
|
||||||
|
Tahini,Tahini,mapped
|
||||||
|
Tortilla pełnoziarnista,Whole wheat tortilla,mapped
|
||||||
|
Truskawki,Strawberries,mapped
|
||||||
|
Truskawki (świeże lub mrożone),Strawberries,mapped
|
||||||
|
Tuńczyk w wodzie (odsączony),Tuna (canned in water),mapped
|
||||||
|
Twaróg półtłusty,Cottage cheese,mapped
|
||||||
|
Tymianek,Thyme dried,mapped
|
||||||
|
"Tymianek, czosnek granulowany, sól",Garlic,mapped
|
||||||
|
"Tymianek, czosnek granulowany, sól, pieprz",Garlic,mapped
|
||||||
|
"Tymianek, oregano, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
"Tymianek, rozmaryn, papryka słodka, sół, pieprz",,skip_spice
|
||||||
|
"Tymianek, rozmaryn, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
"Tymianek, rozmaryn, zioła prowansalskie, sól, pieprz",,skip_spice
|
||||||
|
"Tymianek, sól, pieprz",UNMAPPED,unmapped
|
||||||
|
"Tymianek, sół, pieprz",UNMAPPED,unmapped
|
||||||
|
Udka kurczaka (bez skóry),Chicken breast,mapped
|
||||||
|
Udka kurczaka bez skóry,Chicken breast,mapped
|
||||||
|
Wiórki kokosowe,Coconut flakes,mapped
|
||||||
|
Woda,Water,mapped
|
||||||
|
"Wołowina (rostbef lub ligawa, cienkie paski)",Beef stew meat,mapped
|
||||||
|
Wołowina gulaszowa,Beef stew meat,mapped
|
||||||
|
Wołowina gulaszowa (karkówka/łopatka),Beef stew meat,mapped
|
||||||
|
Wołowina mielona,Ground beef (15% fat),mapped
|
||||||
|
Wątróbka kurczaka,Chicken breast,mapped
|
||||||
|
Ziele angielskie,,skip_spice
|
||||||
|
Ziemniaki,Potato,mapped
|
||||||
|
Zioła prowansalskie,,skip_spice
|
||||||
|
"Zioła prowansalskie, rozmaryn, tymianek, sól, pieprz",,skip_spice
|
||||||
|
"Zioła prowansalskie, sól, pieprz",,skip_spice
|
||||||
|
"Zioła prowansalskie, tymianek, liść laurowy, sól, pieprz",,skip_spice
|
||||||
|
"Zioła prowansalskie, tymianek, sól, pieprz",,skip_spice
|
||||||
|
Łosoś wędzony,Smoked salmon,mapped
|
||||||
|
Śmietana 12%,Sour cream 12%,mapped
|
||||||
|
Żurawina suszona,Dried cranberries,mapped
|
||||||
|
110
database/scripts/006_diet_and_recipe_generators.sql
Normal file
110
database/scripts/006_diet_and_recipe_generators.sql
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
-- 006_diet_and_recipe_generators.sql
|
||||||
|
-- AI-assisted diet plan and recipe generation modules.
|
||||||
|
-- Apply after scripts 001–005.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- User profile for TDEE / goal-based planning
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS diet."DietUserProfiles" (
|
||||||
|
"UserId" uuid PRIMARY KEY
|
||||||
|
REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
|
||||||
|
"HeightCm" numeric(5,2) NOT NULL CHECK ("HeightCm" > 0),
|
||||||
|
"WeightKg" numeric(5,2) NOT NULL CHECK ("WeightKg" > 0),
|
||||||
|
"Age" int NOT NULL CHECK ("Age" BETWEEN 10 AND 120),
|
||||||
|
"Sex" varchar(16) NOT NULL CHECK ("Sex" IN ('male', 'female')),
|
||||||
|
"ActivityLevel" varchar(32) NOT NULL
|
||||||
|
CHECK ("ActivityLevel" IN ('sedentary', 'light', 'moderate', 'active', 'very_active')),
|
||||||
|
"Goal" varchar(32) NOT NULL
|
||||||
|
CHECK ("Goal" IN ('lose_weight', 'maintain', 'gain_muscle', 'recomp')),
|
||||||
|
"AllergiesNotes" text NULL,
|
||||||
|
"DislikedFoods" text NULL,
|
||||||
|
"MealsPerDayMask" int NOT NULL DEFAULT 15 CHECK ("MealsPerDayMask" BETWEEN 1 AND 31),
|
||||||
|
"CreatedAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"UpdatedAt" timestamptz NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
COMMENT ON COLUMN diet."DietUserProfiles"."MealsPerDayMask" IS 'Bitmask: bit0=Breakfast, bit1=SecondBreakfast, bit2=Lunch, bit3=Dinner, bit4=Snack';
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Diet generator wizard sessions
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS diet."DietGeneratorSessions" (
|
||||||
|
"Id" serial PRIMARY KEY,
|
||||||
|
"UserId" uuid NOT NULL REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
|
||||||
|
"Status" varchar(32) NOT NULL DEFAULT 'macros_pending',
|
||||||
|
"ProposedCalories" int NULL CHECK ("ProposedCalories" IS NULL OR "ProposedCalories" > 0),
|
||||||
|
"ProposedProteinG" numeric(6,2) NULL,
|
||||||
|
"ProposedFatG" numeric(6,2) NULL,
|
||||||
|
"ProposedCarbsG" numeric(6,2) NULL,
|
||||||
|
"MacroRationale" text NULL,
|
||||||
|
"MacrosAcceptedAt" timestamptz NULL,
|
||||||
|
"PlanStartDate" date NULL,
|
||||||
|
"PlanDayCount" int NOT NULL DEFAULT 7 CHECK ("PlanDayCount" BETWEEN 1 AND 28),
|
||||||
|
"CalorieToleranceKcal" int NOT NULL DEFAULT 10 CHECK ("CalorieToleranceKcal" >= 0),
|
||||||
|
"PreferencesJson" jsonb NULL,
|
||||||
|
"CommittedAt" timestamptz NULL,
|
||||||
|
"CreatedAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"UpdatedAt" timestamptz NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_DietGeneratorSessions_UserId_Status"
|
||||||
|
ON diet."DietGeneratorSessions" ("UserId", "Status");
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Draft meals (review before commit to MealPlanEntries)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS diet."DietGeneratorDraftMeals" (
|
||||||
|
"Id" serial PRIMARY KEY,
|
||||||
|
"SessionId" int NOT NULL REFERENCES diet."DietGeneratorSessions"("Id") ON DELETE CASCADE,
|
||||||
|
"PlanDate" date NOT NULL,
|
||||||
|
"MealCategory" int NOT NULL CHECK ("MealCategory" BETWEEN 0 AND 4),
|
||||||
|
"RecipeId" int NOT NULL REFERENCES diet."Recipes"("Id") ON DELETE CASCADE,
|
||||||
|
"Portions" numeric(4,2) NOT NULL DEFAULT 1 CHECK ("Portions" > 0),
|
||||||
|
"Calories" int NULL,
|
||||||
|
"ProteinG" numeric(6,2) NULL,
|
||||||
|
"FatG" numeric(6,2) NULL,
|
||||||
|
"CarbsG" numeric(6,2) NULL,
|
||||||
|
"IsLocked" boolean NOT NULL DEFAULT false,
|
||||||
|
"GenerationVersion" int NOT NULL DEFAULT 1,
|
||||||
|
UNIQUE ("SessionId", "PlanDate", "MealCategory")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_DietGeneratorDraftMeals_SessionId"
|
||||||
|
ON diet."DietGeneratorDraftMeals" ("SessionId");
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Recipe generator sessions (draft JSON until commit)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS diet."RecipeGeneratorSessions" (
|
||||||
|
"Id" serial PRIMARY KEY,
|
||||||
|
"UserId" uuid NOT NULL REFERENCES diet."AspNetUsers"("Id") ON DELETE CASCADE,
|
||||||
|
"Status" varchar(32) NOT NULL DEFAULT 'draft',
|
||||||
|
"ConstraintsJson" jsonb NOT NULL DEFAULT '{}',
|
||||||
|
"DraftJson" jsonb NULL,
|
||||||
|
"SavedRecipeId" int NULL REFERENCES diet."Recipes"("Id") ON DELETE SET NULL,
|
||||||
|
"GenerationVersion" int NOT NULL DEFAULT 1,
|
||||||
|
"CreatedAt" timestamptz NOT NULL DEFAULT now(),
|
||||||
|
"UpdatedAt" timestamptz NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_RecipeGeneratorSessions_UserId"
|
||||||
|
ON diet."RecipeGeneratorSessions" ("UserId");
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- Recipe provenance (AI vs manual)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
ALTER TABLE diet."Recipes"
|
||||||
|
ADD COLUMN IF NOT EXISTS "Source" varchar(32) NOT NULL DEFAULT 'manual';
|
||||||
|
|
||||||
|
ALTER TABLE diet."Recipes"
|
||||||
|
ADD COLUMN IF NOT EXISTS "CreatedByUserId" uuid NULL
|
||||||
|
REFERENCES diet."AspNetUsers"("Id") ON DELETE SET NULL;
|
||||||
|
|
||||||
|
ALTER TABLE diet."Recipes"
|
||||||
|
ADD COLUMN IF NOT EXISTS "IsDraft" boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN diet."Recipes"."Source" IS 'manual | excel | ai_generated';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
135
database/scripts/007_remove_duplicate_recipes_by_name.sql
Normal file
135
database/scripts/007_remove_duplicate_recipes_by_name.sql
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
-- 007_remove_duplicate_recipes_by_name.sql
|
||||||
|
-- Removes duplicate recipes that share the same "Name" (trimmed, exact match).
|
||||||
|
-- Keeps the row with the lowest "Id" for each name.
|
||||||
|
--
|
||||||
|
-- Analysis of Recipes-2.csv export (196 rows):
|
||||||
|
-- 168 unique names, 28 duplicate groups, 28 rows to delete (Ids 113–140).
|
||||||
|
-- Those duplicate Ids mirror Ids 29–56 one-to-one.
|
||||||
|
--
|
||||||
|
-- Run inside a transaction. Review the preview queries before COMMIT.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 1) Preview duplicates
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- SELECT
|
||||||
|
-- r."Name",
|
||||||
|
-- array_agg(r."Id" ORDER BY r."Id") AS ids,
|
||||||
|
-- min(r."Id") AS keep_id,
|
||||||
|
-- array_agg(r."Id" ORDER BY r."Id") FILTER (WHERE r."Id" <> min(r."Id")) AS remove_ids
|
||||||
|
-- FROM diet."Recipes" r
|
||||||
|
-- GROUP BY r."Name"
|
||||||
|
-- HAVING count(*) > 1
|
||||||
|
-- ORDER BY min(r."Id");
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 2) Build duplicate map: remove_id -> keep_id
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TEMP TABLE recipe_dedup_map ON COMMIT DROP AS
|
||||||
|
SELECT
|
||||||
|
r."Id" AS remove_id,
|
||||||
|
d.keep_id
|
||||||
|
FROM diet."Recipes" r
|
||||||
|
INNER JOIN (
|
||||||
|
SELECT btrim("Name") AS name_key, MIN("Id") AS keep_id
|
||||||
|
FROM diet."Recipes"
|
||||||
|
GROUP BY btrim("Name")
|
||||||
|
HAVING COUNT(*) > 1
|
||||||
|
) d ON btrim(r."Name") = d.name_key
|
||||||
|
WHERE r."Id" <> d.keep_id;
|
||||||
|
|
||||||
|
-- Optional: restrict to known duplicate ids from export
|
||||||
|
-- DELETE FROM recipe_dedup_map WHERE remove_id NOT BETWEEN 113 AND 140;
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
dup_count int;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO dup_count FROM recipe_dedup_map;
|
||||||
|
RAISE NOTICE 'Duplicate recipe rows to remove: %', dup_count;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 3) Re-point foreign keys to the canonical (lowest-id) recipe
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
-- Meal planner / diet generator drafts
|
||||||
|
UPDATE diet."MealPlanEntries" e
|
||||||
|
SET "RecipeId" = m.keep_id,
|
||||||
|
"UpdatedAt" = now()
|
||||||
|
FROM recipe_dedup_map m
|
||||||
|
WHERE e."RecipeId" = m.remove_id;
|
||||||
|
|
||||||
|
UPDATE diet."DietGeneratorDraftMeals" d
|
||||||
|
SET "RecipeId" = m.keep_id
|
||||||
|
FROM recipe_dedup_map m
|
||||||
|
WHERE d."RecipeId" = m.remove_id;
|
||||||
|
|
||||||
|
-- AI recipe generator saved draft pointer
|
||||||
|
UPDATE diet."RecipeGeneratorSessions" s
|
||||||
|
SET "SavedRecipeId" = m.keep_id,
|
||||||
|
"UpdatedAt" = now()
|
||||||
|
FROM recipe_dedup_map m
|
||||||
|
WHERE s."SavedRecipeId" = m.remove_id;
|
||||||
|
|
||||||
|
-- Diet tracking (nullable FK)
|
||||||
|
UPDATE diet."MealConsumptions" c
|
||||||
|
SET "RecipeId" = m.keep_id
|
||||||
|
FROM recipe_dedup_map m
|
||||||
|
WHERE c."RecipeId" = m.remove_id;
|
||||||
|
|
||||||
|
-- Favorites: drop rows that would collide after merge, then re-point the rest
|
||||||
|
DELETE FROM diet."UserFavoriteRecipes" f
|
||||||
|
USING recipe_dedup_map m
|
||||||
|
WHERE f."RecipeId" = m.remove_id
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM diet."UserFavoriteRecipes" f2
|
||||||
|
WHERE f2."UserId" = f."UserId"
|
||||||
|
AND f2."RecipeId" = m.keep_id
|
||||||
|
);
|
||||||
|
|
||||||
|
UPDATE diet."UserFavoriteRecipes" f
|
||||||
|
SET "RecipeId" = m.keep_id
|
||||||
|
FROM recipe_dedup_map m
|
||||||
|
WHERE f."RecipeId" = m.remove_id;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 4) Delete duplicate recipes (Ingredients + RecipeSteps cascade)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
DELETE FROM diet."Recipes" r
|
||||||
|
USING recipe_dedup_map m
|
||||||
|
WHERE r."Id" = m.remove_id;
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 5) Verify
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
remaining_dup_names int;
|
||||||
|
recipe_count int;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO remaining_dup_names
|
||||||
|
FROM (
|
||||||
|
SELECT btrim("Name")
|
||||||
|
FROM diet."Recipes"
|
||||||
|
GROUP BY btrim("Name")
|
||||||
|
HAVING count(*) > 1
|
||||||
|
) d;
|
||||||
|
|
||||||
|
SELECT count(*) INTO recipe_count FROM diet."Recipes";
|
||||||
|
|
||||||
|
IF remaining_dup_names > 0 THEN
|
||||||
|
RAISE EXCEPTION 'Duplicate recipe names still present: % groups', remaining_dup_names;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RAISE NOTICE 'Done. Active recipe count: %', recipe_count;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
|
|
||||||
|
-- Optional hardening (uncomment if every recipe name must be unique going forward):
|
||||||
|
-- CREATE UNIQUE INDEX IF NOT EXISTS "UX_Recipes_Name"
|
||||||
|
-- ON diet."Recipes" (btrim("Name"))
|
||||||
|
-- WHERE "IsActive" AND NOT "IsDraft";
|
||||||
25
database/scripts/008_diet_draft_meal_ingredients.sql
Normal file
25
database/scripts/008_diet_draft_meal_ingredients.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
-- 008_diet_draft_meal_ingredients.sql
|
||||||
|
-- Scaled ingredient amounts per diet generator draft meal (portions applied at plan time).
|
||||||
|
-- Apply after script 006.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS diet."DietGeneratorDraftMealIngredients" (
|
||||||
|
"Id" serial PRIMARY KEY,
|
||||||
|
"DraftMealId" int NOT NULL
|
||||||
|
REFERENCES diet."DietGeneratorDraftMeals"("Id") ON DELETE CASCADE,
|
||||||
|
"SourceIngredientId" int NULL
|
||||||
|
REFERENCES diet."Ingredients"("Id") ON DELETE SET NULL,
|
||||||
|
"Name" varchar(255) NOT NULL,
|
||||||
|
"AmountGrams" numeric(8,2) NULL,
|
||||||
|
"Unit" varchar(50) NULL,
|
||||||
|
"SortOrder" int NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "IX_DietGeneratorDraftMealIngredients_DraftMealId"
|
||||||
|
ON diet."DietGeneratorDraftMealIngredients" ("DraftMealId");
|
||||||
|
|
||||||
|
COMMENT ON TABLE diet."DietGeneratorDraftMealIngredients" IS
|
||||||
|
'Ingredient weights recalculated for the planned portion size of each draft meal.';
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
27
database/scripts/009_fix_identity_sequences.sql
Normal file
27
database/scripts/009_fix_identity_sequences.sql
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
-- Resync PostgreSQL identity sequences after bulk imports or manual ID inserts.
|
||||||
|
-- Safe to run multiple times. Run against the modwad database when new rows fail with:
|
||||||
|
-- duplicate key value violates unique constraint "Recipes_pkey"
|
||||||
|
|
||||||
|
SELECT setval(
|
||||||
|
pg_get_serial_sequence('diet."Recipes"', 'Id'),
|
||||||
|
COALESCE((SELECT MAX("Id") FROM diet."Recipes"), 1),
|
||||||
|
(SELECT MAX("Id") IS NOT NULL FROM diet."Recipes")
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT setval(
|
||||||
|
pg_get_serial_sequence('diet."Ingredients"', 'Id'),
|
||||||
|
COALESCE((SELECT MAX("Id") FROM diet."Ingredients"), 1),
|
||||||
|
(SELECT MAX("Id") IS NOT NULL FROM diet."Ingredients")
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT setval(
|
||||||
|
pg_get_serial_sequence('diet."RecipeSteps"', 'Id'),
|
||||||
|
COALESCE((SELECT MAX("Id") FROM diet."RecipeSteps"), 1),
|
||||||
|
(SELECT MAX("Id") IS NOT NULL FROM diet."RecipeSteps")
|
||||||
|
);
|
||||||
|
|
||||||
|
SELECT setval(
|
||||||
|
pg_get_serial_sequence('diet."RecipeGeneratorSessions"', 'Id'),
|
||||||
|
COALESCE((SELECT MAX("Id") FROM diet."RecipeGeneratorSessions"), 1),
|
||||||
|
(SELECT MAX("Id") IS NOT NULL FROM diet."RecipeGeneratorSessions")
|
||||||
|
);
|
||||||
173
database/scripts/README.md
Normal file
173
database/scripts/README.md
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
# Database scripts
|
||||||
|
|
||||||
|
SQL in this folder is applied **manually** on PostgreSQL. The API is database-first and does not run migrations.
|
||||||
|
|
||||||
|
## Diet tracking module
|
||||||
|
|
||||||
|
Run on your DailyMeals database:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "$DATABASE_URL" -f database/scripts/001_diet_tracking.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tables created
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `UserDailyGoals` | Daily calorie, macro, and water targets (per user) |
|
||||||
|
| `DrinkCatalog` | Preset drinks (water, tea, coffee, …) with default volumes |
|
||||||
|
| `MealConsumptions` | Meals the user ate (optional link to `Recipes`, macros snapshotted) |
|
||||||
|
| `DrinkConsumptions` | Hydration / beverage log |
|
||||||
|
| `DietReminders` | Reminder schedules (water, meal, custom) |
|
||||||
|
|
||||||
|
### API (after script + API deploy)
|
||||||
|
|
||||||
|
| Method | Endpoint |
|
||||||
|
|--------|----------|
|
||||||
|
| GET | `/api/diet/summary?date=YYYY-MM-DD` |
|
||||||
|
| GET/PUT | `/api/diet/goals` |
|
||||||
|
| GET/POST/DELETE | `/api/diet/meals` |
|
||||||
|
| GET | `/api/diet/drinks/catalog` |
|
||||||
|
| GET/POST/DELETE | `/api/diet/drinks` |
|
||||||
|
| GET/POST/PUT/DELETE | `/api/diet/reminders` |
|
||||||
|
| GET | `/api/diet/reminders/due?date=&time=` |
|
||||||
|
|
||||||
|
Web UI: **My Diet** at `/diet` in the React frontend.
|
||||||
|
|
||||||
|
## Ingredient nutrition catalog
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "$DATABASE_URL" -f database/scripts/002_ingredient_nutrition_catalog.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tables created
|
||||||
|
|
||||||
|
| Table | Purpose |
|
||||||
|
|-------|---------|
|
||||||
|
| `IngredientCategories` | Food groups (meat, vegetables, dairy, …) |
|
||||||
|
| `IngredientNutritionCatalog` | Per-100 g calories, protein, fat, carbs, fiber |
|
||||||
|
|
||||||
|
### API (after script + API deploy)
|
||||||
|
|
||||||
|
| Method | Endpoint |
|
||||||
|
|--------|----------|
|
||||||
|
| GET | `/api/ingredient-catalog/categories` |
|
||||||
|
| GET | `/api/ingredient-catalog?category=&search=` |
|
||||||
|
| GET | `/api/ingredient-catalog/{id}` |
|
||||||
|
| POST | `/api/ingredient-catalog` |
|
||||||
|
| PUT | `/api/ingredient-catalog/{id}` |
|
||||||
|
| DELETE | `/api/ingredient-catalog/{id}` |
|
||||||
|
|
||||||
|
Web UI: **Ingredient catalog** at `/ingredients` in all frontends.
|
||||||
|
|
||||||
|
### Polish ingredient names
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "$DATABASE_URL" -f database/scripts/003_ingredient_catalog_polish_names.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Adds `NamePl` column and Polish display names for all seeded catalog items. Search matches both English and Polish names.
|
||||||
|
|
||||||
|
## Extended features (meal planner, auth persistence, catalog linking)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "$DATABASE_URL" -f database/scripts/004_extended_features.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Changes
|
||||||
|
|
||||||
|
| Object | Purpose |
|
||||||
|
|--------|---------|
|
||||||
|
| `RefreshTokens` | Persist web login sessions across API restarts |
|
||||||
|
| `MealPlanEntries` | Weekly meal planner (one recipe per meal slot per day) |
|
||||||
|
| `UserFavoriteRecipes` | Quick-log favorites |
|
||||||
|
| `Ingredients.CatalogItemId` | FK to nutrition catalog for auto macro calculation |
|
||||||
|
|
||||||
|
Run **after** scripts 001–003.
|
||||||
|
|
||||||
|
## Catalog expansion + bulk ingredient linking
|
||||||
|
|
||||||
|
One-time migration for existing recipe data (based on your `Ingredients.csv` export):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "$DATABASE_URL" -f database/scripts/005_catalog_expansion_and_ingredient_linking.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### What it does
|
||||||
|
|
||||||
|
1. **Adds ~70 catalog items** missing from the seed (Polish staples: rye bread, dry pasta/rice, feta, tahini, passata, herbs/spices, …).
|
||||||
|
2. **Links `Ingredients.CatalogItemId`** via a name→catalog mapping (~218 ingredient name variants).
|
||||||
|
3. **Recalculates `Recipes` macros** as `SUM(catalog × AmountGrams / 100)` for all recipes with linked ingredients.
|
||||||
|
|
||||||
|
Reference mapping: `database/scripts/005_ingredient_name_mapping.csv` (ingredient name → catalog name → status).
|
||||||
|
|
||||||
|
After the script, check PostgreSQL notices for unlinked rows and recipes still missing macros. Spice rows (`do smaku`, `szczypta`) and rows without grams are intentionally skipped.
|
||||||
|
|
||||||
|
### API alternative (after linking)
|
||||||
|
|
||||||
|
If ingredients already have `CatalogItemId` set, you can recalculate without re-running SQL:
|
||||||
|
|
||||||
|
| Method | Endpoint |
|
||||||
|
|--------|----------|
|
||||||
|
| POST | `/api/recipes/recalculate-macros` |
|
||||||
|
|
||||||
|
Returns `{ recipesProcessed, recipesUpdated, unlinkedIngredientRows }`. Requires auth. Overwrites recipe macros when at least one linked ingredient has grams.
|
||||||
|
|
||||||
|
## AI generators (diet plan + recipes)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
psql "$DATABASE_URL" -f database/scripts/006_diet_and_recipe_generators.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure OpenAI in `MealPlan.Api/appsettings.Local.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"OpenAI": {
|
||||||
|
"ApiKey": "sk-...",
|
||||||
|
"Model": "gpt-4.1-mini",
|
||||||
|
"MacrosModel": "gpt-4.1-mini",
|
||||||
|
"PlanModel": "gpt-4.1-mini",
|
||||||
|
"RecipeModel": "gpt-4.1",
|
||||||
|
"MaxTokens": 4096
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Setting | Used for |
|
||||||
|
|---------|----------|
|
||||||
|
| `MacrosModel` | Diet generator — refine daily kcal/macros |
|
||||||
|
| `PlanModel` | Diet generator — pick recipes for the week |
|
||||||
|
| `RecipeModel` | Recipe generator — ingredients + steps |
|
||||||
|
| `Model` | Fallback when a task model is omitted |
|
||||||
|
|
||||||
|
Without an API key, macro targets still use TDEE math and meal plans use a local greedy picker; recipe generation uses a simple fallback template.
|
||||||
|
|
||||||
|
### Diet generator API
|
||||||
|
|
||||||
|
| Method | Endpoint |
|
||||||
|
|--------|----------|
|
||||||
|
| GET/PUT | `/api/diet-generator/profile` |
|
||||||
|
| POST | `/api/diet-generator/sessions` |
|
||||||
|
| GET | `/api/diet-generator/sessions/{id}` |
|
||||||
|
| POST | `/api/diet-generator/sessions/{id}/propose-macros` |
|
||||||
|
| POST | `/api/diet-generator/sessions/{id}/accept-macros` |
|
||||||
|
| POST | `/api/diet-generator/sessions/{id}/generate-plan` |
|
||||||
|
| POST | `/api/diet-generator/sessions/{id}/regenerate-meal` |
|
||||||
|
| PATCH | `/api/diet-generator/sessions/{id}/meals/{mealId}` |
|
||||||
|
| POST | `/api/diet-generator/sessions/{id}/commit` |
|
||||||
|
|
||||||
|
Commit writes `UserDailyGoals` + `MealPlanEntries`. Daily calorie tolerance defaults to **10 kcal**.
|
||||||
|
|
||||||
|
### Recipe generator API
|
||||||
|
|
||||||
|
| Method | Endpoint |
|
||||||
|
|--------|----------|
|
||||||
|
| POST | `/api/recipe-generator/sessions` |
|
||||||
|
| GET | `/api/recipe-generator/sessions/{id}` |
|
||||||
|
| POST | `/api/recipe-generator/sessions/{id}/generate` |
|
||||||
|
| POST | `/api/recipe-generator/sessions/{id}/regenerate` |
|
||||||
|
| PUT | `/api/recipe-generator/sessions/{id}/draft` |
|
||||||
|
| POST | `/api/recipe-generator/sessions/{id}/commit` |
|
||||||
|
|
||||||
|
Saved recipes get `Source = ai_generated` and macros from the nutrition catalog.
|
||||||
|
|
||||||
|
Web UI: `/diet-generator` and `/recipe-generator` in the React frontend.
|
||||||
17
meal-plan-frontend-angular/.editorconfig
Normal file
17
meal-plan-frontend-angular/.editorconfig
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Editor configuration, see https://editorconfig.org
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.ts]
|
||||||
|
quote_type = single
|
||||||
|
ij_typescript_use_double_quotes = false
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
max_line_length = off
|
||||||
|
trim_trailing_whitespace = false
|
||||||
42
meal-plan-frontend-angular/.gitignore
vendored
Normal file
42
meal-plan-frontend-angular/.gitignore
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||||
|
|
||||||
|
# Compiled output
|
||||||
|
/dist
|
||||||
|
/tmp
|
||||||
|
/out-tsc
|
||||||
|
/bazel-out
|
||||||
|
|
||||||
|
# Node
|
||||||
|
/node_modules
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
|
||||||
|
# IDEs and editors
|
||||||
|
.idea/
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.c9/
|
||||||
|
*.launch
|
||||||
|
.settings/
|
||||||
|
*.sublime-workspace
|
||||||
|
|
||||||
|
# Visual Studio Code
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.history/*
|
||||||
|
|
||||||
|
# Miscellaneous
|
||||||
|
/.angular/cache
|
||||||
|
.sass-cache/
|
||||||
|
/connect.lock
|
||||||
|
/coverage
|
||||||
|
/libpeerconnection.log
|
||||||
|
testem.log
|
||||||
|
/typings
|
||||||
|
|
||||||
|
# System files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
4
meal-plan-frontend-angular/.vscode/extensions.json
vendored
Normal file
4
meal-plan-frontend-angular/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||||
|
"recommendations": ["angular.ng-template"]
|
||||||
|
}
|
||||||
20
meal-plan-frontend-angular/.vscode/launch.json
vendored
Normal file
20
meal-plan-frontend-angular/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "ng serve",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: start",
|
||||||
|
"url": "http://localhost:4200/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ng test",
|
||||||
|
"type": "chrome",
|
||||||
|
"request": "launch",
|
||||||
|
"preLaunchTask": "npm: test",
|
||||||
|
"url": "http://localhost:9876/debug.html"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
42
meal-plan-frontend-angular/.vscode/tasks.json
vendored
Normal file
42
meal-plan-frontend-angular/.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "start",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "(.*?)"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "npm",
|
||||||
|
"script": "test",
|
||||||
|
"isBackground": true,
|
||||||
|
"problemMatcher": {
|
||||||
|
"owner": "typescript",
|
||||||
|
"pattern": "$tsc",
|
||||||
|
"background": {
|
||||||
|
"activeOnStart": true,
|
||||||
|
"beginsPattern": {
|
||||||
|
"regexp": "(.*?)"
|
||||||
|
},
|
||||||
|
"endsPattern": {
|
||||||
|
"regexp": "bundle generation complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
30
meal-plan-frontend-angular/Dockerfile
Normal file
30
meal-plan-frontend-angular/Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# ---------- Build stage ----------
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm ci || npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---------- Serve stage ----------
|
||||||
|
FROM nginx:alpine AS final
|
||||||
|
|
||||||
|
RUN apk add --no-cache openssl && \
|
||||||
|
mkdir -p /etc/nginx/certs && \
|
||||||
|
openssl req -x509 -nodes -newkey rsa:2048 -days 365 \
|
||||||
|
-keyout /etc/nginx/certs/server.key \
|
||||||
|
-out /etc/nginx/certs/server.crt \
|
||||||
|
-subj "/CN=dailymeals.local" && \
|
||||||
|
chmod 600 /etc/nginx/certs/server.key
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/nginx.conf
|
||||||
|
COPY --from=build /app/dist/meal-plan-frontend-angular/browser /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80 443
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --no-check-certificate --tries=1 --spider https://localhost/ || exit 1
|
||||||
|
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
56
meal-plan-frontend-angular/README.md
Normal file
56
meal-plan-frontend-angular/README.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# DailyMeals — Angular frontend
|
||||||
|
|
||||||
|
Angular 19 rewrite of the React `meal-plan-frontend` app.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Angular 19 (standalone components)
|
||||||
|
- Tailwind CSS 3
|
||||||
|
- `@ngx-translate` (EN / PL)
|
||||||
|
- Reactive forms
|
||||||
|
- JWT auth with silent refresh (`AuthInterceptor`)
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd meal-plan-frontend-angular
|
||||||
|
npm install
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
App runs at **http://localhost:4200** with API proxied to `http://localhost:5000` (`proxy.conf.json`).
|
||||||
|
|
||||||
|
Ensure the ASP.NET API is running locally before signing in.
|
||||||
|
|
||||||
|
## Production build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Output: `dist/meal-plan-frontend-angular/browser/`
|
||||||
|
|
||||||
|
## Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t dailymeals-angular .
|
||||||
|
```
|
||||||
|
|
||||||
|
Uses nginx with HTTPS and `/api` reverse proxy (same pattern as the React frontend).
|
||||||
|
|
||||||
|
## Routes
|
||||||
|
|
||||||
|
| Path | Page |
|
||||||
|
|------|------|
|
||||||
|
| `/login`, `/register` | Auth (public) |
|
||||||
|
| `/` | Dashboard |
|
||||||
|
| `/breakfast` … `/dinner` | Category lists |
|
||||||
|
| `/all-meals` | Search, select, PDF export |
|
||||||
|
| `/manage-meals` | Manual add + Excel import |
|
||||||
|
| `/recipes/:id` | Recipe detail |
|
||||||
|
|
||||||
|
## i18n
|
||||||
|
|
||||||
|
Translation files: `public/i18n/en.json`, `public/i18n/pl.json`
|
||||||
|
|
||||||
|
Language preference stored in `localStorage` key `dailymeals.lang`.
|
||||||
127
meal-plan-frontend-angular/angular.json
Normal file
127
meal-plan-frontend-angular/angular.json
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||||
|
"version": 1,
|
||||||
|
"newProjectRoot": "projects",
|
||||||
|
"projects": {
|
||||||
|
"meal-plan-frontend-angular": {
|
||||||
|
"projectType": "application",
|
||||||
|
"schematics": {
|
||||||
|
"@schematics/angular:class": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:component": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:directive": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:guard": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:interceptor": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:pipe": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:resolver": {
|
||||||
|
"skipTests": true
|
||||||
|
},
|
||||||
|
"@schematics/angular:service": {
|
||||||
|
"skipTests": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "",
|
||||||
|
"sourceRoot": "src",
|
||||||
|
"prefix": "app",
|
||||||
|
"architect": {
|
||||||
|
"build": {
|
||||||
|
"builder": "@angular-devkit/build-angular:application",
|
||||||
|
"options": {
|
||||||
|
"outputPath": "dist/meal-plan-frontend-angular",
|
||||||
|
"index": "src/index.html",
|
||||||
|
"browser": "src/main.ts",
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.css"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"budgets": [
|
||||||
|
{
|
||||||
|
"type": "initial",
|
||||||
|
"maximumWarning": "1.5MB",
|
||||||
|
"maximumError": "2MB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "anyComponentStyle",
|
||||||
|
"maximumWarning": "4kB",
|
||||||
|
"maximumError": "8kB"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"outputHashing": "all"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"optimization": false,
|
||||||
|
"extractLicenses": false,
|
||||||
|
"sourceMap": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "production"
|
||||||
|
},
|
||||||
|
"serve": {
|
||||||
|
"builder": "@angular-devkit/build-angular:dev-server",
|
||||||
|
"options": {
|
||||||
|
"proxyConfig": "proxy.conf.json"
|
||||||
|
},
|
||||||
|
"configurations": {
|
||||||
|
"production": {
|
||||||
|
"buildTarget": "meal-plan-frontend-angular:build:production"
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"buildTarget": "meal-plan-frontend-angular:build:development"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"defaultConfiguration": "development"
|
||||||
|
},
|
||||||
|
"extract-i18n": {
|
||||||
|
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||||
|
},
|
||||||
|
"test": {
|
||||||
|
"builder": "@angular-devkit/build-angular:karma",
|
||||||
|
"options": {
|
||||||
|
"polyfills": [
|
||||||
|
"zone.js",
|
||||||
|
"zone.js/testing"
|
||||||
|
],
|
||||||
|
"tsConfig": "tsconfig.spec.json",
|
||||||
|
"assets": [
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "public"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"styles": [
|
||||||
|
"src/styles.css"
|
||||||
|
],
|
||||||
|
"scripts": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cli": {
|
||||||
|
"analytics": false
|
||||||
|
}
|
||||||
|
}
|
||||||
84
meal-plan-frontend-angular/nginx.conf
Normal file
84
meal-plan-frontend-angular/nginx.conf
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
worker_processes auto;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
server_tokens off;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_types
|
||||||
|
text/plain
|
||||||
|
text/css
|
||||||
|
text/javascript
|
||||||
|
application/javascript
|
||||||
|
application/json
|
||||||
|
application/xml
|
||||||
|
image/svg+xml
|
||||||
|
font/woff2;
|
||||||
|
|
||||||
|
upstream api_upstream {
|
||||||
|
server api:5000;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/server.crt;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/server.key;
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header Referrer-Policy "no-referrer" always;
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" always;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api_upstream;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /i18n/ {
|
||||||
|
expires 1h;
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
16757
meal-plan-frontend-angular/package-lock.json
generated
Normal file
16757
meal-plan-frontend-angular/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
meal-plan-frontend-angular/package.json
Normal file
44
meal-plan-frontend-angular/package.json
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "meal-plan-frontend-angular",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"ng": "ng",
|
||||||
|
"start": "ng serve",
|
||||||
|
"build": "ng build",
|
||||||
|
"watch": "ng build --watch --configuration development",
|
||||||
|
"test": "ng test"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@angular/common": "^19.2.0",
|
||||||
|
"@angular/compiler": "^19.2.0",
|
||||||
|
"@angular/core": "^19.2.0",
|
||||||
|
"@angular/forms": "^19.2.0",
|
||||||
|
"@angular/platform-browser": "^19.2.0",
|
||||||
|
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||||
|
"@angular/router": "^19.2.0",
|
||||||
|
"@ngx-translate/core": "^18.0.0",
|
||||||
|
"@ngx-translate/http-loader": "^18.0.0",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"jspdf": "^4.2.1",
|
||||||
|
"rxjs": "~7.8.0",
|
||||||
|
"tslib": "^2.3.0",
|
||||||
|
"zone.js": "~0.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@angular-devkit/build-angular": "^19.2.27",
|
||||||
|
"@angular/cli": "^19.2.27",
|
||||||
|
"@angular/compiler-cli": "^19.2.0",
|
||||||
|
"@types/jasmine": "~5.1.0",
|
||||||
|
"autoprefixer": "^10.5.0",
|
||||||
|
"jasmine-core": "~5.6.0",
|
||||||
|
"karma": "~6.4.0",
|
||||||
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
|
"karma-coverage": "~2.2.0",
|
||||||
|
"karma-jasmine": "~5.1.0",
|
||||||
|
"karma-jasmine-html-reporter": "~2.1.0",
|
||||||
|
"postcss": "^8.5.15",
|
||||||
|
"tailwindcss": "^3.4.14",
|
||||||
|
"typescript": "~5.7.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
meal-plan-frontend-angular/postcss.config.js
Normal file
6
meal-plan-frontend-angular/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
7
meal-plan-frontend-angular/proxy.conf.json
Normal file
7
meal-plan-frontend-angular/proxy.conf.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"/api": {
|
||||||
|
"target": "http://localhost:5000",
|
||||||
|
"secure": false,
|
||||||
|
"changeOrigin": true
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
meal-plan-frontend-angular/public/favicon.ico
Normal file
BIN
meal-plan-frontend-angular/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
7
meal-plan-frontend-angular/public/favicon.svg
Normal file
7
meal-plan-frontend-angular/public/favicon.svg
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#16774c" />
|
||||||
|
<path
|
||||||
|
d="M11 7v8a3 3 0 0 0 3 3v7a1 1 0 0 0 2 0v-7a3 3 0 0 0 3-3V7a1 1 0 0 0-2 0v5a1 1 0 0 1-2 0V7a1 1 0 0 0-2 0v5a1 1 0 0 1-2 0V7a1 1 0 0 0-2 0Z"
|
||||||
|
fill="#fff"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 297 B |
434
meal-plan-frontend-angular/public/i18n/en.json
Normal file
434
meal-plan-frontend-angular/public/i18n/en.json
Normal file
@@ -0,0 +1,434 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"name": "DailyMeals"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"label": "Language",
|
||||||
|
"en": "English",
|
||||||
|
"pl": "Polish"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"label": "Layout",
|
||||||
|
"sidebar": "Sidebar",
|
||||||
|
"topnav": "Top navigation",
|
||||||
|
"compact": "Compact sidebar",
|
||||||
|
"wide": "Wide sidebar",
|
||||||
|
"wideTagline": "Plan your meals with ease"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"label": "Appearance",
|
||||||
|
"forest": "Forest Classic",
|
||||||
|
"forestDesc": "Green palette, outline icons",
|
||||||
|
"ocean": "Ocean Fresh",
|
||||||
|
"oceanDesc": "Blue palette, outline icons",
|
||||||
|
"sunset": "Sunset Kitchen",
|
||||||
|
"sunsetDesc": "Warm amber palette, emoji icons",
|
||||||
|
"berry": "Berry Modern",
|
||||||
|
"berryDesc": "Violet palette, bold icons"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"breakfast": "Breakfast",
|
||||||
|
"secondBreakfast": "Second Breakfast",
|
||||||
|
"lunch": "Lunch",
|
||||||
|
"dinner": "Dinner",
|
||||||
|
"allMeals": "All Meals",
|
||||||
|
"manageMeals": "Manage Meals",
|
||||||
|
"mealPlanner": "Meal Planner",
|
||||||
|
"shoppingList": "Shopping List",
|
||||||
|
"dietTracker": "My Diet",
|
||||||
|
"ingredientCatalog": "Ingredients",
|
||||||
|
"dietGenerator": "Diet Generator",
|
||||||
|
"recipeGenerator": "Recipe Generator",
|
||||||
|
"logout": "Logout",
|
||||||
|
"user": "User",
|
||||||
|
"toggleNav": "Toggle navigation",
|
||||||
|
"toggleTheme": "Toggle theme",
|
||||||
|
"lightMode": "Switch to light mode",
|
||||||
|
"darkMode": "Switch to dark mode"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"breakfast": "Breakfast",
|
||||||
|
"secondBreakfast": "Second Breakfast",
|
||||||
|
"lunch": "Lunch",
|
||||||
|
"dinner": "Dinner",
|
||||||
|
"unknown": "Unknown"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"tryAgain": "Try again",
|
||||||
|
"back": "Back",
|
||||||
|
"add": "Add",
|
||||||
|
"category": "Category",
|
||||||
|
"all": "All",
|
||||||
|
"search": "Search",
|
||||||
|
"save": "Save",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"edit": "Edit",
|
||||||
|
"delete": "Delete",
|
||||||
|
"optional": "optional",
|
||||||
|
"recipeCount_one": "{{count}} recipe",
|
||||||
|
"recipeCount_other": "{{count}} recipes",
|
||||||
|
"selectedCount_one": "{{count}} selected",
|
||||||
|
"selectedCount_other": "{{count}} selected"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signInSubtitle": "Sign in to plan your meals",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"emailPlaceholder": "you@example.com",
|
||||||
|
"signIn": "Sign in",
|
||||||
|
"signingIn": "Signing in...",
|
||||||
|
"noAccount": "Don't have an account?",
|
||||||
|
"createOne": "Create one",
|
||||||
|
"createAccountTitle": "Create account",
|
||||||
|
"createAccountSubtitle": "Join DailyMeals to browse and plan meals",
|
||||||
|
"displayName": "Display name",
|
||||||
|
"displayNamePlaceholder": "Piotr",
|
||||||
|
"confirmPassword": "Confirm password",
|
||||||
|
"passwordHint": "At least 8 characters with uppercase, lowercase, and a number.",
|
||||||
|
"createAccount": "Create account",
|
||||||
|
"creatingAccount": "Creating account...",
|
||||||
|
"hasAccount": "Already have an account?"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"emailRequired": "Email is required.",
|
||||||
|
"emailInvalid": "Enter a valid email address.",
|
||||||
|
"passwordRequired": "Password is required.",
|
||||||
|
"displayNameTooLong": "Display name is too long.",
|
||||||
|
"passwordMinLength": "Password must be at least 8 characters.",
|
||||||
|
"passwordUppercase": "Include at least one uppercase letter.",
|
||||||
|
"passwordLowercase": "Include at least one lowercase letter.",
|
||||||
|
"passwordDigit": "Include at least one digit.",
|
||||||
|
"confirmPasswordRequired": "Please confirm your password.",
|
||||||
|
"passwordsMismatch": "Passwords do not match.",
|
||||||
|
"recipeNameRequired": "Recipe name is required.",
|
||||||
|
"ingredientNameRequired": "Ingredient name is required.",
|
||||||
|
"stepNumberMin": "Step number must be at least 1.",
|
||||||
|
"stepDescriptionRequired": "Step description is required.",
|
||||||
|
"stepsMin": "Add at least one preparation step."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "Something went wrong.",
|
||||||
|
"signInFailed": "Unable to sign in.",
|
||||||
|
"registerFailed": "Unable to create account.",
|
||||||
|
"loadCategoriesFailed": "Failed to load categories.",
|
||||||
|
"loadRecipesFailed": "Failed to load recipes.",
|
||||||
|
"loadRecipeFailed": "Failed to load recipe.",
|
||||||
|
"loadDietFailed": "Failed to load diet data.",
|
||||||
|
"loadMealPlanFailed": "Failed to load meal plan.",
|
||||||
|
"loadShoppingListFailed": "Failed to load shopping list.",
|
||||||
|
"loadIngredientCatalogFailed": "Failed to load ingredient catalog.",
|
||||||
|
"saveIngredientFailed": "Failed to save ingredient.",
|
||||||
|
"saveRecipeFailed": "Failed to save recipe.",
|
||||||
|
"recalculateMacrosFailed": "Failed to recalculate recipe macros.",
|
||||||
|
"importFailed": "Import failed.",
|
||||||
|
"pdfLoadFailed": "Could not load recipes for export.",
|
||||||
|
"pdfGenerateFailed": "PDF generation failed.",
|
||||||
|
"pdfRenderAreaMissing": "PDF render area not found.",
|
||||||
|
"pdfNothingToExport": "Nothing to export."
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"welcome": "Welcome back",
|
||||||
|
"welcomeNamed": "Welcome back, {{name}}",
|
||||||
|
"subtitle": "Browse recipes by meal category or explore everything at once.",
|
||||||
|
"viewRecipes": "View recipes"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"allMealsTitle": "All Meals",
|
||||||
|
"allMealsSubtitle": "Select recipes and export them with a combined shopping list.",
|
||||||
|
"selectAllVisible": "Select all visible",
|
||||||
|
"deselectAll": "Deselect all",
|
||||||
|
"searchRecipes": "Search recipes...",
|
||||||
|
"searchByIngredient": "Search by ingredient...",
|
||||||
|
"searchByIngredientLabel": "Search by ingredient",
|
||||||
|
"searchByNameAria": "Search recipes by name",
|
||||||
|
"searchByIngredientAria": "Search recipes by ingredient",
|
||||||
|
"noMatchingTitle": "No matching recipes",
|
||||||
|
"noMatchingIngredient": "No recipes contain that ingredient. Try a different ingredient name.",
|
||||||
|
"noMatchingFilter": "Try adjusting your search or category filter.",
|
||||||
|
"emptyCategoryTitle": "No {{category}} recipes yet",
|
||||||
|
"emptyCategoryDescription": "There are currently no recipes in this category.",
|
||||||
|
"selectRecipe": "Select {{name}}",
|
||||||
|
"prepTime": "{{count}} min preparation",
|
||||||
|
"calories": "Calories",
|
||||||
|
"protein": "Protein",
|
||||||
|
"fat": "Fat",
|
||||||
|
"carbs": "Carbs",
|
||||||
|
"ingredients": "Ingredients",
|
||||||
|
"preparation": "Preparation",
|
||||||
|
"noIngredients": "No ingredients listed.",
|
||||||
|
"noSteps": "No steps listed.",
|
||||||
|
"kcal": "{{count}} kcal",
|
||||||
|
"minutes": "{{count}} min"
|
||||||
|
},
|
||||||
|
"manage": {
|
||||||
|
"title": "Manage Meals",
|
||||||
|
"subtitle": "Add recipes manually or import them from an Excel spreadsheet.",
|
||||||
|
"tabManual": "Add manually",
|
||||||
|
"tabEdit": "Edit recipes",
|
||||||
|
"tabImport": "Import Excel",
|
||||||
|
"savedSuccess": "\"{{name}}\" saved successfully.",
|
||||||
|
"basicInfo": "Basic info",
|
||||||
|
"recipeName": "Recipe name",
|
||||||
|
"recipeNamePlaceholder": "Grilled chicken salad",
|
||||||
|
"prepTimeMin": "Prep time (min)",
|
||||||
|
"caloriesKcal": "Calories (kcal)",
|
||||||
|
"proteinG": "Protein (g)",
|
||||||
|
"fatG": "Fat (g)",
|
||||||
|
"carbsG": "Carbs (g)",
|
||||||
|
"preparationSteps": "Preparation steps",
|
||||||
|
"addStep": "Add step",
|
||||||
|
"stepPlaceholder": "Describe this step...",
|
||||||
|
"ingredientName": "Name",
|
||||||
|
"ingredientGrams": "Grams",
|
||||||
|
"ingredientUnit": "Unit",
|
||||||
|
"removeIngredient": "Remove ingredient",
|
||||||
|
"removeStep": "Remove step",
|
||||||
|
"saveRecipe": "Save recipe",
|
||||||
|
"updateRecipe": "Update recipe",
|
||||||
|
"updatedSuccess": "\"{{name}}\" updated successfully.",
|
||||||
|
"selectRecipeToEdit": "Select a recipe from the list to edit.",
|
||||||
|
"catalogLink": "Link to ingredient catalog",
|
||||||
|
"estimatedFromCatalog": "Estimated from linked catalog items",
|
||||||
|
"unlinkedIngredientsWarning": "{{count}} ingredient(s) have grams but no catalog link — macros may be incomplete.",
|
||||||
|
"recalculateMacros": "Recalculate all macros",
|
||||||
|
"recalculating": "Recalculating…",
|
||||||
|
"recalculateSuccess": "Updated {{updated}} of {{processed}} recipes. {{unlinked}} ingredient row(s) still unlinked.",
|
||||||
|
"macroHint": "Estimated: {{calories}} kcal · P {{protein}}g · F {{fat}}g · C {{carbs}}g",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"importTitle": "Import from Excel",
|
||||||
|
"importDescription": "Use the template with three sheets: Recipes, Ingredients, and Steps. Recipe names must match across sheets.",
|
||||||
|
"downloadTemplate": "Download template",
|
||||||
|
"uploadLabel": "Upload filled .xlsx file",
|
||||||
|
"importComplete": "Import complete",
|
||||||
|
"createdCount": "Created: {{count}}",
|
||||||
|
"skippedCount": "Skipped: {{count}}",
|
||||||
|
"importRecipes": "Import recipes",
|
||||||
|
"importing": "Importing...",
|
||||||
|
"excelFormatTitle": "Excel format",
|
||||||
|
"excelRecipesSheet": "Recipes: Name, MealCategory (Breakfast / Lunch / 0–3), Calories, Protein, Fat, Carbs, PrepTimeMinutes",
|
||||||
|
"excelIngredientsSheet": "Ingredients: RecipeName, Name, AmountGrams, Unit, SortOrder",
|
||||||
|
"excelStepsSheet": "Steps: RecipeName, StepNumber, Description",
|
||||||
|
"duplicateSkipped": "Duplicate recipe names are skipped."
|
||||||
|
},
|
||||||
|
"generators": {
|
||||||
|
"diet": {
|
||||||
|
"title": "Diet Generator",
|
||||||
|
"subtitle": "Calculate targets from your profile, build a weekly plan from your recipe catalog, and save it to the meal planner.",
|
||||||
|
"profileTitle": "Your profile",
|
||||||
|
"heightCm": "Height (cm)",
|
||||||
|
"weightKg": "Weight (kg)",
|
||||||
|
"age": "Age",
|
||||||
|
"sex": "Sex",
|
||||||
|
"male": "Male",
|
||||||
|
"female": "Female",
|
||||||
|
"activity": "Activity level",
|
||||||
|
"sedentary": "Sedentary",
|
||||||
|
"light": "Light",
|
||||||
|
"moderate": "Moderate",
|
||||||
|
"active": "Active",
|
||||||
|
"veryActive": "Very active",
|
||||||
|
"goal": "Goal",
|
||||||
|
"loseWeight": "Lose weight",
|
||||||
|
"maintain": "Maintain",
|
||||||
|
"gainMuscle": "Gain muscle",
|
||||||
|
"recomp": "Recomposition",
|
||||||
|
"allergies": "Allergies / notes",
|
||||||
|
"continue": "Calculate targets",
|
||||||
|
"macrosTitle": "Daily targets",
|
||||||
|
"acceptAndGenerate": "Accept & generate 7-day plan",
|
||||||
|
"planHint": "Each day should be within ±10 kcal of your target. Lock meals you like, regenerate others.",
|
||||||
|
"lock": "Lock",
|
||||||
|
"unlock": "Unlock",
|
||||||
|
"viewRecipe": "Details",
|
||||||
|
"noAlternativeMeals": "No other recipes are available for this meal — all suitable options are already in your plan.",
|
||||||
|
"commit": "Save plan to calendar",
|
||||||
|
"done": "Plan saved to your meal planner and daily goals.",
|
||||||
|
"newPlan": "Create another plan",
|
||||||
|
"error": "Diet generator failed."
|
||||||
|
},
|
||||||
|
"recipe": {
|
||||||
|
"title": "Recipe Generator",
|
||||||
|
"subtitle": "Create several recipe drafts with AI, review them, and save the ones you like.",
|
||||||
|
"constraintsTitle": "What should we cook?",
|
||||||
|
"prompt": "Describe the meal",
|
||||||
|
"promptPlaceholder": "Light breakfast with oatmeal and fruit…",
|
||||||
|
"targetCalories": "Target calories (kcal)",
|
||||||
|
"targetCaloriesOptional": "e.g. 500",
|
||||||
|
"targetCaloriesHint": "Ingredient amounts will be scaled to match (±10 kcal).",
|
||||||
|
"caloriesWithTarget": "{{actual}} kcal (target: {{target}})",
|
||||||
|
"maxPrep": "Max prep time (min)",
|
||||||
|
"exclusions": "Exclude ingredients",
|
||||||
|
"generate": "Generate recipe",
|
||||||
|
"recipeCount": "How many recipes to generate",
|
||||||
|
"reviewHint": "Review {{count}} generated recipes. Uncheck or remove ones you don't want, then save the rest.",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"remove": "Remove",
|
||||||
|
"saveSelected": "Save selected ({{count}})",
|
||||||
|
"doneMultiple": "{{count}} recipes saved to your library.",
|
||||||
|
"generateMore": "Generate more recipes",
|
||||||
|
"regenIngredients": "Regenerate ingredients",
|
||||||
|
"regenSteps": "Regenerate steps",
|
||||||
|
"regenFull": "Regenerate all",
|
||||||
|
"save": "Save to library",
|
||||||
|
"done": "Recipe saved.",
|
||||||
|
"viewRecipe": "View recipe",
|
||||||
|
"error": "Recipe generator failed."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pdf": {
|
||||||
|
"generate": "Generate PDF",
|
||||||
|
"preparing": "Preparing...",
|
||||||
|
"generating": "Generating...",
|
||||||
|
"prepTime": "Prep time: {{count}} min",
|
||||||
|
"shoppingList": "Shopping List",
|
||||||
|
"generatedOn_one": "Generated {{date}} · {{count}} recipe",
|
||||||
|
"generatedOn_other": "Generated {{date}} · {{count}} recipes",
|
||||||
|
"recipesInExport": "Recipes in this export",
|
||||||
|
"toTaste": "to taste",
|
||||||
|
"dayPlanTitle": "Daily meal plan — {{date}}",
|
||||||
|
"emptySlot": "Not planned",
|
||||||
|
"emptyShoppingList": "No shopping items for this day.",
|
||||||
|
"plannedMealMeta": "{{slot}} · {{portions}}× portions",
|
||||||
|
"dayShoppingSubtitle": "List for {{date}} · {{count}} meals"
|
||||||
|
},
|
||||||
|
"diet": {
|
||||||
|
"title": "My Diet",
|
||||||
|
"subtitle": "Track what you eat and drink, set daily targets, and stay on top of reminders.",
|
||||||
|
"tabToday": "Today",
|
||||||
|
"tabHistory": "History",
|
||||||
|
"tabGoals": "Goals",
|
||||||
|
"tabReminders": "Reminders",
|
||||||
|
"goToday": "Today",
|
||||||
|
"notToday": "not today",
|
||||||
|
"quickLog": "Quick log",
|
||||||
|
"copyYesterday": "Copy yesterday's meals",
|
||||||
|
"recentMeals": "Recent",
|
||||||
|
"favorites": "Favorites",
|
||||||
|
"days": "days",
|
||||||
|
"historyCalories": "Calories over time",
|
||||||
|
"historyMax": "Peak: {{max}} kcal",
|
||||||
|
"noHistory": "No history for this period.",
|
||||||
|
"mealsOnDate": "Meals on {{date}}",
|
||||||
|
"drinksOnDate": "Drinks on {{date}}",
|
||||||
|
"editReminder": "Edit reminder",
|
||||||
|
"calories": "Calories",
|
||||||
|
"water": "Water",
|
||||||
|
"snack": "Snack",
|
||||||
|
"logDrink": "Log a drink",
|
||||||
|
"logMeal": "Mark a meal as eaten",
|
||||||
|
"searchRecipe": "From a recipe",
|
||||||
|
"orCustomMeal": "Or enter a custom meal name",
|
||||||
|
"markEaten": "Mark as eaten",
|
||||||
|
"mealsToday": "Meals today",
|
||||||
|
"drinksToday": "Drinks today",
|
||||||
|
"nothingLogged": "Nothing logged yet.",
|
||||||
|
"noMacros": "No nutrition info",
|
||||||
|
"removeEntry": "Remove entry",
|
||||||
|
"dailyGoals": "Daily goals",
|
||||||
|
"goalsHint": "Set targets like Lifesum or MyFitnessPal — the Today tab shows what is left.",
|
||||||
|
"calorieGoal": "Calorie goal (kcal)",
|
||||||
|
"waterGoal": "Water goal (ml)",
|
||||||
|
"saveGoals": "Save goals",
|
||||||
|
"addReminder": "Add reminder",
|
||||||
|
"reminderType": "Type",
|
||||||
|
"reminderWater": "Drink water",
|
||||||
|
"reminderMeal": "Meal time",
|
||||||
|
"reminderCustom": "Custom",
|
||||||
|
"reminderTitle": "Title",
|
||||||
|
"reminderTitlePlaceholder": "Drink a glass of water",
|
||||||
|
"reminderTime": "Time",
|
||||||
|
"saveReminder": "Save reminder",
|
||||||
|
"yourReminders": "Your reminders",
|
||||||
|
"noReminders": "No reminders yet.",
|
||||||
|
"remaining": "Left",
|
||||||
|
"portions": "Portions",
|
||||||
|
"fromCatalog": "From ingredient catalog",
|
||||||
|
"noCatalogItem": "None",
|
||||||
|
"grams": "Amount (g)",
|
||||||
|
"mealPreview": "This meal adds",
|
||||||
|
"remainingAfter": "After logging you will still have",
|
||||||
|
"suggestionsTitle": "What to eat next",
|
||||||
|
"suggestionsHint": "Based on what you still need today.",
|
||||||
|
"logSuggestion": "Log",
|
||||||
|
"suggestionReasons": {
|
||||||
|
"highProtein": "High protein",
|
||||||
|
"needCarbs": "Good carbs",
|
||||||
|
"needFat": "Healthy fats",
|
||||||
|
"needCalories": "Energy boost",
|
||||||
|
"balanced": "Balanced choice"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mealPlanner": {
|
||||||
|
"title": "Meal Planner",
|
||||||
|
"subtitle": "Plan recipes for each day of the week.",
|
||||||
|
"today": "This week",
|
||||||
|
"addRecipe": "Add recipe",
|
||||||
|
"dailyTarget": "Daily calorie target",
|
||||||
|
"daySummary": "{{logged}} / {{target}} kcal planned",
|
||||||
|
"remaining": "{{count}} kcal left",
|
||||||
|
"slotHint": "Suggested ~{{count}} kcal for this meal",
|
||||||
|
"suggestionsTitle": "Suggestions for this slot",
|
||||||
|
"noSuggestions": "Set a daily calorie target to see suggestions.",
|
||||||
|
"estimatedMeal": "This meal: ~{{count}} kcal",
|
||||||
|
"selectedRecipe": "Selected: {{name}}",
|
||||||
|
"clearSelection": "Clear selection",
|
||||||
|
"exportDay": "Export day plan (PDF)",
|
||||||
|
"includingSelection": "incl. current pick: {{count}} kcal"
|
||||||
|
},
|
||||||
|
"shoppingList": {
|
||||||
|
"title": "Shopping List",
|
||||||
|
"subtitle": "Aggregated ingredients from your meal plan.",
|
||||||
|
"from": "From",
|
||||||
|
"to": "To",
|
||||||
|
"load": "Load list",
|
||||||
|
"clearChecks": "Clear checked items",
|
||||||
|
"empty": "No items for this date range.",
|
||||||
|
"checklistHint": "Checked items are saved locally in your browser."
|
||||||
|
},
|
||||||
|
"ingredientCatalog": {
|
||||||
|
"title": "Ingredient catalog",
|
||||||
|
"subtitle": "Nutrition values per 100 g, grouped by food category.",
|
||||||
|
"allCategories": "All",
|
||||||
|
"searchPlaceholder": "Search ingredients…",
|
||||||
|
"noResultsTitle": "No ingredients found",
|
||||||
|
"noResultsDescription": "Try another category or search term.",
|
||||||
|
"per100gNote": "All values are per 100 g.",
|
||||||
|
"addIngredient": "Add ingredient",
|
||||||
|
"editIngredient": "Edit ingredient",
|
||||||
|
"selectCategory": "Select category",
|
||||||
|
"nameEn": "Name (English)",
|
||||||
|
"namePlLabel": "Name (Polish)",
|
||||||
|
"formInvalid": "Please fill in category and name.",
|
||||||
|
"deleteConfirm": "Remove this ingredient from the catalog?",
|
||||||
|
"columns": {
|
||||||
|
"name": "Ingredient",
|
||||||
|
"category": "Category",
|
||||||
|
"calories": "kcal",
|
||||||
|
"protein": "Protein (g)",
|
||||||
|
"fat": "Fat (g)",
|
||||||
|
"carbs": "Carbs (g)",
|
||||||
|
"fiber": "Fiber (g)"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"meat": "Meat",
|
||||||
|
"poultry": "Poultry",
|
||||||
|
"fish": "Fish",
|
||||||
|
"seafood": "Seafood",
|
||||||
|
"vegetables": "Vegetables",
|
||||||
|
"fruits": "Fruits",
|
||||||
|
"dairy": "Dairy",
|
||||||
|
"eggs": "Eggs",
|
||||||
|
"grains": "Grains",
|
||||||
|
"legumes": "Legumes",
|
||||||
|
"nuts": "Nuts & seeds",
|
||||||
|
"oils": "Oils & fats"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notFound": {
|
||||||
|
"title": "Page not found",
|
||||||
|
"description": "The page you're looking for doesn't exist.",
|
||||||
|
"backToDashboard": "Back to dashboard"
|
||||||
|
}
|
||||||
|
}
|
||||||
440
meal-plan-frontend-angular/public/i18n/pl.json
Normal file
440
meal-plan-frontend-angular/public/i18n/pl.json
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
{
|
||||||
|
"app": {
|
||||||
|
"name": "DailyMeals"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"label": "Język",
|
||||||
|
"en": "Angielski",
|
||||||
|
"pl": "Polski"
|
||||||
|
},
|
||||||
|
"layout": {
|
||||||
|
"label": "Układ",
|
||||||
|
"sidebar": "Panel boczny",
|
||||||
|
"topnav": "Nawigacja górna",
|
||||||
|
"compact": "Wąski panel",
|
||||||
|
"wide": "Szeroki panel",
|
||||||
|
"wideTagline": "Planuj posiłki wygodnie"
|
||||||
|
},
|
||||||
|
"appearance": {
|
||||||
|
"label": "Wygląd",
|
||||||
|
"forest": "Leśny klasyczny",
|
||||||
|
"forestDesc": "Zielona paleta, ikony konturowe",
|
||||||
|
"ocean": "Oceaniczna świeżość",
|
||||||
|
"oceanDesc": "Niebieska paleta, ikony konturowe",
|
||||||
|
"sunset": "Zachód kuchni",
|
||||||
|
"sunsetDesc": "Ciepła bursztynowa paleta, emoji",
|
||||||
|
"berry": "Jagodowy modern",
|
||||||
|
"berryDesc": "Fioletowa paleta, pogrubione ikony"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"dashboard": "Panel",
|
||||||
|
"breakfast": "Śniadanie",
|
||||||
|
"secondBreakfast": "Drugie śniadanie",
|
||||||
|
"lunch": "Obiad",
|
||||||
|
"dinner": "Kolacja",
|
||||||
|
"allMeals": "Wszystkie posiłki",
|
||||||
|
"manageMeals": "Zarządzaj posiłkami",
|
||||||
|
"mealPlanner": "Plan posiłków",
|
||||||
|
"shoppingList": "Lista zakupów",
|
||||||
|
"dietTracker": "Moja dieta",
|
||||||
|
"ingredientCatalog": "Składniki",
|
||||||
|
"dietGenerator": "Generator diety",
|
||||||
|
"recipeGenerator": "Generator przepisów",
|
||||||
|
"logout": "Wyloguj",
|
||||||
|
"user": "Użytkownik",
|
||||||
|
"toggleNav": "Przełącz nawigację",
|
||||||
|
"toggleTheme": "Przełącz motyw",
|
||||||
|
"lightMode": "Przełącz na jasny motyw",
|
||||||
|
"darkMode": "Przełącz na ciemny motyw"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"breakfast": "Śniadanie",
|
||||||
|
"secondBreakfast": "Drugie śniadanie",
|
||||||
|
"lunch": "Obiad",
|
||||||
|
"dinner": "Kolacja",
|
||||||
|
"unknown": "Nieznana"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Ładowanie...",
|
||||||
|
"tryAgain": "Spróbuj ponownie",
|
||||||
|
"back": "Wstecz",
|
||||||
|
"add": "Dodaj",
|
||||||
|
"category": "Kategoria",
|
||||||
|
"all": "Wszystkie",
|
||||||
|
"search": "Szukaj",
|
||||||
|
"save": "Zapisz",
|
||||||
|
"cancel": "Anuluj",
|
||||||
|
"edit": "Edytuj",
|
||||||
|
"delete": "Usuń",
|
||||||
|
"optional": "opcjonalnie",
|
||||||
|
"recipeCount_one": "{{count}} przepis",
|
||||||
|
"recipeCount_few": "{{count}} przepisy",
|
||||||
|
"recipeCount_many": "{{count}} przepisów",
|
||||||
|
"recipeCount_other": "{{count}} przepisów",
|
||||||
|
"selectedCount_one": "Wybrano: {{count}}",
|
||||||
|
"selectedCount_few": "Wybrano: {{count}}",
|
||||||
|
"selectedCount_many": "Wybrano: {{count}}",
|
||||||
|
"selectedCount_other": "Wybrano: {{count}}"
|
||||||
|
},
|
||||||
|
"auth": {
|
||||||
|
"signInSubtitle": "Zaloguj się, aby planować posiłki",
|
||||||
|
"email": "E-mail",
|
||||||
|
"password": "Hasło",
|
||||||
|
"emailPlaceholder": "ty@example.com",
|
||||||
|
"signIn": "Zaloguj się",
|
||||||
|
"signingIn": "Logowanie...",
|
||||||
|
"noAccount": "Nie masz konta?",
|
||||||
|
"createOne": "Utwórz je",
|
||||||
|
"createAccountTitle": "Utwórz konto",
|
||||||
|
"createAccountSubtitle": "Dołącz do DailyMeals, aby przeglądać i planować posiłki",
|
||||||
|
"displayName": "Nazwa wyświetlana",
|
||||||
|
"displayNamePlaceholder": "Piotr",
|
||||||
|
"confirmPassword": "Potwierdź hasło",
|
||||||
|
"passwordHint": "Co najmniej 8 znaków, w tym wielka litera, mała litera i cyfra.",
|
||||||
|
"createAccount": "Utwórz konto",
|
||||||
|
"creatingAccount": "Tworzenie konta...",
|
||||||
|
"hasAccount": "Masz już konto?"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"emailRequired": "E-mail jest wymagany.",
|
||||||
|
"emailInvalid": "Podaj prawidłowy adres e-mail.",
|
||||||
|
"passwordRequired": "Hasło jest wymagane.",
|
||||||
|
"displayNameTooLong": "Nazwa wyświetlana jest zbyt długa.",
|
||||||
|
"passwordMinLength": "Hasło musi mieć co najmniej 8 znaków.",
|
||||||
|
"passwordUppercase": "Dołącz co najmniej jedną wielką literę.",
|
||||||
|
"passwordLowercase": "Dołącz co najmniej jedną małą literę.",
|
||||||
|
"passwordDigit": "Dołącz co najmniej jedną cyfrę.",
|
||||||
|
"confirmPasswordRequired": "Potwierdź hasło.",
|
||||||
|
"passwordsMismatch": "Hasła nie są identyczne.",
|
||||||
|
"recipeNameRequired": "Nazwa przepisu jest wymagana.",
|
||||||
|
"ingredientNameRequired": "Nazwa składnika jest wymagana.",
|
||||||
|
"stepNumberMin": "Numer kroku musi być co najmniej 1.",
|
||||||
|
"stepDescriptionRequired": "Opis kroku jest wymagany.",
|
||||||
|
"stepsMin": "Dodaj co najmniej jeden krok przygotowania."
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"generic": "Coś poszło nie tak.",
|
||||||
|
"signInFailed": "Nie udało się zalogować.",
|
||||||
|
"registerFailed": "Nie udało się utworzyć konta.",
|
||||||
|
"loadCategoriesFailed": "Nie udało się załadować kategorii.",
|
||||||
|
"loadRecipesFailed": "Nie udało się załadować przepisów.",
|
||||||
|
"loadRecipeFailed": "Nie udało się załadować przepisu.",
|
||||||
|
"loadDietFailed": "Nie udało się załadować danych diety.",
|
||||||
|
"loadMealPlanFailed": "Nie udało się załadować planu posiłków.",
|
||||||
|
"loadShoppingListFailed": "Nie udało się załadować listy zakupów.",
|
||||||
|
"loadIngredientCatalogFailed": "Nie udało się załadować katalogu składników.",
|
||||||
|
"saveIngredientFailed": "Nie udało się zapisać składnika.",
|
||||||
|
"saveRecipeFailed": "Nie udało się zapisać przepisu.",
|
||||||
|
"recalculateMacrosFailed": "Nie udało się przeliczyć makroskładników przepisów.",
|
||||||
|
"importFailed": "Import nie powiódł się.",
|
||||||
|
"pdfLoadFailed": "Nie udało się załadować przepisów do eksportu.",
|
||||||
|
"pdfGenerateFailed": "Generowanie PDF nie powiodło się.",
|
||||||
|
"pdfRenderAreaMissing": "Nie znaleziono obszaru renderowania PDF.",
|
||||||
|
"pdfNothingToExport": "Brak danych do eksportu."
|
||||||
|
},
|
||||||
|
"dashboard": {
|
||||||
|
"welcome": "Witaj ponownie",
|
||||||
|
"welcomeNamed": "Witaj ponownie, {{name}}",
|
||||||
|
"subtitle": "Przeglądaj przepisy według kategorii posiłków lub odkrywaj wszystkie naraz.",
|
||||||
|
"viewRecipes": "Zobacz przepisy"
|
||||||
|
},
|
||||||
|
"recipes": {
|
||||||
|
"allMealsTitle": "Wszystkie posiłki",
|
||||||
|
"allMealsSubtitle": "Wybierz przepisy i wyeksportuj je wraz ze wspólną listą zakupów.",
|
||||||
|
"selectAllVisible": "Zaznacz widoczne",
|
||||||
|
"deselectAll": "Odznacz wszystkie",
|
||||||
|
"searchRecipes": "Szukaj przepisów...",
|
||||||
|
"searchByIngredient": "Szukaj po składniku...",
|
||||||
|
"searchByIngredientLabel": "Szukaj po składniku",
|
||||||
|
"searchByNameAria": "Szukaj przepisów po nazwie",
|
||||||
|
"searchByIngredientAria": "Szukaj przepisów po składniku",
|
||||||
|
"noMatchingTitle": "Brak pasujących przepisów",
|
||||||
|
"noMatchingIngredient": "Żaden przepis nie zawiera tego składnika. Spróbuj innej nazwy.",
|
||||||
|
"noMatchingFilter": "Spróbuj zmienić wyszukiwanie lub filtr kategorii.",
|
||||||
|
"emptyCategoryTitle": "Brak przepisów: {{category}}",
|
||||||
|
"emptyCategoryDescription": "W tej kategorii nie ma jeszcze żadnych przepisów.",
|
||||||
|
"selectRecipe": "Wybierz {{name}}",
|
||||||
|
"prepTime": "{{count}} min przygotowania",
|
||||||
|
"calories": "Kalorie",
|
||||||
|
"protein": "Białko",
|
||||||
|
"fat": "Tłuszcz",
|
||||||
|
"carbs": "Węglowodany",
|
||||||
|
"ingredients": "Składniki",
|
||||||
|
"preparation": "Przygotowanie",
|
||||||
|
"noIngredients": "Brak składników.",
|
||||||
|
"noSteps": "Brak kroków.",
|
||||||
|
"kcal": "{{count}} kcal",
|
||||||
|
"minutes": "{{count}} min"
|
||||||
|
},
|
||||||
|
"manage": {
|
||||||
|
"title": "Zarządzaj posiłkami",
|
||||||
|
"subtitle": "Dodawaj przepisy ręcznie lub importuj je z arkusza Excel.",
|
||||||
|
"tabManual": "Dodaj ręcznie",
|
||||||
|
"tabEdit": "Edytuj przepisy",
|
||||||
|
"tabImport": "Import Excel",
|
||||||
|
"savedSuccess": "„{{name}}” zapisano pomyślnie.",
|
||||||
|
"basicInfo": "Podstawowe informacje",
|
||||||
|
"recipeName": "Nazwa przepisu",
|
||||||
|
"recipeNamePlaceholder": "Sałatka z grillowanym kurczakiem",
|
||||||
|
"prepTimeMin": "Czas przygotowania (min)",
|
||||||
|
"caloriesKcal": "Kalorie (kcal)",
|
||||||
|
"proteinG": "Białko (g)",
|
||||||
|
"fatG": "Tłuszcz (g)",
|
||||||
|
"carbsG": "Węglowodany (g)",
|
||||||
|
"preparationSteps": "Kroki przygotowania",
|
||||||
|
"addStep": "Dodaj krok",
|
||||||
|
"stepPlaceholder": "Opisz ten krok...",
|
||||||
|
"ingredientName": "Nazwa",
|
||||||
|
"ingredientGrams": "Gramy",
|
||||||
|
"ingredientUnit": "Jednostka",
|
||||||
|
"removeIngredient": "Usuń składnik",
|
||||||
|
"removeStep": "Usuń krok",
|
||||||
|
"saveRecipe": "Zapisz przepis",
|
||||||
|
"updateRecipe": "Aktualizuj przepis",
|
||||||
|
"updatedSuccess": "„{{name}}” zaktualizowano pomyślnie.",
|
||||||
|
"selectRecipeToEdit": "Wybierz przepis z listy, aby go edytować.",
|
||||||
|
"catalogLink": "Powiąż z katalogiem składników",
|
||||||
|
"estimatedFromCatalog": "Szacunkowo z powiązanych składników",
|
||||||
|
"unlinkedIngredientsWarning": "{{count}} składnik(ów) ma gramy, ale brak powiązania z katalogiem — makra mogą być niepełne.",
|
||||||
|
"recalculateMacros": "Przelicz wszystkie makra",
|
||||||
|
"recalculating": "Przeliczanie…",
|
||||||
|
"recalculateSuccess": "Zaktualizowano {{updated}} z {{processed}} przepisów. {{unlinked}} wierszy składników nadal niepowiązanych.",
|
||||||
|
"macroHint": "Szacunkowo: {{calories}} kcal · B {{protein}}g · T {{fat}}g · W {{carbs}}g",
|
||||||
|
"saving": "Zapisywanie...",
|
||||||
|
"importTitle": "Import z Excela",
|
||||||
|
"importDescription": "Użyj szablonu z trzema arkuszami: Recipes, Ingredients i Steps. Nazwy przepisów muszą się zgadzać między arkuszami.",
|
||||||
|
"downloadTemplate": "Pobierz szablon",
|
||||||
|
"uploadLabel": "Prześlij wypełniony plik .xlsx",
|
||||||
|
"importComplete": "Import zakończony",
|
||||||
|
"createdCount": "Utworzono: {{count}}",
|
||||||
|
"skippedCount": "Pominięto: {{count}}",
|
||||||
|
"importRecipes": "Importuj przepisy",
|
||||||
|
"importing": "Importowanie...",
|
||||||
|
"excelFormatTitle": "Format Excela",
|
||||||
|
"excelRecipesSheet": "Recipes: Name, MealCategory (Breakfast / Lunch / 0–3), Calories, Protein, Fat, Carbs, PrepTimeMinutes",
|
||||||
|
"excelIngredientsSheet": "Ingredients: RecipeName, Name, AmountGrams, Unit, SortOrder",
|
||||||
|
"excelStepsSheet": "Steps: RecipeName, StepNumber, Description",
|
||||||
|
"duplicateSkipped": "Duplikaty nazw przepisów są pomijane."
|
||||||
|
},
|
||||||
|
"generators": {
|
||||||
|
"diet": {
|
||||||
|
"title": "Generator diety",
|
||||||
|
"subtitle": "Oblicz cele z profilu, zbuduj tygodniowy plan z katalogu przepisów i zapisz w planerze.",
|
||||||
|
"profileTitle": "Twój profil",
|
||||||
|
"heightCm": "Wzrost (cm)",
|
||||||
|
"weightKg": "Waga (kg)",
|
||||||
|
"age": "Wiek",
|
||||||
|
"sex": "Płeć",
|
||||||
|
"male": "Mężczyzna",
|
||||||
|
"female": "Kobieta",
|
||||||
|
"activity": "Poziom aktywności",
|
||||||
|
"sedentary": "Siedzący",
|
||||||
|
"light": "Lekka",
|
||||||
|
"moderate": "Umiarkowana",
|
||||||
|
"active": "Aktywna",
|
||||||
|
"veryActive": "Bardzo aktywna",
|
||||||
|
"goal": "Cel",
|
||||||
|
"loseWeight": "Redukcja wagi",
|
||||||
|
"maintain": "Utrzymanie",
|
||||||
|
"gainMuscle": "Budowa mięśni",
|
||||||
|
"recomp": "Rekompozycja",
|
||||||
|
"allergies": "Alergie / uwagi",
|
||||||
|
"continue": "Oblicz cele",
|
||||||
|
"macrosTitle": "Dzienne cele",
|
||||||
|
"acceptAndGenerate": "Akceptuj i generuj plan 7 dni",
|
||||||
|
"planHint": "Każdy dzień powinien mieścić się w ±10 kcal od celu. Zablokuj lub wygeneruj ponownie posiłki.",
|
||||||
|
"lock": "Zablokuj",
|
||||||
|
"unlock": "Odblokuj",
|
||||||
|
"viewRecipe": "Szczegóły",
|
||||||
|
"noAlternativeMeals": "Brak innych przepisów na ten posiłek — wszystkie pasujące opcje są już w planie.",
|
||||||
|
"commit": "Zapisz plan w kalendarzu",
|
||||||
|
"done": "Plan zapisany w planerze i celach dziennych.",
|
||||||
|
"newPlan": "Utwórz kolejny plan",
|
||||||
|
"error": "Generator diety nie powiódł się."
|
||||||
|
},
|
||||||
|
"recipe": {
|
||||||
|
"title": "Generator przepisów",
|
||||||
|
"subtitle": "Wygeneruj kilka propozycji przepisów z AI, przejrzyj je i zapisz te, które Ci pasują.",
|
||||||
|
"constraintsTitle": "Co ugotować?",
|
||||||
|
"prompt": "Opisz posiłek",
|
||||||
|
"promptPlaceholder": "Lekkie śniadanie z owsianką i owocami…",
|
||||||
|
"targetCalories": "Docelowa kaloryczność (kcal)",
|
||||||
|
"targetCaloriesOptional": "np. 500",
|
||||||
|
"targetCaloriesHint": "Składniki zostaną dopasowane do tej wartości (±10 kcal).",
|
||||||
|
"caloriesWithTarget": "{{actual}} kcal (cel: {{target}})",
|
||||||
|
"maxPrep": "Maks. czas przygotowania (min)",
|
||||||
|
"exclusions": "Wyklucz składniki",
|
||||||
|
"generate": "Generuj przepis",
|
||||||
|
"recipeCount": "Ile przepisów wygenerować",
|
||||||
|
"reviewHint": "Przejrzyj {{count}} wygenerowanych przepisów. Odznacz lub usuń te, których nie chcesz, i zapisz resztę.",
|
||||||
|
"selectAll": "Zaznacz wszystkie",
|
||||||
|
"remove": "Usuń",
|
||||||
|
"saveSelected": "Zapisz zaznaczone ({{count}})",
|
||||||
|
"doneMultiple": "Zapisano {{count}} przepisów w bibliotece.",
|
||||||
|
"generateMore": "Generuj kolejne przepisy",
|
||||||
|
"regenIngredients": "Generuj składniki ponownie",
|
||||||
|
"regenSteps": "Generuj kroki ponownie",
|
||||||
|
"regenFull": "Generuj całość ponownie",
|
||||||
|
"save": "Zapisz w bibliotece",
|
||||||
|
"done": "Przepis zapisany.",
|
||||||
|
"viewRecipe": "Zobacz przepis",
|
||||||
|
"error": "Generator przepisów nie powiódł się."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pdf": {
|
||||||
|
"generate": "Generuj PDF",
|
||||||
|
"preparing": "Przygotowywanie...",
|
||||||
|
"generating": "Generowanie...",
|
||||||
|
"prepTime": "Czas przygotowania: {{count}} min",
|
||||||
|
"shoppingList": "Lista zakupów",
|
||||||
|
"generatedOn_one": "Wygenerowano {{date}} · {{count}} przepis",
|
||||||
|
"generatedOn_few": "Wygenerowano {{date}} · {{count}} przepisy",
|
||||||
|
"generatedOn_many": "Wygenerowano {{date}} · {{count}} przepisów",
|
||||||
|
"generatedOn_other": "Wygenerowano {{date}} · {{count}} przepisów",
|
||||||
|
"recipesInExport": "Przepisy w tym eksporcie",
|
||||||
|
"toTaste": "do smaku",
|
||||||
|
"dayPlanTitle": "Plan posiłków — {{date}}",
|
||||||
|
"emptySlot": "Nie zaplanowano",
|
||||||
|
"emptyShoppingList": "Brak składników do zakupu dla tego dnia.",
|
||||||
|
"plannedMealMeta": "{{slot}} · {{portions}}× porcji",
|
||||||
|
"dayShoppingSubtitle": "Lista na {{date}} · {{count}} posiłków"
|
||||||
|
},
|
||||||
|
"diet": {
|
||||||
|
"title": "Moja dieta",
|
||||||
|
"subtitle": "Śledź posiłki i napoje, ustaw cele dzienne i przypomnienia.",
|
||||||
|
"tabToday": "Dziś",
|
||||||
|
"tabHistory": "Historia",
|
||||||
|
"tabGoals": "Cele",
|
||||||
|
"tabReminders": "Przypomnienia",
|
||||||
|
"goToday": "Dziś",
|
||||||
|
"notToday": "inny dzień",
|
||||||
|
"quickLog": "Szybkie logowanie",
|
||||||
|
"copyYesterday": "Skopiuj wczorajsze posiłki",
|
||||||
|
"recentMeals": "Ostatnie",
|
||||||
|
"favorites": "Ulubione",
|
||||||
|
"days": "dni",
|
||||||
|
"historyCalories": "Kalorie w czasie",
|
||||||
|
"historyMax": "Szczyt: {{max}} kcal",
|
||||||
|
"noHistory": "Brak historii w tym okresie.",
|
||||||
|
"mealsOnDate": "Posiłki {{date}}",
|
||||||
|
"drinksOnDate": "Napoje {{date}}",
|
||||||
|
"editReminder": "Edytuj przypomnienie",
|
||||||
|
"calories": "Kalorie",
|
||||||
|
"water": "Woda",
|
||||||
|
"snack": "Przekąska",
|
||||||
|
"logDrink": "Dodaj napój",
|
||||||
|
"logMeal": "Oznacz zjedzony posiłek",
|
||||||
|
"searchRecipe": "Z przepisu",
|
||||||
|
"orCustomMeal": "Lub wpisz własną nazwę posiłku",
|
||||||
|
"markEaten": "Oznacz jako zjedzone",
|
||||||
|
"mealsToday": "Posiłki dziś",
|
||||||
|
"drinksToday": "Napoje dziś",
|
||||||
|
"nothingLogged": "Nic jeszcze nie zalogowano.",
|
||||||
|
"noMacros": "Brak danych odżywczych",
|
||||||
|
"removeEntry": "Usuń wpis",
|
||||||
|
"dailyGoals": "Cele dzienne",
|
||||||
|
"goalsHint": "Ustaw cele jak w Lifesum lub MyFitnessPal — zakładka Dziś pokaże, co zostało.",
|
||||||
|
"calorieGoal": "Cel kalorii (kcal)",
|
||||||
|
"waterGoal": "Cel wody (ml)",
|
||||||
|
"saveGoals": "Zapisz cele",
|
||||||
|
"addReminder": "Dodaj przypomnienie",
|
||||||
|
"reminderType": "Typ",
|
||||||
|
"reminderWater": "Pij wodę",
|
||||||
|
"reminderMeal": "Pora posiłku",
|
||||||
|
"reminderCustom": "Własne",
|
||||||
|
"reminderTitle": "Tytuł",
|
||||||
|
"reminderTitlePlaceholder": "Wypij szklankę wody",
|
||||||
|
"reminderTime": "Godzina",
|
||||||
|
"saveReminder": "Zapisz przypomnienie",
|
||||||
|
"yourReminders": "Twoje przypomnienia",
|
||||||
|
"noReminders": "Brak przypomnień.",
|
||||||
|
"remaining": "Pozostało",
|
||||||
|
"portions": "Porcje",
|
||||||
|
"fromCatalog": "Z katalogu składników",
|
||||||
|
"noCatalogItem": "Brak",
|
||||||
|
"grams": "Ilość (g)",
|
||||||
|
"mealPreview": "Ten posiłek dodaje",
|
||||||
|
"remainingAfter": "Po zapisaniu zostanie Ci",
|
||||||
|
"suggestionsTitle": "Co warto zjeść dalej",
|
||||||
|
"suggestionsHint": "Na podstawie tego, czego jeszcze brakuje dziś.",
|
||||||
|
"logSuggestion": "Zapisz",
|
||||||
|
"suggestionReasons": {
|
||||||
|
"highProtein": "Dużo białka",
|
||||||
|
"needCarbs": "Dobre węglowodany",
|
||||||
|
"needFat": "Zdrowe tłuszcze",
|
||||||
|
"needCalories": "Energia",
|
||||||
|
"balanced": "Zbilansowany wybór"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"mealPlanner": {
|
||||||
|
"title": "Plan posiłków",
|
||||||
|
"subtitle": "Planuj przepisy na każdy dzień tygodnia.",
|
||||||
|
"today": "Ten tydzień",
|
||||||
|
"addRecipe": "Dodaj przepis",
|
||||||
|
"dailyTarget": "Cel kalorii na dzień",
|
||||||
|
"daySummary": "Zaplanowano {{logged}} / {{target}} kcal",
|
||||||
|
"remaining": "Pozostało {{count}} kcal",
|
||||||
|
"slotHint": "Sugerowane ~{{count}} kcal na ten posiłek",
|
||||||
|
"suggestionsTitle": "Propozycje na ten posiłek",
|
||||||
|
"noSuggestions": "Ustaw cel kalorii na dzień, aby zobaczyć propozycje.",
|
||||||
|
"estimatedMeal": "Ten posiłek: ~{{count}} kcal",
|
||||||
|
"selectedRecipe": "Wybrany: {{name}}",
|
||||||
|
"clearSelection": "Wyczyść wybór",
|
||||||
|
"exportDay": "Eksportuj plan dnia (PDF)",
|
||||||
|
"includingSelection": "w tym bieżący wybór: {{count}} kcal"
|
||||||
|
},
|
||||||
|
"shoppingList": {
|
||||||
|
"title": "Lista zakupów",
|
||||||
|
"subtitle": "Składniki zebrane z planu posiłków.",
|
||||||
|
"from": "Od",
|
||||||
|
"to": "Do",
|
||||||
|
"load": "Załaduj listę",
|
||||||
|
"clearChecks": "Wyczyść zaznaczenia",
|
||||||
|
"empty": "Brak pozycji w tym zakresie dat.",
|
||||||
|
"checklistHint": "Zaznaczenia są zapisywane lokalnie w przeglądarce."
|
||||||
|
},
|
||||||
|
"ingredientCatalog": {
|
||||||
|
"title": "Katalog składników",
|
||||||
|
"subtitle": "Wartości odżywcze na 100 g, pogrupowane według kategorii.",
|
||||||
|
"allCategories": "Wszystkie",
|
||||||
|
"searchPlaceholder": "Szukaj składników…",
|
||||||
|
"noResultsTitle": "Nie znaleziono składników",
|
||||||
|
"noResultsDescription": "Spróbuj innej kategorii lub frazy.",
|
||||||
|
"per100gNote": "Wszystkie wartości dotyczą 100 g.",
|
||||||
|
"addIngredient": "Dodaj składnik",
|
||||||
|
"editIngredient": "Edytuj składnik",
|
||||||
|
"selectCategory": "Wybierz kategorię",
|
||||||
|
"nameEn": "Nazwa (angielski)",
|
||||||
|
"namePlLabel": "Nazwa (polski)",
|
||||||
|
"formInvalid": "Wypełnij kategorię i nazwę.",
|
||||||
|
"deleteConfirm": "Usunąć ten składnik z katalogu?",
|
||||||
|
"columns": {
|
||||||
|
"name": "Składnik",
|
||||||
|
"category": "Kategoria",
|
||||||
|
"calories": "kcal",
|
||||||
|
"protein": "Białko (g)",
|
||||||
|
"fat": "Tłuszcz (g)",
|
||||||
|
"carbs": "Węglowodany (g)",
|
||||||
|
"fiber": "Błonnik (g)"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"meat": "Mięso",
|
||||||
|
"poultry": "Drób",
|
||||||
|
"fish": "Ryby",
|
||||||
|
"seafood": "Owoce morza",
|
||||||
|
"vegetables": "Warzywa",
|
||||||
|
"fruits": "Owoce",
|
||||||
|
"dairy": "Nabiał",
|
||||||
|
"eggs": "Jaja",
|
||||||
|
"grains": "Zboża",
|
||||||
|
"legumes": "Rośliny strączkowe",
|
||||||
|
"nuts": "Orzechy i nasiona",
|
||||||
|
"oils": "Oleje i tłuszcze"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notFound": {
|
||||||
|
"title": "Nie znaleziono strony",
|
||||||
|
"description": "Strona, której szukasz, nie istnieje.",
|
||||||
|
"backToDashboard": "Wróć do panelu"
|
||||||
|
}
|
||||||
|
}
|
||||||
10
meal-plan-frontend-angular/src/app/app.component.ts
Normal file
10
meal-plan-frontend-angular/src/app/app.component.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Component } from '@angular/core';
|
||||||
|
import { RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-root',
|
||||||
|
standalone: true,
|
||||||
|
imports: [RouterOutlet],
|
||||||
|
template: `<router-outlet />`,
|
||||||
|
})
|
||||||
|
export class AppComponent {}
|
||||||
43
meal-plan-frontend-angular/src/app/app.config.ts
Normal file
43
meal-plan-frontend-angular/src/app/app.config.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { APP_INITIALIZER, ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||||
|
import { provideRouter } from '@angular/router';
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
|
||||||
|
import { HTTP_INTERCEPTORS } from '@angular/common/http';
|
||||||
|
import { provideTranslateService } from '@ngx-translate/core';
|
||||||
|
import { provideTranslateHttpLoader } from '@ngx-translate/http-loader';
|
||||||
|
|
||||||
|
import { routes } from './app.routes';
|
||||||
|
import { AuthInterceptor } from './core/interceptors/auth.interceptor';
|
||||||
|
import { AppearanceService } from './core/services/appearance.service';
|
||||||
|
import { AuthService } from './core/services/auth.service';
|
||||||
|
import { LanguageService } from './core/services/language.service';
|
||||||
|
|
||||||
|
export const appConfig: ApplicationConfig = {
|
||||||
|
providers: [
|
||||||
|
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||||
|
provideRouter(routes),
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
{ provide: HTTP_INTERCEPTORS, useClass: AuthInterceptor, multi: true },
|
||||||
|
...provideTranslateService({
|
||||||
|
fallbackLang: 'en',
|
||||||
|
loader: provideTranslateHttpLoader({ prefix: './i18n/', suffix: '.json' }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
useFactory: (language: LanguageService) => () => language.init(),
|
||||||
|
deps: [LanguageService],
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
useFactory: (auth: AuthService) => () => auth.restoreSession(),
|
||||||
|
deps: [AuthService],
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: APP_INITIALIZER,
|
||||||
|
useFactory: (appearance: AppearanceService) => () => appearance.init(),
|
||||||
|
deps: [AppearanceService],
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
48
meal-plan-frontend-angular/src/app/app.routes.ts
Normal file
48
meal-plan-frontend-angular/src/app/app.routes.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { Routes } from '@angular/router';
|
||||||
|
import { authGuard, guestGuard } from './core/guards/auth.guard';
|
||||||
|
import { AppLayoutComponent } from './layout/app-layout.component';
|
||||||
|
import { LoginComponent } from './features/auth/login.component';
|
||||||
|
import { RegisterComponent } from './features/auth/register.component';
|
||||||
|
import { DashboardComponent } from './features/dashboard/dashboard.component';
|
||||||
|
import { BreakfastComponent } from './features/category/breakfast.component';
|
||||||
|
import { SecondBreakfastComponent } from './features/category/second-breakfast.component';
|
||||||
|
import { LunchComponent } from './features/category/lunch.component';
|
||||||
|
import { DinnerComponent } from './features/category/dinner.component';
|
||||||
|
import { AllMealsComponent } from './features/all-meals/all-meals.component';
|
||||||
|
import { ManageMealsComponent } from './features/manage-meals/manage-meals.component';
|
||||||
|
import { IngredientCatalogComponent } from './features/ingredient-catalog/ingredient-catalog.component';
|
||||||
|
import { DietTrackerComponent } from './features/diet-tracker/diet-tracker.component';
|
||||||
|
import { MealPlannerComponent } from './features/meal-planner/meal-planner.component';
|
||||||
|
import { ShoppingListComponent } from './features/shopping-list/shopping-list.component';
|
||||||
|
import { RecipeDetailPageComponent } from './features/recipe-detail/recipe-detail-page.component';
|
||||||
|
import { NotFoundComponent } from './features/not-found/not-found.component';
|
||||||
|
import { DietGeneratorComponent } from './features/diet-generator/diet-generator.component';
|
||||||
|
import { RecipeGeneratorComponent } from './features/recipe-generator/recipe-generator.component';
|
||||||
|
|
||||||
|
export const routes: Routes = [
|
||||||
|
{ path: 'login', component: LoginComponent, canActivate: [guestGuard] },
|
||||||
|
{ path: 'register', component: RegisterComponent, canActivate: [guestGuard] },
|
||||||
|
{
|
||||||
|
path: '',
|
||||||
|
component: AppLayoutComponent,
|
||||||
|
canActivate: [authGuard],
|
||||||
|
children: [
|
||||||
|
{ path: '', component: DashboardComponent },
|
||||||
|
{ path: 'breakfast', component: BreakfastComponent },
|
||||||
|
{ path: 'second-breakfast', component: SecondBreakfastComponent },
|
||||||
|
{ path: 'lunch', component: LunchComponent },
|
||||||
|
{ path: 'dinner', component: DinnerComponent },
|
||||||
|
{ path: 'all-meals', component: AllMealsComponent },
|
||||||
|
{ path: 'manage-meals', component: ManageMealsComponent },
|
||||||
|
{ path: 'ingredients', component: IngredientCatalogComponent },
|
||||||
|
{ path: 'diet', component: DietTrackerComponent },
|
||||||
|
{ path: 'meal-planner', component: MealPlannerComponent },
|
||||||
|
{ path: 'diet-generator', component: DietGeneratorComponent },
|
||||||
|
{ path: 'recipe-generator', component: RecipeGeneratorComponent },
|
||||||
|
{ path: 'shopping-list', component: ShoppingListComponent },
|
||||||
|
{ path: 'recipes/:id', component: RecipeDetailPageComponent },
|
||||||
|
{ path: '404', component: NotFoundComponent },
|
||||||
|
{ path: '**', redirectTo: '404' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user