* Extended functionalities
* Added different UIs
This commit is contained in:
284
MealPlan.Api/Controllers/DietController.cs
Normal file
284
MealPlan.Api/Controllers/DietController.cs
Normal file
@@ -0,0 +1,284 @@
|
||||
using MealPlan.Api.Extensions;
|
||||
using MealPlan.Api.DTOs.Diet;
|
||||
using MealPlan.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MealPlan.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/diet")]
|
||||
[Authorize]
|
||||
public class DietController : ControllerBase
|
||||
{
|
||||
private readonly IDietTrackingService _dietService;
|
||||
|
||||
public DietController(IDietTrackingService dietService) => _dietService = dietService;
|
||||
|
||||
/// <summary>Daily intake summary with consumed totals, goals, and remaining amounts.</summary>
|
||||
[HttpGet("summary")]
|
||||
[ProducesResponseType(typeof(DailySummaryDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetSummary([FromQuery] DateOnly? date, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var summary = await _dietService.GetDailySummaryAsync(userId, targetDate, ct);
|
||||
return Ok(summary);
|
||||
}
|
||||
|
||||
[HttpGet("goals")]
|
||||
[ProducesResponseType(typeof(UserDailyGoalsDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetGoals(CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var goals = await _dietService.GetGoalsAsync(userId, ct);
|
||||
return Ok(goals ?? new UserDailyGoalsDto());
|
||||
}
|
||||
|
||||
[HttpPut("goals")]
|
||||
[ProducesResponseType(typeof(UserDailyGoalsDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> UpsertGoals([FromBody] UserDailyGoalsDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var goals = await _dietService.UpsertGoalsAsync(userId, dto, ct);
|
||||
return Ok(goals);
|
||||
}
|
||||
|
||||
[HttpGet("meals")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<MealConsumptionDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetMeals([FromQuery] DateOnly? date, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var meals = await _dietService.GetMealsAsync(userId, targetDate, ct);
|
||||
return Ok(meals);
|
||||
}
|
||||
|
||||
[HttpPost("meals")]
|
||||
[ProducesResponseType(typeof(MealConsumptionDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> LogMeal([FromBody] LogMealDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var meal = await _dietService.LogMealAsync(userId, dto, ct);
|
||||
if (meal is null)
|
||||
{
|
||||
return dto.RecipeId.HasValue
|
||||
? NotFound(new { error = $"Recipe {dto.RecipeId} was not found." })
|
||||
: BadRequest(new { error = "Invalid meal log payload." });
|
||||
}
|
||||
|
||||
return CreatedAtAction(nameof(GetMeals), new { date = meal.LogDate }, meal);
|
||||
}
|
||||
|
||||
[HttpPost("meals/preview")]
|
||||
[ProducesResponseType(typeof(MealPreviewDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> PreviewMeal([FromQuery] DateOnly? date, [FromBody] LogMealDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var preview = await _dietService.PreviewMealAsync(userId, targetDate, dto, ct);
|
||||
return preview is null
|
||||
? BadRequest(new { error = "Could not preview meal macros." })
|
||||
: Ok(preview);
|
||||
}
|
||||
|
||||
[HttpGet("suggestions")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<DietSuggestionDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetSuggestions([FromQuery] DateOnly? date, [FromQuery] int limit = 5, CancellationToken ct = default)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var suggestions = await _dietService.GetMealSuggestionsAsync(userId, targetDate, limit, ct);
|
||||
return Ok(suggestions);
|
||||
}
|
||||
|
||||
[HttpDelete("meals/{id:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteMeal(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var deleted = await _dietService.DeleteMealAsync(userId, id, ct);
|
||||
return deleted ? NoContent() : NotFound(new { error = $"Meal log {id} was not found." });
|
||||
}
|
||||
|
||||
[HttpGet("drinks/catalog")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<DrinkCatalogItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetDrinkCatalog(CancellationToken ct)
|
||||
{
|
||||
var catalog = await _dietService.GetDrinkCatalogAsync(ct);
|
||||
return Ok(catalog);
|
||||
}
|
||||
|
||||
[HttpGet("drinks")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<DrinkConsumptionDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetDrinks([FromQuery] DateOnly? date, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var drinks = await _dietService.GetDrinksAsync(userId, targetDate, ct);
|
||||
return Ok(drinks);
|
||||
}
|
||||
|
||||
[HttpPost("drinks")]
|
||||
[ProducesResponseType(typeof(DrinkConsumptionDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> LogDrink([FromBody] LogDrinkDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var drink = await _dietService.LogDrinkAsync(userId, dto, ct);
|
||||
if (drink is null)
|
||||
{
|
||||
return NotFound(new { error = "Drink catalog item was not found." });
|
||||
}
|
||||
|
||||
return CreatedAtAction(nameof(GetDrinks), new { date = drink.LogDate }, drink);
|
||||
}
|
||||
|
||||
[HttpDelete("drinks/{id:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteDrink(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var deleted = await _dietService.DeleteDrinkAsync(userId, id, ct);
|
||||
return deleted ? NoContent() : NotFound(new { error = $"Drink log {id} was not found." });
|
||||
}
|
||||
|
||||
[HttpGet("reminders")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<DietReminderDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetReminders(CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var reminders = await _dietService.GetRemindersAsync(userId, ct);
|
||||
return Ok(reminders);
|
||||
}
|
||||
|
||||
[HttpGet("reminders/due")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<DueReminderDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetDueReminders(
|
||||
[FromQuery] DateOnly? date,
|
||||
[FromQuery] TimeOnly? time,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var due = await _dietService.GetDueRemindersAsync(userId, date, time, ct);
|
||||
return Ok(due);
|
||||
}
|
||||
|
||||
[HttpPost("reminders")]
|
||||
[ProducesResponseType(typeof(DietReminderDto), StatusCodes.Status201Created)]
|
||||
public async Task<IActionResult> CreateReminder([FromBody] CreateDietReminderDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var reminder = await _dietService.CreateReminderAsync(userId, dto, ct);
|
||||
return CreatedAtAction(nameof(GetReminders), reminder);
|
||||
}
|
||||
|
||||
[HttpPut("reminders/{id:int}")]
|
||||
[ProducesResponseType(typeof(DietReminderDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateReminder(int id, [FromBody] UpdateDietReminderDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var reminder = await _dietService.UpdateReminderAsync(userId, id, dto, ct);
|
||||
return reminder is null
|
||||
? NotFound(new { error = $"Reminder {id} was not found." })
|
||||
: Ok(reminder);
|
||||
}
|
||||
|
||||
[HttpDelete("reminders/{id:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteReminder(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var deleted = await _dietService.DeleteReminderAsync(userId, id, ct);
|
||||
return deleted ? NoContent() : NotFound(new { error = $"Reminder {id} was not found." });
|
||||
}
|
||||
|
||||
[HttpGet("history")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<HistoryDayDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetHistory([FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var history = await _dietService.GetHistoryAsync(userId, from, to, ct);
|
||||
return Ok(history);
|
||||
}
|
||||
|
||||
[HttpGet("meals/recent")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<RecentMealDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetRecentMeals([FromQuery] int limit = 10, CancellationToken ct = default)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var meals = await _dietService.GetRecentMealsAsync(userId, limit, ct);
|
||||
return Ok(meals);
|
||||
}
|
||||
|
||||
[HttpPost("meals/copy-yesterday")]
|
||||
[ProducesResponseType(typeof(CopyMealsResultDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> CopyYesterdayMeals([FromQuery] DateOnly? date, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var targetDate = date ?? DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var result = await _dietService.CopyYesterdayMealsAsync(userId, targetDate, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("favorites/recipes")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<FavoriteRecipeDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetFavoriteRecipes(CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
return Ok(await _dietService.GetFavoriteRecipesAsync(userId, ct));
|
||||
}
|
||||
|
||||
[HttpPost("favorites/recipes/{recipeId:int}")]
|
||||
[ProducesResponseType(typeof(FavoriteRecipeDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> AddFavoriteRecipe(int recipeId, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var fav = await _dietService.AddFavoriteRecipeAsync(userId, recipeId, ct);
|
||||
return fav is null ? NotFound() : Ok(fav);
|
||||
}
|
||||
|
||||
[HttpDelete("favorites/recipes/{recipeId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> RemoveFavoriteRecipe(int recipeId, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var deleted = await _dietService.RemoveFavoriteRecipeAsync(userId, recipeId, ct);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("favorites/catalog")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<FavoriteCatalogItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetFavoriteCatalogItems(CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
return Ok(await _dietService.GetFavoriteCatalogItemsAsync(userId, ct));
|
||||
}
|
||||
|
||||
[HttpPost("favorites/catalog/{catalogItemId:int}")]
|
||||
[ProducesResponseType(typeof(FavoriteCatalogItemDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> AddFavoriteCatalogItem(int catalogItemId, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var fav = await _dietService.AddFavoriteCatalogItemAsync(userId, catalogItemId, ct);
|
||||
return fav is null ? NotFound() : Ok(fav);
|
||||
}
|
||||
|
||||
[HttpDelete("favorites/catalog/{catalogItemId:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
public async Task<IActionResult> RemoveFavoriteCatalogItem(int catalogItemId, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var deleted = await _dietService.RemoveFavoriteCatalogItemAsync(userId, catalogItemId, ct);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
}
|
||||
128
MealPlan.Api/Controllers/DietGeneratorController.cs
Normal file
128
MealPlan.Api/Controllers/DietGeneratorController.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using MealPlan.Api.DTOs.Generators;
|
||||
using MealPlan.Api.Extensions;
|
||||
using MealPlan.Api.Services.Generators;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MealPlan.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/diet-generator")]
|
||||
[Authorize]
|
||||
public class DietGeneratorController : ControllerBase
|
||||
{
|
||||
private readonly IDietGeneratorService _service;
|
||||
|
||||
public DietGeneratorController(IDietGeneratorService service) => _service = service;
|
||||
|
||||
[HttpGet("profile")]
|
||||
[ProducesResponseType(typeof(DietUserProfileDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetProfile(CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var profile = await _service.GetProfileAsync(userId, ct);
|
||||
return Ok(profile ?? new DietUserProfileDto());
|
||||
}
|
||||
|
||||
[HttpPut("profile")]
|
||||
[ProducesResponseType(typeof(DietUserProfileDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> UpsertProfile([FromBody] DietUserProfileDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
return Ok(await _service.UpsertProfileAsync(userId, dto, ct));
|
||||
}
|
||||
|
||||
[HttpPost("sessions")]
|
||||
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status201Created)]
|
||||
public async Task<IActionResult> CreateSession([FromBody] CreateDietGeneratorSessionDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
try
|
||||
{
|
||||
var session = await _service.CreateSessionAsync(userId, dto, ct);
|
||||
return CreatedAtAction(nameof(GetSession), new { id = session.Id }, session);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("sessions/{id:int}")]
|
||||
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetSession(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var session = await _service.GetSessionAsync(userId, id, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:int}/propose-macros")]
|
||||
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> ProposeMacros(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var session = await _service.ProposeMacrosAsync(userId, id, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:int}/accept-macros")]
|
||||
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> AcceptMacros(int id, [FromBody] AcceptMacrosDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var session = await _service.AcceptMacrosAsync(userId, id, dto, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:int}/generate-plan")]
|
||||
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GeneratePlan(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
try
|
||||
{
|
||||
var session = await _service.GeneratePlanAsync(userId, id, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:int}/regenerate-meal")]
|
||||
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> RegenerateMeal(int id, [FromBody] RegenerateDietMealDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
try
|
||||
{
|
||||
var session = await _service.RegenerateMealAsync(userId, id, dto, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPatch("sessions/{id:int}/meals/{mealId:int}")]
|
||||
[ProducesResponseType(typeof(DietGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> UpdateMeal(int id, int mealId, [FromBody] UpdateDraftMealDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var session = await _service.UpdateDraftMealAsync(userId, id, mealId, dto, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:int}/commit")]
|
||||
[ProducesResponseType(typeof(CommitDietPlanResultDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Commit(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var result = await _service.CommitAsync(userId, id, ct);
|
||||
return result is null ? NotFound() : Ok(result);
|
||||
}
|
||||
}
|
||||
76
MealPlan.Api/Controllers/IngredientCatalogController.cs
Normal file
76
MealPlan.Api/Controllers/IngredientCatalogController.cs
Normal file
@@ -0,0 +1,76 @@
|
||||
using MealPlan.Api.DTOs.IngredientCatalog;
|
||||
using MealPlan.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MealPlan.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/ingredient-catalog")]
|
||||
[Authorize]
|
||||
public class IngredientCatalogController : ControllerBase
|
||||
{
|
||||
private readonly IIngredientCatalogService _catalogService;
|
||||
|
||||
public IngredientCatalogController(IIngredientCatalogService catalogService) =>
|
||||
_catalogService = catalogService;
|
||||
|
||||
[HttpGet("categories")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<IngredientCategoryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetCategories(CancellationToken ct)
|
||||
{
|
||||
var categories = await _catalogService.GetCategoriesAsync(ct);
|
||||
return Ok(categories);
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<IngredientNutritionItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetItems(
|
||||
[FromQuery] string? category,
|
||||
[FromQuery] string? search,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var items = await _catalogService.GetItemsAsync(category, search, ct);
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
[ProducesResponseType(typeof(IngredientNutritionItemDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetItem(int id, CancellationToken ct)
|
||||
{
|
||||
var item = await _catalogService.GetItemByIdAsync(id, ct);
|
||||
return item is null ? NotFound(new { error = $"Ingredient catalog item {id} was not found." }) : Ok(item);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(IngredientNutritionItemDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> CreateItem([FromBody] UpsertIngredientNutritionItemDto dto, CancellationToken ct)
|
||||
{
|
||||
var item = await _catalogService.CreateItemAsync(dto, ct);
|
||||
return item is null
|
||||
? BadRequest(new { error = "Invalid category or payload." })
|
||||
: CreatedAtAction(nameof(GetItem), new { id = item.Id }, item);
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(IngredientNutritionItemDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpdateItem(int id, [FromBody] UpsertIngredientNutritionItemDto dto, CancellationToken ct)
|
||||
{
|
||||
var item = await _catalogService.UpdateItemAsync(id, dto, ct);
|
||||
return item is null
|
||||
? NotFound(new { error = $"Ingredient catalog item {id} was not found." })
|
||||
: Ok(item);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteItem(int id, CancellationToken ct)
|
||||
{
|
||||
var deleted = await _catalogService.DeleteItemAsync(id, ct);
|
||||
return deleted ? NoContent() : NotFound(new { error = $"Ingredient catalog item {id} was not found." });
|
||||
}
|
||||
}
|
||||
55
MealPlan.Api/Controllers/MealPlanController.cs
Normal file
55
MealPlan.Api/Controllers/MealPlanController.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using MealPlan.Api.DTOs.MealPlan;
|
||||
using MealPlan.Api.Extensions;
|
||||
using MealPlan.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MealPlan.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/meal-plan")]
|
||||
[Authorize]
|
||||
public class MealPlanController : ControllerBase
|
||||
{
|
||||
private readonly IMealPlanService _mealPlanService;
|
||||
|
||||
public MealPlanController(IMealPlanService mealPlanService) => _mealPlanService = mealPlanService;
|
||||
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<MealPlanEntryDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetEntries([FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var entries = await _mealPlanService.GetEntriesAsync(userId, from, to, ct);
|
||||
return Ok(entries);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
[ProducesResponseType(typeof(MealPlanEntryDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> UpsertEntry([FromBody] UpsertMealPlanEntryDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var entry = await _mealPlanService.UpsertEntryAsync(userId, dto, ct);
|
||||
return entry is null ? NotFound(new { error = "Recipe not found." }) : Ok(entry);
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> DeleteEntry(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var deleted = await _mealPlanService.DeleteEntryAsync(userId, id, ct);
|
||||
return deleted ? NoContent() : NotFound();
|
||||
}
|
||||
|
||||
[HttpGet("shopping-list")]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<ShoppingListItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetShoppingList([FromQuery] DateOnly from, [FromQuery] DateOnly to, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var items = await _mealPlanService.GetShoppingListAsync(userId, from, to, ct);
|
||||
return Ok(items);
|
||||
}
|
||||
}
|
||||
115
MealPlan.Api/Controllers/RecipeGeneratorController.cs
Normal file
115
MealPlan.Api/Controllers/RecipeGeneratorController.cs
Normal file
@@ -0,0 +1,115 @@
|
||||
using System.Text.Json;
|
||||
using MealPlan.Api.DTOs.Generators;
|
||||
using MealPlan.Api.Extensions;
|
||||
using MealPlan.Api.Services.Generators;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MealPlan.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/recipe-generator")]
|
||||
[Authorize]
|
||||
public class RecipeGeneratorController : ControllerBase
|
||||
{
|
||||
private readonly IRecipeGeneratorService _service;
|
||||
|
||||
public RecipeGeneratorController(IRecipeGeneratorService service) => _service = service;
|
||||
|
||||
[HttpPost("sessions")]
|
||||
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status201Created)]
|
||||
public async Task<IActionResult> CreateSession([FromBody] RecipeGeneratorConstraintsDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var session = await _service.CreateSessionAsync(userId, dto, ct);
|
||||
return CreatedAtAction(nameof(GetSession), new { id = session.Id }, session);
|
||||
}
|
||||
|
||||
[HttpGet("sessions/{id:int}")]
|
||||
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetSession(int id, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var session = await _service.GetSessionAsync(userId, id, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:int}/generate")]
|
||||
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Generate(int id, [FromBody] GenerateRecipesRequestDto? request, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
try
|
||||
{
|
||||
var session = await _service.GenerateAsync(userId, id, request, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:int}/regenerate")]
|
||||
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Regenerate(int id, [FromBody] RegenerateRecipePartDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
try
|
||||
{
|
||||
var session = await _service.RegeneratePartAsync(userId, id, dto, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return BadRequest(new { error = "Could not parse AI recipe response. Try again." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("sessions/{id:int}/draft")]
|
||||
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> UpdateDraft(int id, [FromBody] GeneratedRecipeDraftDto dto, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var session = await _service.UpdateDraftAsync(userId, id, dto, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
|
||||
[HttpDelete("sessions/{id:int}/drafts/{draftId}")]
|
||||
[ProducesResponseType(typeof(RecipeGeneratorSessionDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> RemoveDraft(int id, string draftId, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
var session = await _service.RemoveDraftAsync(userId, id, draftId, ct);
|
||||
return session is null ? NotFound() : Ok(session);
|
||||
}
|
||||
|
||||
[HttpPost("sessions/{id:int}/commit")]
|
||||
[ProducesResponseType(typeof(CommitRecipesResultDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> Commit(int id, [FromBody] CommitRecipesRequestDto? request, CancellationToken ct)
|
||||
{
|
||||
if (User.RequireUserId(out var userId) is { } unauthorized) return unauthorized;
|
||||
try
|
||||
{
|
||||
var result = await _service.CommitAsync(userId, id, request, ct);
|
||||
if (result is null)
|
||||
{
|
||||
return NotFound(new
|
||||
{
|
||||
error = "Nothing to save. The session may have expired, or the selected drafts were not found.",
|
||||
});
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return BadRequest(new { error = ex.Message });
|
||||
}
|
||||
}
|
||||
}
|
||||
93
MealPlan.Api/Controllers/RecipeManagementController.cs
Normal file
93
MealPlan.Api/Controllers/RecipeManagementController.cs
Normal file
@@ -0,0 +1,93 @@
|
||||
using MealPlan.Api.DTOs.Recipe;
|
||||
using MealPlan.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace MealPlan.Api.Controllers;
|
||||
|
||||
/// <summary>Endpoints for creating recipes manually and importing from Excel.</summary>
|
||||
[ApiController]
|
||||
[Route("api/recipes")]
|
||||
[Authorize]
|
||||
public class RecipeManagementController : ControllerBase
|
||||
{
|
||||
private readonly IRecipeManagementService _managementService;
|
||||
private readonly IRecipeExcelService _excelService;
|
||||
|
||||
public RecipeManagementController(
|
||||
IRecipeManagementService managementService,
|
||||
IRecipeExcelService excelService)
|
||||
{
|
||||
_managementService = managementService;
|
||||
_excelService = excelService;
|
||||
}
|
||||
|
||||
/// <summary>Creates a new recipe with ingredients and steps.</summary>
|
||||
[HttpPost]
|
||||
[ProducesResponseType(typeof(RecipeDetailDto), StatusCodes.Status201Created)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> Create([FromBody] CreateRecipeDto dto, CancellationToken ct)
|
||||
{
|
||||
var recipe = await _managementService.CreateRecipeAsync(dto, ct);
|
||||
return CreatedAtAction(
|
||||
nameof(RecipesController.GetRecipe),
|
||||
"Recipes",
|
||||
new { id = recipe.Id },
|
||||
recipe);
|
||||
}
|
||||
|
||||
/// <summary>Updates an existing recipe (replaces ingredients and steps).</summary>
|
||||
[HttpPut("{id:int}")]
|
||||
[ProducesResponseType(typeof(RecipeDetailDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] CreateRecipeDto dto, CancellationToken ct)
|
||||
{
|
||||
var recipe = await _managementService.UpdateRecipeAsync(id, dto, ct);
|
||||
return recipe is null
|
||||
? NotFound(new { error = $"Recipe {id} was not found." })
|
||||
: Ok(recipe);
|
||||
}
|
||||
|
||||
/// <summary>Downloads an Excel template for bulk recipe import.</summary>
|
||||
[HttpGet("import/template")]
|
||||
[ProducesResponseType(typeof(FileContentResult), StatusCodes.Status200OK)]
|
||||
public IActionResult DownloadTemplate()
|
||||
{
|
||||
var bytes = _excelService.GenerateTemplate();
|
||||
return File(
|
||||
bytes,
|
||||
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
"DailyMeals-recipe-import-template.xlsx");
|
||||
}
|
||||
|
||||
/// <summary>Imports recipes from an uploaded .xlsx file (Recipes + Ingredients + Steps sheets).</summary>
|
||||
[HttpPost("import/excel")]
|
||||
[RequestSizeLimit(10_000_000)]
|
||||
[ProducesResponseType(typeof(RecipeImportResultDto), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> ImportExcel(IFormFile? file, CancellationToken ct)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new { error = "No file uploaded." });
|
||||
}
|
||||
|
||||
if (!file.FileName.EndsWith(".xlsx", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BadRequest(new { error = "Only .xlsx files are supported." });
|
||||
}
|
||||
|
||||
await using var stream = file.OpenReadStream();
|
||||
var result = await _excelService.ImportAsync(stream, ct);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>Recalculates recipe macros from linked catalog ingredients (all active recipes).</summary>
|
||||
[HttpPost("recalculate-macros")]
|
||||
[ProducesResponseType(typeof(RecipeMacroRecalcResultDto), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> RecalculateMacros(CancellationToken ct)
|
||||
{
|
||||
var result = await _managementService.RecalculateAllMacrosFromCatalogAsync(ct);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -14,12 +14,16 @@ public class RecipesController : ControllerBase
|
||||
|
||||
public RecipesController(IRecipeService recipeService) => _recipeService = recipeService;
|
||||
|
||||
/// <summary>Lists active recipes, optionally filtered by category and/or name search.</summary>
|
||||
/// <summary>
|
||||
/// Lists active recipes, optionally filtered by category, recipe-name search,
|
||||
/// and/or an ingredient name (returns recipes that contain that ingredient).
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(IReadOnlyList<RecipeListItemDto>), StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetRecipes(
|
||||
[FromQuery] int? category,
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] string? ingredient,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (category is < 0 or > 3)
|
||||
@@ -27,7 +31,7 @@ public class RecipesController : ControllerBase
|
||||
return BadRequest(new { error = "category must be between 0 and 3." });
|
||||
}
|
||||
|
||||
var recipes = await _recipeService.GetRecipesAsync(category, search, ct);
|
||||
var recipes = await _recipeService.GetRecipesAsync(category, search, ingredient, ct);
|
||||
return Ok(recipes);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user