* Extended functionalities

* Added different UIs
This commit is contained in:
2026-06-24 21:43:40 +02:00
parent 282f4864c4
commit f15bb7a916
348 changed files with 59057 additions and 498 deletions

View File

@@ -0,0 +1,284 @@
using MealPlan.Api.Extensions;
using MealPlan.Api.DTOs.Diet;
using MealPlan.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MealPlan.Api.Controllers;
[ApiController]
[Route("api/diet")]
[Authorize]
public class DietController : ControllerBase
{
private readonly IDietTrackingService _dietService;
public DietController(IDietTrackingService dietService) => _dietService = dietService;
/// <summary>Daily intake summary with consumed totals, goals, and remaining amounts.</summary>
[HttpGet("summary")]
[ProducesResponseType(typeof(DailySummaryDto), StatusCodes.Status200OK)]
public async Task<IActionResult> GetSummary([FromQuery] DateOnly? date, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
var summary = await _dietService.GetDailySummaryAsync(userId, targetDate, ct);
return Ok(summary);
}
[HttpGet("goals")]
[ProducesResponseType(typeof(UserDailyGoalsDto), StatusCodes.Status200OK)]
public async Task<IActionResult> GetGoals(CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var goals = await _dietService.GetGoalsAsync(userId, ct);
return Ok(goals ?? new UserDailyGoalsDto());
}
[HttpPut("goals")]
[ProducesResponseType(typeof(UserDailyGoalsDto), StatusCodes.Status200OK)]
public async Task<IActionResult> UpsertGoals([FromBody] UserDailyGoalsDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var goals = await _dietService.UpsertGoalsAsync(userId, dto, ct);
return Ok(goals);
}
[HttpGet("meals")]
[ProducesResponseType(typeof(IReadOnlyList<MealConsumptionDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetMeals([FromQuery] DateOnly? date, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
var meals = await _dietService.GetMealsAsync(userId, targetDate, ct);
return Ok(meals);
}
[HttpPost("meals")]
[ProducesResponseType(typeof(MealConsumptionDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> LogMeal([FromBody] LogMealDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var meal = await _dietService.LogMealAsync(userId, dto, ct);
if (meal is null)
{
return dto.RecipeId.HasValue
? NotFound(new { error = $"Recipe {dto.RecipeId} was not found." })
: BadRequest(new { error = "Invalid meal log payload." });
}
return CreatedAtAction(nameof(GetMeals), new { date = meal.LogDate }, meal);
}
[HttpPost("meals/preview")]
[ProducesResponseType(typeof(MealPreviewDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> PreviewMeal([FromQuery] DateOnly? date, [FromBody] LogMealDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
var preview = await _dietService.PreviewMealAsync(userId, targetDate, dto, ct);
return preview is null
? BadRequest(new { error = "Could not preview meal macros." })
: Ok(preview);
}
[HttpGet("suggestions")]
[ProducesResponseType(typeof(IReadOnlyList<DietSuggestionDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetSuggestions([FromQuery] DateOnly? date, [FromQuery] int limit = 5, CancellationToken ct = default)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
var suggestions = await _dietService.GetMealSuggestionsAsync(userId, targetDate, limit, ct);
return Ok(suggestions);
}
[HttpDelete("meals/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteMeal(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var deleted = await _dietService.DeleteMealAsync(userId, id, ct);
return deleted ? NoContent() : NotFound(new { error = $"Meal log {id} was not found." });
}
[HttpGet("drinks/catalog")]
[ProducesResponseType(typeof(IReadOnlyList<DrinkCatalogItemDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetDrinkCatalog(CancellationToken ct)
{
var catalog = await _dietService.GetDrinkCatalogAsync(ct);
return Ok(catalog);
}
[HttpGet("drinks")]
[ProducesResponseType(typeof(IReadOnlyList<DrinkConsumptionDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetDrinks([FromQuery] DateOnly? date, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
var drinks = await _dietService.GetDrinksAsync(userId, targetDate, ct);
return Ok(drinks);
}
[HttpPost("drinks")]
[ProducesResponseType(typeof(DrinkConsumptionDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> LogDrink([FromBody] LogDrinkDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var drink = await _dietService.LogDrinkAsync(userId, dto, ct);
if (drink is null)
{
return NotFound(new { error = "Drink catalog item was not found." });
}
return CreatedAtAction(nameof(GetDrinks), new { date = drink.LogDate }, drink);
}
[HttpDelete("drinks/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteDrink(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var deleted = await _dietService.DeleteDrinkAsync(userId, id, ct);
return deleted ? NoContent() : NotFound(new { error = $"Drink log {id} was not found." });
}
[HttpGet("reminders")]
[ProducesResponseType(typeof(IReadOnlyList<DietReminderDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetReminders(CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var reminders = await _dietService.GetRemindersAsync(userId, ct);
return Ok(reminders);
}
[HttpGet("reminders/due")]
[ProducesResponseType(typeof(IReadOnlyList<DueReminderDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetDueReminders(
[FromQuery] DateOnly? date,
[FromQuery] TimeOnly? time,
CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var due = await _dietService.GetDueRemindersAsync(userId, date, time, ct);
return Ok(due);
}
[HttpPost("reminders")]
[ProducesResponseType(typeof(DietReminderDto), StatusCodes.Status201Created)]
public async Task<IActionResult> CreateReminder([FromBody] CreateDietReminderDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var reminder = await _dietService.CreateReminderAsync(userId, dto, ct);
return CreatedAtAction(nameof(GetReminders), reminder);
}
[HttpPut("reminders/{id:int}")]
[ProducesResponseType(typeof(DietReminderDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateReminder(int id, [FromBody] UpdateDietReminderDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var reminder = await _dietService.UpdateReminderAsync(userId, id, dto, ct);
return reminder is null
? NotFound(new { error = $"Reminder {id} was not found." })
: Ok(reminder);
}
[HttpDelete("reminders/{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteReminder(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var deleted = await _dietService.DeleteReminderAsync(userId, id, ct);
return deleted ? NoContent() : NotFound(new { error = $"Reminder {id} was not found." });
}
[HttpGet("history")]
[ProducesResponseType(typeof(IReadOnlyList<HistoryDayDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetHistory([FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var history = await _dietService.GetHistoryAsync(userId, from, to, ct);
return Ok(history);
}
[HttpGet("meals/recent")]
[ProducesResponseType(typeof(IReadOnlyList<RecentMealDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetRecentMeals([FromQuery] int limit = 10, CancellationToken ct = default)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var meals = await _dietService.GetRecentMealsAsync(userId, limit, ct);
return Ok(meals);
}
[HttpPost("meals/copy-yesterday")]
[ProducesResponseType(typeof(CopyMealsResultDto), StatusCodes.Status200OK)]
public async Task<IActionResult> CopyYesterdayMeals([FromQuery] DateOnly? date, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
var result = await _dietService.CopyYesterdayMealsAsync(userId, targetDate, ct);
return Ok(result);
}
[HttpGet("favorites/recipes")]
[ProducesResponseType(typeof(IReadOnlyList<FavoriteRecipeDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetFavoriteRecipes(CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
return Ok(await _dietService.GetFavoriteRecipesAsync(userId, ct));
}
[HttpPost("favorites/recipes/{recipeId:int}")]
[ProducesResponseType(typeof(FavoriteRecipeDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AddFavoriteRecipe(int recipeId, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var fav = await _dietService.AddFavoriteRecipeAsync(userId, recipeId, ct);
return fav is null ? NotFound() : Ok(fav);
}
[HttpDelete("favorites/recipes/{recipeId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> RemoveFavoriteRecipe(int recipeId, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var deleted = await _dietService.RemoveFavoriteRecipeAsync(userId, recipeId, ct);
return deleted ? NoContent() : NotFound();
}
[HttpGet("favorites/catalog")]
[ProducesResponseType(typeof(IReadOnlyList<FavoriteCatalogItemDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetFavoriteCatalogItems(CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
return Ok(await _dietService.GetFavoriteCatalogItemsAsync(userId, ct));
}
[HttpPost("favorites/catalog/{catalogItemId:int}")]
[ProducesResponseType(typeof(FavoriteCatalogItemDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> AddFavoriteCatalogItem(int catalogItemId, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var fav = await _dietService.AddFavoriteCatalogItemAsync(userId, catalogItemId, ct);
return fav is null ? NotFound() : Ok(fav);
}
[HttpDelete("favorites/catalog/{catalogItemId:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<IActionResult> RemoveFavoriteCatalogItem(int catalogItemId, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var deleted = await _dietService.RemoveFavoriteCatalogItemAsync(userId, catalogItemId, ct);
return deleted ? NoContent() : NotFound();
}
}

View File

@@ -0,0 +1,128 @@
using MealPlan.Api.DTOs.Generators;
using MealPlan.Api.Extensions;
using MealPlan.Api.Services.Generators;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MealPlan.Api.Controllers;
[ApiController]
[Route("api/diet-generator")]
[Authorize]
public class DietGeneratorController : ControllerBase
{
private readonly IDietGeneratorService _service;
public DietGeneratorController(IDietGeneratorService service) => _service = service;
[HttpGet("profile")]
[ProducesResponseType(typeof(DietUserProfileDto), StatusCodes.Status200OK)]
public async Task<IActionResult> GetProfile(CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var profile = await _service.GetProfileAsync(userId, ct);
return Ok(profile ?? new DietUserProfileDto());
}
[HttpPut("profile")]
[ProducesResponseType(typeof(DietUserProfileDto), StatusCodes.Status200OK)]
public async Task<IActionResult> UpsertProfile([FromBody] DietUserProfileDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
return Ok(await _service.UpsertProfileAsync(userId, dto, ct));
}
[HttpPost("sessions")]
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status201Created)]
public async Task<IActionResult> CreateSession([FromBody] CreateDietGeneratorSessionDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
try
{
var session = await _service.CreateSessionAsync(userId, dto, ct);
return CreatedAtAction(nameof(GetSession), new { id = session.Id }, session);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpGet("sessions/{id:int}")]
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSession(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var session = await _service.GetSessionAsync(userId, id, ct);
return session is null ? NotFound() : Ok(session);
}
[HttpPost("sessions/{id:int}/propose-macros")]
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> ProposeMacros(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var session = await _service.ProposeMacrosAsync(userId, id, ct);
return session is null ? NotFound() : Ok(session);
}
[HttpPost("sessions/{id:int}/accept-macros")]
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> AcceptMacros(int id, [FromBody] AcceptMacrosDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var session = await _service.AcceptMacrosAsync(userId, id, dto, ct);
return session is null ? NotFound() : Ok(session);
}
[HttpPost("sessions/{id:int}/generate-plan")]
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> GeneratePlan(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
try
{
var session = await _service.GeneratePlanAsync(userId, id, ct);
return session is null ? NotFound() : Ok(session);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("sessions/{id:int}/regenerate-meal")]
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> RegenerateMeal(int id, [FromBody] RegenerateDietMealDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
try
{
var session = await _service.RegenerateMealAsync(userId, id, dto, ct);
return session is null ? NotFound() : Ok(session);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPatch("sessions/{id:int}/meals/{mealId:int}")]
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateMeal(int id, int mealId, [FromBody] UpdateDraftMealDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var session = await _service.UpdateDraftMealAsync(userId, id, mealId, dto, ct);
return session is null ? NotFound() : Ok(session);
}
[HttpPost("sessions/{id:int}/commit")]
[ProducesResponseType(typeof(CommitDietPlanResultDto), StatusCodes.Status200OK)]
public async Task<IActionResult> Commit(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var result = await _service.CommitAsync(userId, id, ct);
return result is null ? NotFound() : Ok(result);
}
}

View File

@@ -0,0 +1,76 @@
using MealPlan.Api.DTOs.IngredientCatalog;
using MealPlan.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MealPlan.Api.Controllers;
[ApiController]
[Route("api/ingredient-catalog")]
[Authorize]
public class IngredientCatalogController : ControllerBase
{
private readonly IIngredientCatalogService _catalogService;
public IngredientCatalogController(IIngredientCatalogService catalogService) =>
_catalogService = catalogService;
[HttpGet("categories")]
[ProducesResponseType(typeof(IReadOnlyList<IngredientCategoryDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetCategories(CancellationToken ct)
{
var categories = await _catalogService.GetCategoriesAsync(ct);
return Ok(categories);
}
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<IngredientNutritionItemDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetItems(
[FromQuery] string? category,
[FromQuery] string? search,
CancellationToken ct)
{
var items = await _catalogService.GetItemsAsync(category, search, ct);
return Ok(items);
}
[HttpGet("{id:int}")]
[ProducesResponseType(typeof(IngredientNutritionItemDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetItem(int id, CancellationToken ct)
{
var item = await _catalogService.GetItemByIdAsync(id, ct);
return item is null ? NotFound(new { error = $"Ingredient catalog item {id} was not found." }) : Ok(item);
}
[HttpPost]
[ProducesResponseType(typeof(IngredientNutritionItemDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> CreateItem([FromBody] UpsertIngredientNutritionItemDto dto, CancellationToken ct)
{
var item = await _catalogService.CreateItemAsync(dto, ct);
return item is null
? BadRequest(new { error = "Invalid category or payload." })
: CreatedAtAction(nameof(GetItem), new { id = item.Id }, item);
}
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(IngredientNutritionItemDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpdateItem(int id, [FromBody] UpsertIngredientNutritionItemDto dto, CancellationToken ct)
{
var item = await _catalogService.UpdateItemAsync(id, dto, ct);
return item is null
? NotFound(new { error = $"Ingredient catalog item {id} was not found." })
: Ok(item);
}
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteItem(int id, CancellationToken ct)
{
var deleted = await _catalogService.DeleteItemAsync(id, ct);
return deleted ? NoContent() : NotFound(new { error = $"Ingredient catalog item {id} was not found." });
}
}

View File

@@ -0,0 +1,55 @@
using MealPlan.Api.DTOs.MealPlan;
using MealPlan.Api.Extensions;
using MealPlan.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MealPlan.Api.Controllers;
[ApiController]
[Route("api/meal-plan")]
[Authorize]
public class MealPlanController : ControllerBase
{
private readonly IMealPlanService _mealPlanService;
public MealPlanController(IMealPlanService mealPlanService) => _mealPlanService = mealPlanService;
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<MealPlanEntryDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetEntries([FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var entries = await _mealPlanService.GetEntriesAsync(userId, from, to, ct);
return Ok(entries);
}
[HttpPut]
[ProducesResponseType(typeof(MealPlanEntryDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> UpsertEntry([FromBody] UpsertMealPlanEntryDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var entry = await _mealPlanService.UpsertEntryAsync(userId, dto, ct);
return entry is null ? NotFound(new { error = "Recipe not found." }) : Ok(entry);
}
[HttpDelete("{id:int}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> DeleteEntry(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var deleted = await _mealPlanService.DeleteEntryAsync(userId, id, ct);
return deleted ? NoContent() : NotFound();
}
[HttpGet("shopping-list")]
[ProducesResponseType(typeof(IReadOnlyList<ShoppingListItemDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetShoppingList([FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var items = await _mealPlanService.GetShoppingListAsync(userId, from, to, ct);
return Ok(items);
}
}

View File

@@ -0,0 +1,115 @@
using System.Text.Json;
using MealPlan.Api.DTOs.Generators;
using MealPlan.Api.Extensions;
using MealPlan.Api.Services.Generators;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MealPlan.Api.Controllers;
[ApiController]
[Route("api/recipe-generator")]
[Authorize]
public class RecipeGeneratorController : ControllerBase
{
private readonly IRecipeGeneratorService _service;
public RecipeGeneratorController(IRecipeGeneratorService service) => _service = service;
[HttpPost("sessions")]
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status201Created)]
public async Task<IActionResult> CreateSession([FromBody] RecipeGeneratorConstraintsDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var session = await _service.CreateSessionAsync(userId, dto, ct);
return CreatedAtAction(nameof(GetSession), new { id = session.Id }, session);
}
[HttpGet("sessions/{id:int}")]
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> GetSession(int id, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var session = await _service.GetSessionAsync(userId, id, ct);
return session is null ? NotFound() : Ok(session);
}
[HttpPost("sessions/{id:int}/generate")]
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> Generate(int id, [FromBody] GenerateRecipesRequestDto? request, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
try
{
var session = await _service.GenerateAsync(userId, id, request, ct);
return session is null ? NotFound() : Ok(session);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
[HttpPost("sessions/{id:int}/regenerate")]
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> Regenerate(int id, [FromBody] RegenerateRecipePartDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
try
{
var session = await _service.RegeneratePartAsync(userId, id, dto, ct);
return session is null ? NotFound() : Ok(session);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
catch (JsonException)
{
return BadRequest(new { error = "Could not parse AI recipe response. Try again." });
}
}
[HttpPut("sessions/{id:int}/draft")]
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateDraft(int id, [FromBody] GeneratedRecipeDraftDto dto, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var session = await _service.UpdateDraftAsync(userId, id, dto, ct);
return session is null ? NotFound() : Ok(session);
}
[HttpDelete("sessions/{id:int}/drafts/{draftId}")]
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
public async Task<IActionResult> RemoveDraft(int id, string draftId, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
var session = await _service.RemoveDraftAsync(userId, id, draftId, ct);
return session is null ? NotFound() : Ok(session);
}
[HttpPost("sessions/{id:int}/commit")]
[ProducesResponseType(typeof(CommitRecipesResultDto), StatusCodes.Status200OK)]
public async Task<IActionResult> Commit(int id, [FromBody] CommitRecipesRequestDto? request, CancellationToken ct)
{
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
try
{
var result = await _service.CommitAsync(userId, id, request, ct);
if (result is null)
{
return NotFound(new
{
error = "Nothing to save. The session may have expired, or the selected drafts were not found.",
});
}
return Ok(result);
}
catch (InvalidOperationException ex)
{
return BadRequest(new { error = ex.Message });
}
}
}

View File

@@ -0,0 +1,93 @@
using MealPlan.Api.DTOs.Recipe;
using MealPlan.Api.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MealPlan.Api.Controllers;
/// <summary>Endpoints for creating recipes manually and importing from Excel.</summary>
[ApiController]
[Route("api/recipes")]
[Authorize]
public class RecipeManagementController : ControllerBase
{
private readonly IRecipeManagementService _managementService;
private readonly IRecipeExcelService _excelService;
public RecipeManagementController(
IRecipeManagementService managementService,
IRecipeExcelService excelService)
{
_managementService = managementService;
_excelService = excelService;
}
/// <summary>Creates a new recipe with ingredients and steps.</summary>
[HttpPost]
[ProducesResponseType(typeof(RecipeDetailDto), StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> Create([FromBody] CreateRecipeDto dto, CancellationToken ct)
{
var recipe = await _managementService.CreateRecipeAsync(dto, ct);
return CreatedAtAction(
nameof(RecipesController.GetRecipe),
"Recipes",
new { id = recipe.Id },
recipe);
}
/// <summary>Updates an existing recipe (replaces ingredients and steps).</summary>
[HttpPut("{id:int}")]
[ProducesResponseType(typeof(RecipeDetailDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<IActionResult> Update(int id, [FromBody] CreateRecipeDto dto, CancellationToken ct)
{
var recipe = await _managementService.UpdateRecipeAsync(id, dto, ct);
return recipe is null
? NotFound(new { error = $"Recipe {id} was not found." })
: Ok(recipe);
}
/// <summary>Downloads an Excel template for bulk recipe import.</summary>
[HttpGet("import/template")]
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
public IActionResult DownloadTemplate()
{
var bytes = _excelService.GenerateTemplate();
return File(
bytes,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"DailyMeals-recipe-import-template.xlsx");
}
/// <summary>Imports recipes from an uploaded .xlsx file (Recipes + Ingredients + Steps sheets).</summary>
[HttpPost("import/excel")]
[RequestSizeLimit(10_000_000)]
[ProducesResponseType(typeof(RecipeImportResultDto), StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> ImportExcel(IFormFile? file, CancellationToken ct)
{
if (file is null || file.Length == 0)
{
return BadRequest(new { error = "No file uploaded." });
}
if (!file.FileName.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase))
{
return BadRequest(new { error = "Only .xlsx files are supported." });
}
await using var stream = file.OpenReadStream();
var result = await _excelService.ImportAsync(stream, ct);
return Ok(result);
}
/// <summary>Recalculates recipe macros from linked catalog ingredients (all active recipes).</summary>
[HttpPost("recalculate-macros")]
[ProducesResponseType(typeof(RecipeMacroRecalcResultDto), StatusCodes.Status200OK)]
public async Task<IActionResult> RecalculateMacros(CancellationToken ct)
{
var result = await _managementService.RecalculateAllMacrosFromCatalogAsync(ct);
return Ok(result);
}
}

View File

@@ -14,12 +14,16 @@ public class RecipesController : ControllerBase
public RecipesController(IRecipeService recipeService) => _recipeService = recipeService;
/// <summary>Lists active recipes, optionally filtered by category and/or name search.</summary>
/// <summary>
/// Lists active recipes, optionally filtered by category, recipe-name search,
/// and/or an ingredient name (returns recipes that contain that ingredient).
/// </summary>
[HttpGet]
[ProducesResponseType(typeof(IReadOnlyList<RecipeListItemDto>), StatusCodes.Status200OK)]
public async Task<IActionResult> GetRecipes(
[FromQuery] int? category,
[FromQuery] string? search,
[FromQuery] string? ingredient,
CancellationToken ct)
{
if (category is < 0 or > 3)
@@ -27,7 +31,7 @@ public class RecipesController : ControllerBase
return BadRequest(new { error = "category must be between 0 and 3." });
}
var recipes = await _recipeService.GetRecipesAsync(category, search, ct);
var recipes = await _recipeService.GetRecipesAsync(category, search, ingredient, ct);
return Ok(recipes);
}