using MealPlan.Api.Data; using MealPlan.Api.DTOs.Recipe; using MealPlan.Api.Models; using Microsoft.EntityFrameworkCore; namespace MealPlan.Api.Services; /// Creates and updates recipes in the existing PostgreSQL schema. public class RecipeManagementService : IRecipeManagementService { private readonly AppDbContext _db; private readonly IRecipeService _recipeService; public RecipeManagementService(AppDbContext db, IRecipeService recipeService) { _db = db; _recipeService = recipeService; } public async Task CreateRecipeAsync(CreateRecipeDto dto, CancellationToken ct = default) { var recipe = MapToEntity(dto); recipe.CreatedAt = DateTime.UtcNow; recipe.IsActive = true; await ApplyMacrosFromCatalogAsync(recipe, ct); _db.Recipes.Add(recipe); await _db.SaveChangesAsync(ct); return (await _recipeService.GetRecipeByIdAsync(recipe.Id, ct))!; } public async Task UpdateRecipeAsync(int id, CreateRecipeDto dto, CancellationToken ct = default) { var recipe = await _db.Recipes .Include(r => r.Ingredients) .Include(r => r.Steps) .FirstOrDefaultAsync(r => r.Id == id && r.IsActive, ct); if (recipe is null) { return null; } recipe.Name = dto.Name.Trim(); recipe.MealCategory = dto.MealCategory; recipe.Calories = dto.Calories; recipe.Protein = dto.Protein; recipe.Fat = dto.Fat; recipe.Carbs = dto.Carbs; recipe.PrepTimeMinutes = dto.PrepTimeMinutes; recipe.UpdatedAt = DateTime.UtcNow; _db.Ingredients.RemoveRange(recipe.Ingredients); _db.RecipeSteps.RemoveRange(recipe.Steps); recipe.Ingredients = MapIngredients(dto.Ingredients); recipe.Steps = MapSteps(dto.Steps); await ApplyMacrosFromCatalogAsync(recipe, ct); await _db.SaveChangesAsync(ct); return await _recipeService.GetRecipeByIdAsync(id, ct); } public async Task RecalculateAllMacrosFromCatalogAsync(CancellationToken ct = default) { var catalog = await _db.IngredientNutritionCatalog.AsNoTracking() .Where(i => i.IsActive) .ToDictionaryAsync(i => i.Id, ct); var recipes = await _db.Recipes .Include(r => r.Ingredients) .Where(r => r.IsActive) .ToListAsync(ct); var updated = 0; foreach (var recipe in recipes) { if (TrySetMacrosFromCatalog(recipe, catalog, overwrite: true)) { recipe.UpdatedAt = DateTime.UtcNow; updated++; } } await _db.SaveChangesAsync(ct); var unlinked = await _db.Ingredients.CountAsync(i => i.AmountGrams > 0 && i.CatalogItemId == null && (i.Unit == null || !HasNonMacroUnit(i.Unit)), ct); return new RecipeMacroRecalcResultDto { RecipesProcessed = recipes.Count, RecipesUpdated = updated, UnlinkedIngredientRows = unlinked, }; } internal static Recipe MapToEntity(CreateRecipeDto dto) => new() { Name = dto.Name.Trim(), MealCategory = dto.MealCategory, Calories = dto.Calories, Protein = dto.Protein, Fat = dto.Fat, Carbs = dto.Carbs, PrepTimeMinutes = dto.PrepTimeMinutes, Ingredients = MapIngredients(dto.Ingredients), Steps = MapSteps(dto.Steps), }; private static List MapIngredients(IEnumerable items) => items.Select((item, index) => new Ingredient { Name = item.Name.Trim(), AmountGrams = item.AmountGrams, Unit = string.IsNullOrWhiteSpace(item.Unit) ? null : item.Unit.Trim(), SortOrder = item.SortOrder > 0 ? item.SortOrder : index, CatalogItemId = item.CatalogItemId, }).ToList(); private async Task ApplyMacrosFromCatalogAsync(Recipe recipe, CancellationToken ct) { var catalogIds = recipe.Ingredients .Where(i => i.CatalogItemId.HasValue && i.AmountGrams is > 0 && !HasNonMacroUnit(i.Unit)) .Select(i => i.CatalogItemId!.Value) .Distinct() .ToList(); if (catalogIds.Count == 0) return; var catalog = await _db.IngredientNutritionCatalog.AsNoTracking() .Where(i => catalogIds.Contains(i.Id) && i.IsActive) .ToDictionaryAsync(i => i.Id, ct); TrySetMacrosFromCatalog(recipe, catalog, overwrite: false); } private static bool TrySetMacrosFromCatalog( Recipe recipe, IReadOnlyDictionary catalog, bool overwrite) { int totalCalories = 0; decimal totalProtein = 0; decimal totalFat = 0; decimal totalCarbs = 0; var hasAny = false; foreach (var ing in recipe.Ingredients) { if (ing.CatalogItemId is null || ing.AmountGrams is not > 0 || HasNonMacroUnit(ing.Unit)) continue; if (!catalog.TryGetValue(ing.CatalogItemId.Value, out var cat)) continue; var scale = ing.AmountGrams.Value / 100m; totalCalories += (int)Math.Round(cat.CaloriesPer100G * scale); totalProtein += cat.ProteinGPer100G * scale; totalFat += cat.FatGPer100G * scale; totalCarbs += cat.CarbsGPer100G * scale; hasAny = true; } if (!hasAny) return false; if (overwrite || !recipe.Calories.HasValue) recipe.Calories = totalCalories; if (overwrite || !recipe.Protein.HasValue) recipe.Protein = Math.Round(totalProtein, 2); if (overwrite || !recipe.Fat.HasValue) recipe.Fat = Math.Round(totalFat, 2); if (overwrite || !recipe.Carbs.HasValue) recipe.Carbs = Math.Round(totalCarbs, 2); return true; } private static bool HasNonMacroUnit(string? unit) { if (string.IsNullOrWhiteSpace(unit)) return false; var u = unit.Trim().ToLowerInvariant(); return u is "do smaku" or "optional" or "opcjonalnie" or "szczypta"; } private static List MapSteps(IEnumerable items) => items.Select(item => new RecipeStep { StepNumber = item.StepNumber, Description = item.Description.Trim(), }).ToList(); }