diff --git a/MealPlan.Api/Controllers/DietController.cs b/MealPlan.Api/Controllers/DietController.cs
new file mode 100644
index 0000000..d9471a6
--- /dev/null
+++ b/MealPlan.Api/Controllers/DietController.cs
@@ -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;
+
+ /// Daily intake summary with consumed totals, goals, and remaining amounts.
+ [HttpGet("summary")]
+ [ProducesResponseType(typeof(DailySummaryDto), StatusCodes.Status200OK)]
+ public async Task 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 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 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), StatusCodes.Status200OK)]
+ public async Task 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 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 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), StatusCodes.Status200OK)]
+ public async Task 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 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), StatusCodes.Status200OK)]
+ public async Task GetDrinkCatalog(CancellationToken ct)
+ {
+ var catalog = await _dietService.GetDrinkCatalogAsync(ct);
+ return Ok(catalog);
+ }
+
+ [HttpGet("drinks")]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ public async Task 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 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 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), StatusCodes.Status200OK)]
+ public async Task 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), StatusCodes.Status200OK)]
+ public async Task 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 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 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 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), StatusCodes.Status200OK)]
+ public async Task 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), StatusCodes.Status200OK)]
+ public async Task 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 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), StatusCodes.Status200OK)]
+ public async Task 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 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 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), StatusCodes.Status200OK)]
+ public async Task 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 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 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();
+ }
+}
diff --git a/MealPlan.Api/Controllers/DietGeneratorController.cs b/MealPlan.Api/Controllers/DietGeneratorController.cs
new file mode 100644
index 0000000..50e90c8
--- /dev/null
+++ b/MealPlan.Api/Controllers/DietGeneratorController.cs
@@ -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 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 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 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 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 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 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 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 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 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 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);
+ }
+}
diff --git a/MealPlan.Api/Controllers/IngredientCatalogController.cs b/MealPlan.Api/Controllers/IngredientCatalogController.cs
new file mode 100644
index 0000000..4059e8d
--- /dev/null
+++ b/MealPlan.Api/Controllers/IngredientCatalogController.cs
@@ -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), StatusCodes.Status200OK)]
+ public async Task GetCategories(CancellationToken ct)
+ {
+ var categories = await _catalogService.GetCategoriesAsync(ct);
+ return Ok(categories);
+ }
+
+ [HttpGet]
+ [ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
+ public async Task 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 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 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 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 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." });
+ }
+}
diff --git a/MealPlan.Api/Controllers/MealPlanController.cs b/MealPlan.Api/Controllers/MealPlanController.cs
new file mode 100644
index 0000000..771a2d6
--- /dev/null
+++ b/MealPlan.Api/Controllers/MealPlanController.cs
@@ -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), StatusCodes.Status200OK)]
+ public async Task 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 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 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), StatusCodes.Status200OK)]
+ public async Task 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);
+ }
+}
diff --git a/MealPlan.Api/Controllers/RecipeGeneratorController.cs b/MealPlan.Api/Controllers/RecipeGeneratorController.cs
new file mode 100644
index 0000000..7586597
--- /dev/null
+++ b/MealPlan.Api/Controllers/RecipeGeneratorController.cs
@@ -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 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 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 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 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 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 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 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 });
+ }
+ }
+}
diff --git a/MealPlan.Api/Controllers/RecipeManagementController.cs b/MealPlan.Api/Controllers/RecipeManagementController.cs
new file mode 100644
index 0000000..ca65e49
--- /dev/null
+++ b/MealPlan.Api/Controllers/RecipeManagementController.cs
@@ -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;
+
+/// Endpoints for creating recipes manually and importing from Excel.
+[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;
+ }
+
+ /// Creates a new recipe with ingredients and steps.
+ [HttpPost]
+ [ProducesResponseType(typeof(RecipeDetailDto), StatusCodes.Status201Created)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task Create([FromBody] CreateRecipeDto dto, CancellationToken ct)
+ {
+ var recipe = await _managementService.CreateRecipeAsync(dto, ct);
+ return CreatedAtAction(
+ nameof(RecipesController.GetRecipe),
+ "Recipes",
+ new { id = recipe.Id },
+ recipe);
+ }
+
+ /// Updates an existing recipe (replaces ingredients and steps).
+ [HttpPut("{id:int}")]
+ [ProducesResponseType(typeof(RecipeDetailDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ public async Task 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);
+ }
+
+ /// Downloads an Excel template for bulk recipe import.
+ [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");
+ }
+
+ /// Imports recipes from an uploaded .xlsx file (Recipes + Ingredients + Steps sheets).
+ [HttpPost("import/excel")]
+ [RequestSizeLimit(10_000_000)]
+ [ProducesResponseType(typeof(RecipeImportResultDto), StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task 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);
+ }
+
+ /// Recalculates recipe macros from linked catalog ingredients (all active recipes).
+ [HttpPost("recalculate-macros")]
+ [ProducesResponseType(typeof(RecipeMacroRecalcResultDto), StatusCodes.Status200OK)]
+ public async Task RecalculateMacros(CancellationToken ct)
+ {
+ var result = await _managementService.RecalculateAllMacrosFromCatalogAsync(ct);
+ return Ok(result);
+ }
+}
diff --git a/MealPlan.Api/Controllers/RecipesController.cs b/MealPlan.Api/Controllers/RecipesController.cs
index 74da835..515a5d5 100644
--- a/MealPlan.Api/Controllers/RecipesController.cs
+++ b/MealPlan.Api/Controllers/RecipesController.cs
@@ -14,12 +14,16 @@ public class RecipesController : ControllerBase
public RecipesController(IRecipeService recipeService) => _recipeService = recipeService;
- /// Lists active recipes, optionally filtered by category and/or name search.
+ ///
+ /// Lists active recipes, optionally filtered by category, recipe-name search,
+ /// and/or an ingredient name (returns recipes that contain that ingredient).
+ ///
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList), StatusCodes.Status200OK)]
public async Task GetRecipes(
[FromQuery] int? category,
[FromQuery] string? search,
+ [FromQuery] string? ingredient,
CancellationToken ct)
{
if (category is < 0 or > 3)
@@ -27,7 +31,7 @@ public class RecipesController : ControllerBase
return BadRequest(new { error = "category must be between 0 and 3." });
}
- var recipes = await _recipeService.GetRecipesAsync(category, search, ct);
+ var recipes = await _recipeService.GetRecipesAsync(category, search, ingredient, ct);
return Ok(recipes);
}
diff --git a/MealPlan.Api/DTOs/Diet/DietDtos.cs b/MealPlan.Api/DTOs/Diet/DietDtos.cs
new file mode 100644
index 0000000..ac49cbb
--- /dev/null
+++ b/MealPlan.Api/DTOs/Diet/DietDtos.cs
@@ -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 Meals { get; set; } = Array.Empty();
+ public IReadOnlyList Drinks { get; set; } = Array.Empty();
+}
+
+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; }
+}
diff --git a/MealPlan.Api/DTOs/Generators/GeneratorDtos.cs b/MealPlan.Api/DTOs/Generators/GeneratorDtos.cs
new file mode 100644
index 0000000..e2de93e
--- /dev/null
+++ b/MealPlan.Api/DTOs/Generators/GeneratorDtos.cs
@@ -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 DraftMeals { get; set; } = Array.Empty();
+ public IReadOnlyList DaySummaries { get; set; } = Array.Empty();
+}
+
+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 Ingredients { get; set; } =
+ Array.Empty();
+ public IReadOnlyList Steps { get; set; } =
+ Array.Empty();
+}
+
+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 Ingredients { get; set; } = Array.Empty();
+ public IReadOnlyList Steps { get; set; } = Array.Empty();
+ 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 Drafts { get; set; } = Array.Empty();
+ public int? SavedRecipeId { get; set; }
+ public int GenerationVersion { get; set; }
+}
+
+public class GenerateRecipesRequestDto
+{
+ public int Count { get; set; } = 3;
+}
+
+public class CommitRecipesRequestDto
+{
+ public IReadOnlyList? 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 Saved { get; set; } = Array.Empty();
+}
diff --git a/MealPlan.Api/DTOs/IngredientCatalog/IngredientCatalogDtos.cs b/MealPlan.Api/DTOs/IngredientCatalog/IngredientCatalogDtos.cs
new file mode 100644
index 0000000..8934c2e
--- /dev/null
+++ b/MealPlan.Api/DTOs/IngredientCatalog/IngredientCatalogDtos.cs
@@ -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;
+}
diff --git a/MealPlan.Api/DTOs/MealPlan/MealPlanDtos.cs b/MealPlan.Api/DTOs/MealPlan/MealPlanDtos.cs
new file mode 100644
index 0000000..bc6b614
--- /dev/null
+++ b/MealPlan.Api/DTOs/MealPlan/MealPlanDtos.cs
@@ -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 Breakdown { get; set; } = Array.Empty();
+}
diff --git a/MealPlan.Api/DTOs/Recipe/CreateRecipeDto.cs b/MealPlan.Api/DTOs/Recipe/CreateRecipeDto.cs
new file mode 100644
index 0000000..d540b44
--- /dev/null
+++ b/MealPlan.Api/DTOs/Recipe/CreateRecipeDto.cs
@@ -0,0 +1,59 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace MealPlan.Api.DTOs.Recipe;
+
+/// Payload for creating or updating a recipe with nested ingredients and steps.
+public class CreateRecipeDto
+{
+ [Required]
+ [MaxLength(255)]
+ public string Name { get; set; } = string.Empty;
+
+ /// 0 = Breakfast, 1 = SecondBreakfast, 2 = Lunch, 3 = Dinner.
+ [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 Ingredients { get; set; } = new();
+ public List 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;
+}
diff --git a/MealPlan.Api/DTOs/Recipe/RecipeDetailDto.cs b/MealPlan.Api/DTOs/Recipe/RecipeDetailDto.cs
index 1693b25..0edb241 100644
--- a/MealPlan.Api/DTOs/Recipe/RecipeDetailDto.cs
+++ b/MealPlan.Api/DTOs/Recipe/RecipeDetailDto.cs
@@ -23,6 +23,7 @@ public class IngredientDto
public decimal? AmountGrams { get; set; }
public string? Unit { get; set; }
public int SortOrder { get; set; }
+ public int? CatalogItemId { get; set; }
}
public class RecipeStepDto
diff --git a/MealPlan.Api/DTOs/Recipe/RecipeImportResultDto.cs b/MealPlan.Api/DTOs/Recipe/RecipeImportResultDto.cs
new file mode 100644
index 0000000..12582ff
--- /dev/null
+++ b/MealPlan.Api/DTOs/Recipe/RecipeImportResultDto.cs
@@ -0,0 +1,10 @@
+namespace MealPlan.Api.DTOs.Recipe;
+
+/// Summary returned after an Excel import operation.
+public class RecipeImportResultDto
+{
+ public int CreatedCount { get; set; }
+ public int SkippedCount { get; set; }
+ public List Errors { get; set; } = new();
+ public List CreatedRecipeIds { get; set; } = new();
+}
diff --git a/MealPlan.Api/DTOs/Recipe/RecipeMacroRecalcResultDto.cs b/MealPlan.Api/DTOs/Recipe/RecipeMacroRecalcResultDto.cs
new file mode 100644
index 0000000..e98174e
--- /dev/null
+++ b/MealPlan.Api/DTOs/Recipe/RecipeMacroRecalcResultDto.cs
@@ -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; }
+}
diff --git a/MealPlan.Api/Data/AppDbContext.cs b/MealPlan.Api/Data/AppDbContext.cs
index 9f8602c..e8d3ae5 100644
--- a/MealPlan.Api/Data/AppDbContext.cs
+++ b/MealPlan.Api/Data/AppDbContext.cs
@@ -17,6 +17,23 @@ public class AppDbContext : DbContext
public DbSet Ingredients => Set();
public DbSet RecipeSteps => Set();
public DbSet Users => Set();
+ public DbSet UserDailyGoals => Set();
+ public DbSet DrinkCatalog => Set();
+ public DbSet MealConsumptions => Set();
+ public DbSet DrinkConsumptions => Set();
+ public DbSet DietReminders => Set();
+ public DbSet IngredientCategories => Set();
+ public DbSet IngredientNutritionCatalog => Set();
+ public DbSet RefreshTokens => Set();
+ public DbSet MealPlanEntries => Set();
+ public DbSet UserFavoriteRecipes => Set();
+ public DbSet UserFavoriteCatalogItems => Set();
+ public DbSet DietUserProfiles => Set();
+ public DbSet DietGeneratorSessions => Set();
+ public DbSet DietGeneratorDraftMeals => Set();
+ public DbSet DietGeneratorDraftMealIngredients =>
+ Set();
+ public DbSet RecipeGeneratorSessions => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -48,6 +65,10 @@ public class AppDbContext : DbContext
e.HasKey(i => i.Id);
e.Property(i => i.Id).UseIdentityByDefaultColumn();
e.Property(i => i.AmountGrams).HasColumnType("numeric(8,2)");
+ e.HasOne(i => i.CatalogItem)
+ .WithMany()
+ .HasForeignKey(i => i.CatalogItemId)
+ .OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity(e =>
@@ -61,5 +82,180 @@ public class AppDbContext : DbContext
e.HasKey(u => u.Id);
e.Property(u => u.Id).HasColumnType("uuid");
});
+
+ modelBuilder.Entity(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(e =>
+ {
+ e.HasKey(d => d.Id);
+ e.Property(d => d.Id).UseIdentityByDefaultColumn();
+ e.Property(d => d.CaloriesPer100Ml).HasColumnType("numeric(6,2)");
+ });
+
+ modelBuilder.Entity(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(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(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(e =>
+ {
+ e.HasKey(c => c.Id);
+ e.Property(c => c.Id).UseIdentityByDefaultColumn();
+ });
+
+ modelBuilder.Entity(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(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(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(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(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(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(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(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(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(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);
+ });
}
}
diff --git a/MealPlan.Api/Data/SequenceMaintenance.cs b/MealPlan.Api/Data/SequenceMaintenance.cs
new file mode 100644
index 0000000..e8b0742
--- /dev/null
+++ b/MealPlan.Api/Data/SequenceMaintenance.cs
@@ -0,0 +1,50 @@
+using Microsoft.EntityFrameworkCore;
+
+namespace MealPlan.Api.Data;
+
+///
+/// Resyncs PostgreSQL identity sequences after bulk imports or manual ID inserts.
+///
+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> ResyncIdentitySequencesAsync(
+ AppDbContext db,
+ CancellationToken ct = default)
+ {
+ var messages = new List();
+
+ 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;
+ }
+}
diff --git a/MealPlan.Api/Extensions/ClaimsPrincipalExtensions.cs b/MealPlan.Api/Extensions/ClaimsPrincipalExtensions.cs
new file mode 100644
index 0000000..7fa1498
--- /dev/null
+++ b/MealPlan.Api/Extensions/ClaimsPrincipalExtensions.cs
@@ -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;
+ }
+}
diff --git a/MealPlan.Api/MealPlan.Api.csproj b/MealPlan.Api/MealPlan.Api.csproj
index 13c31d3..1915762 100644
--- a/MealPlan.Api/MealPlan.Api.csproj
+++ b/MealPlan.Api/MealPlan.Api.csproj
@@ -10,6 +10,7 @@
+
diff --git a/MealPlan.Api/Models/DietEnums.cs b/MealPlan.Api/Models/DietEnums.cs
new file mode 100644
index 0000000..79ebbf2
--- /dev/null
+++ b/MealPlan.Api/Models/DietEnums.cs
@@ -0,0 +1,41 @@
+namespace MealPlan.Api.Models;
+
+/// Meal slot when logging consumption. Extends recipe categories with snack.
+public enum ConsumptionMealCategory
+{
+ Breakfast = 0,
+ SecondBreakfast = 1,
+ Lunch = 2,
+ Dinner = 3,
+ Snack = 4,
+}
+
+public enum DietReminderType
+{
+ Water = 0,
+ Meal = 1,
+ Snack = 2,
+ Custom = 3,
+}
+
+/// Bitmask for reminder days: Sun=1, Mon=2, Tue=4, Wed=8, Thu=16, Fri=32, Sat=64.
+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;
+ }
+}
diff --git a/MealPlan.Api/Models/DietGeneratorDraftMeal.cs b/MealPlan.Api/Models/DietGeneratorDraftMeal.cs
new file mode 100644
index 0000000..383c859
--- /dev/null
+++ b/MealPlan.Api/Models/DietGeneratorDraftMeal.cs
@@ -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 PlannedIngredients { get; set; } =
+ new List();
+}
diff --git a/MealPlan.Api/Models/DietGeneratorDraftMealIngredient.cs b/MealPlan.Api/Models/DietGeneratorDraftMealIngredient.cs
new file mode 100644
index 0000000..64a83e4
--- /dev/null
+++ b/MealPlan.Api/Models/DietGeneratorDraftMealIngredient.cs
@@ -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; }
+}
diff --git a/MealPlan.Api/Models/DietGeneratorSession.cs b/MealPlan.Api/Models/DietGeneratorSession.cs
new file mode 100644
index 0000000..f48e664
--- /dev/null
+++ b/MealPlan.Api/Models/DietGeneratorSession.cs
@@ -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 DraftMeals { get; set; } = new List();
+}
+
+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";
+}
diff --git a/MealPlan.Api/Models/DietReminder.cs b/MealPlan.Api/Models/DietReminder.cs
new file mode 100644
index 0000000..654b1f0
--- /dev/null
+++ b/MealPlan.Api/Models/DietReminder.cs
@@ -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; }
+}
diff --git a/MealPlan.Api/Models/DietUserProfile.cs b/MealPlan.Api/Models/DietUserProfile.cs
new file mode 100644
index 0000000..eaa450f
--- /dev/null
+++ b/MealPlan.Api/Models/DietUserProfile.cs
@@ -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; }
+}
diff --git a/MealPlan.Api/Models/DrinkCatalogItem.cs b/MealPlan.Api/Models/DrinkCatalogItem.cs
new file mode 100644
index 0000000..5189469
--- /dev/null
+++ b/MealPlan.Api/Models/DrinkCatalogItem.cs
@@ -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; }
+}
diff --git a/MealPlan.Api/Models/DrinkConsumption.cs b/MealPlan.Api/Models/DrinkConsumption.cs
new file mode 100644
index 0000000..03d2f73
--- /dev/null
+++ b/MealPlan.Api/Models/DrinkConsumption.cs
@@ -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; }
+}
diff --git a/MealPlan.Api/Models/Ingredient.cs b/MealPlan.Api/Models/Ingredient.cs
index 0dc6bbd..950f29a 100644
--- a/MealPlan.Api/Models/Ingredient.cs
+++ b/MealPlan.Api/Models/Ingredient.cs
@@ -29,5 +29,9 @@ public class Ingredient
[Column("SortOrder")]
public int SortOrder { get; set; }
+ [Column("CatalogItemId")]
+ public int? CatalogItemId { get; set; }
+
public Recipe? Recipe { get; set; }
+ public IngredientNutritionCatalogItem? CatalogItem { get; set; }
}
diff --git a/MealPlan.Api/Models/IngredientCategory.cs b/MealPlan.Api/Models/IngredientCategory.cs
new file mode 100644
index 0000000..65b38ef
--- /dev/null
+++ b/MealPlan.Api/Models/IngredientCategory.cs
@@ -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 Items { get; set; } = new List();
+}
diff --git a/MealPlan.Api/Models/IngredientNutritionCatalogItem.cs b/MealPlan.Api/Models/IngredientNutritionCatalogItem.cs
new file mode 100644
index 0000000..e073fb3
--- /dev/null
+++ b/MealPlan.Api/Models/IngredientNutritionCatalogItem.cs
@@ -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; }
+}
diff --git a/MealPlan.Api/Models/MealConsumption.cs b/MealPlan.Api/Models/MealConsumption.cs
new file mode 100644
index 0000000..3e2f836
--- /dev/null
+++ b/MealPlan.Api/Models/MealConsumption.cs
@@ -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; }
+}
diff --git a/MealPlan.Api/Models/MealPlanEntry.cs b/MealPlan.Api/Models/MealPlanEntry.cs
new file mode 100644
index 0000000..9d9d95f
--- /dev/null
+++ b/MealPlan.Api/Models/MealPlanEntry.cs
@@ -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; }
+}
diff --git a/MealPlan.Api/Models/Recipe.cs b/MealPlan.Api/Models/Recipe.cs
index 065b3a9..1f6074f 100644
--- a/MealPlan.Api/Models/Recipe.cs
+++ b/MealPlan.Api/Models/Recipe.cs
@@ -44,6 +44,16 @@ public class Recipe
[Column("IsActive")]
public bool IsActive { get; set; }
+ [Column("Source")]
+ [MaxLength(32)]
+ public string Source { get; set; } = "manual";
+
+ [Column("CreatedByUserId")]
+ public Guid? CreatedByUserId { get; set; }
+
+ [Column("IsDraft")]
+ public bool IsDraft { get; set; }
+
public ICollection Ingredients { get; set; } = new List();
public ICollection Steps { get; set; } = new List();
}
diff --git a/MealPlan.Api/Models/RecipeGeneratorSession.cs b/MealPlan.Api/Models/RecipeGeneratorSession.cs
new file mode 100644
index 0000000..d9939fc
--- /dev/null
+++ b/MealPlan.Api/Models/RecipeGeneratorSession.cs
@@ -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";
+}
diff --git a/MealPlan.Api/Models/RefreshTokenEntity.cs b/MealPlan.Api/Models/RefreshTokenEntity.cs
new file mode 100644
index 0000000..b357851
--- /dev/null
+++ b/MealPlan.Api/Models/RefreshTokenEntity.cs
@@ -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; }
+}
diff --git a/MealPlan.Api/Models/UserDailyGoal.cs b/MealPlan.Api/Models/UserDailyGoal.cs
new file mode 100644
index 0000000..9781b51
--- /dev/null
+++ b/MealPlan.Api/Models/UserDailyGoal.cs
@@ -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; }
+}
diff --git a/MealPlan.Api/Models/UserFavoriteCatalogItem.cs b/MealPlan.Api/Models/UserFavoriteCatalogItem.cs
new file mode 100644
index 0000000..8859bf1
--- /dev/null
+++ b/MealPlan.Api/Models/UserFavoriteCatalogItem.cs
@@ -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; }
+}
diff --git a/MealPlan.Api/Models/UserFavoriteRecipe.cs b/MealPlan.Api/Models/UserFavoriteRecipe.cs
new file mode 100644
index 0000000..b6cb0fb
--- /dev/null
+++ b/MealPlan.Api/Models/UserFavoriteRecipe.cs
@@ -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; }
+}
diff --git a/MealPlan.Api/Program.cs b/MealPlan.Api/Program.cs
index 188260e..dd8fdb0 100644
--- a/MealPlan.Api/Program.cs
+++ b/MealPlan.Api/Program.cs
@@ -62,12 +62,23 @@ builder.Services.AddDbContext(options =>
// ---------------------------------------------------------------------------
// Application services
// ---------------------------------------------------------------------------
-builder.Services.AddSingleton();
+builder.Services.AddSingleton();
builder.Services.AddHostedService();
builder.Services.AddSingleton, PasswordHasher>();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.Configure(
+ builder.Configuration.GetSection(MealPlan.Api.Services.AI.OpenAiOptions.SectionName));
+builder.Services.AddHttpClient();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
// ---------------------------------------------------------------------------
// Authentication / authorization (JWT bearer)
@@ -163,6 +174,19 @@ builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);
var app = builder.Build();
+if (args.Contains("--fix-sequences", StringComparer.OrdinalIgnoreCase))
+{
+ await using var scope = app.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var messages = await SequenceMaintenance.ResyncIdentitySequencesAsync(db);
+ foreach (var message in messages)
+ {
+ Console.WriteLine(message);
+ }
+
+ return;
+}
+
// ---------------------------------------------------------------------------
// HTTP request pipeline
// ---------------------------------------------------------------------------
diff --git a/MealPlan.Api/Services/AI/IOpenAiChatService.cs b/MealPlan.Api/Services/AI/IOpenAiChatService.cs
new file mode 100644
index 0000000..db5e464
--- /dev/null
+++ b/MealPlan.Api/Services/AI/IOpenAiChatService.cs
@@ -0,0 +1,12 @@
+namespace MealPlan.Api.Services.AI;
+
+public interface IOpenAiChatService
+{
+ bool IsConfigured { get; }
+
+ Task CompleteJsonAsync(
+ string systemPrompt,
+ string userPrompt,
+ OpenAiUseCase useCase = OpenAiUseCase.Default,
+ CancellationToken ct = default);
+}
diff --git a/MealPlan.Api/Services/AI/OpenAiChatService.cs b/MealPlan.Api/Services/AI/OpenAiChatService.cs
new file mode 100644
index 0000000..ae4d0c9
--- /dev/null
+++ b/MealPlan.Api/Services/AI/OpenAiChatService.cs
@@ -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 _logger;
+
+ public OpenAiChatService(HttpClient http, IOptions options, ILogger logger)
+ {
+ _http = http;
+ _options = options.Value;
+ _logger = logger;
+ }
+
+ public bool IsConfigured => _options.IsConfigured;
+
+ public async Task 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();
+ }
+}
diff --git a/MealPlan.Api/Services/AI/OpenAiOptions.cs b/MealPlan.Api/Services/AI/OpenAiOptions.cs
new file mode 100644
index 0000000..ec0ed88
--- /dev/null
+++ b/MealPlan.Api/Services/AI/OpenAiOptions.cs
@@ -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;
+
+ /// Fallback when a task-specific model is not set.
+ public string Model { get; set; } = "gpt-4.1-mini";
+
+ /// Macro target refinement (diet generator step 2).
+ public string? MacrosModel { get; set; }
+
+ /// Weekly meal plan recipe selection (diet generator step 3).
+ public string? PlanModel { get; set; }
+
+ /// AI recipe draft generation and partial regen.
+ 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;
+}
diff --git a/MealPlan.Api/Services/DbRefreshTokenStore.cs b/MealPlan.Api/Services/DbRefreshTokenStore.cs
new file mode 100644
index 0000000..beaea41
--- /dev/null
+++ b/MealPlan.Api/Services/DbRefreshTokenStore.cs
@@ -0,0 +1,68 @@
+using MealPlan.Api.Data;
+using MealPlan.Api.Models;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace MealPlan.Api.Services;
+
+/// Persists refresh tokens in PostgreSQL (survives API restarts).
+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();
+ 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();
+ 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();
+ 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();
+ 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,
+ };
+}
diff --git a/MealPlan.Api/Services/DietTrackingService.cs b/MealPlan.Api/Services/DietTrackingService.cs
new file mode 100644
index 0000000..fc3b495
--- /dev/null
+++ b/MealPlan.Api/Services/DietTrackingService.cs
@@ -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 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 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 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> 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 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 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> 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();
+
+ 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 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 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> 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> 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 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 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> 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 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 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 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> 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> 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();
+ 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> 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();
+ var result = new List();
+ 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 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> 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 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 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> 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 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 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;
+}
diff --git a/MealPlan.Api/Services/Generators/AiRecipeJsonParser.cs b/MealPlan.Api/Services/Generators/AiRecipeJsonParser.cs
new file mode 100644
index 0000000..1cceb3c
--- /dev/null
+++ b/MealPlan.Api/Services/Generators/AiRecipeJsonParser.cs
@@ -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();
+}
diff --git a/MealPlan.Api/Services/Generators/CatalogMatchingService.cs b/MealPlan.Api/Services/Generators/CatalogMatchingService.cs
new file mode 100644
index 0000000..f6c5aa0
--- /dev/null
+++ b/MealPlan.Api/Services/Generators/CatalogMatchingService.cs
@@ -0,0 +1,60 @@
+using MealPlan.Api.Data;
+using Microsoft.EntityFrameworkCore;
+
+namespace MealPlan.Api.Services.Generators;
+
+public interface ICatalogMatchingService
+{
+ Task ResolveCatalogItemIdAsync(string nameOrCatalogName, CancellationToken ct = default);
+}
+
+public class CatalogMatchingService : ICatalogMatchingService
+{
+ private readonly AppDbContext _db;
+ private List? _cache;
+
+ public CatalogMatchingService(AppDbContext db) => _db = db;
+
+ public async Task 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> 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();
+ }
+}
diff --git a/MealPlan.Api/Services/Generators/DietGeneratorService.cs b/MealPlan.Api/Services/Generators/DietGeneratorService.cs
new file mode 100644
index 0000000..24ab9bf
--- /dev/null
+++ b/MealPlan.Api/Services/Generators/DietGeneratorService.cs
@@ -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 GetProfileAsync(Guid userId, CancellationToken ct = default);
+ Task UpsertProfileAsync(Guid userId, DietUserProfileDto dto, CancellationToken ct = default);
+ Task CreateSessionAsync(Guid userId, CreateDietGeneratorSessionDto dto, CancellationToken ct = default);
+ Task GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default);
+ Task ProposeMacrosAsync(Guid userId, int sessionId, CancellationToken ct = default);
+ Task AcceptMacrosAsync(Guid userId, int sessionId, AcceptMacrosDto dto, CancellationToken ct = default);
+ Task GeneratePlanAsync(Guid userId, int sessionId, CancellationToken ct = default);
+ Task RegenerateMealAsync(Guid userId, int sessionId, RegenerateDietMealDto dto, CancellationToken ct = default);
+ Task UpdateDraftMealAsync(Guid userId, int sessionId, int mealId, UpdateDraftMealDto dto, CancellationToken ct = default);
+ Task 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 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 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 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 GetSessionAsync(Guid userId, int sessionId, CancellationToken ct = default)
+ {
+ await BackfillMissingDraftIngredientsAsync(userId, sessionId, ct);
+ return await MapSessionAsync(sessionId, userId, ct);
+ }
+
+ public async Task 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 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 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();
+ var allSlots = new List();
+
+ 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 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 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 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