feat: complete mealplanner app (backend + frontend + deployment)
.NET 8 backend with Zitadel JWT auth, TheMealDB integration, weekly meal plan generation, shopping list aggregation. Vue 3 + Tailwind 4 frontend with dark emerald theme, manual OIDC PKCE auth, all views implemented. Multi-stage Dockerfile with nginx reverse proxy. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
180
backend/Services/MealPlanService.cs
Normal file
180
backend/Services/MealPlanService.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MealPlanner.Data;
|
||||
using MealPlanner.Models;
|
||||
|
||||
namespace MealPlanner.Services;
|
||||
|
||||
public class MealPlanService(AppDbContext db, TheMealDbClient mealDbClient, RecipeService recipeService)
|
||||
{
|
||||
public async Task<MealPlan> GenerateWeekPlanAsync(string userId, DateOnly weekStart)
|
||||
{
|
||||
// Remove existing plan for this week if present
|
||||
var existing = await db.MealPlans
|
||||
.Include(p => p.Entries)
|
||||
.FirstOrDefaultAsync(p => p.UserId == userId && p.WeekStart == weekStart);
|
||||
if (existing is not null)
|
||||
{
|
||||
db.MealPlans.Remove(existing);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var plan = new MealPlan
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
WeekStart = weekStart,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
// Get own recipes to optionally mix in (up to 3)
|
||||
var ownRecipes = await db.Recipes
|
||||
.Include(r => r.Ingredients)
|
||||
.Where(r => r.UserId == userId)
|
||||
.ToListAsync();
|
||||
|
||||
var usedRecipeIds = new HashSet<Guid>();
|
||||
var entries = new List<MealPlanEntry>();
|
||||
|
||||
// Shuffle own recipes and pick up to 3
|
||||
var ownToUse = ownRecipes
|
||||
.OrderBy(_ => Random.Shared.Next())
|
||||
.Take(Math.Min(3, ownRecipes.Count))
|
||||
.ToList();
|
||||
|
||||
int ownIndex = 0;
|
||||
|
||||
for (int dayOffset = 0; dayOffset < 7; dayOffset++)
|
||||
{
|
||||
var date = weekStart.AddDays(dayOffset);
|
||||
Recipe? recipe = null;
|
||||
|
||||
// Use an own recipe for this slot if available
|
||||
if (ownIndex < ownToUse.Count)
|
||||
{
|
||||
recipe = ownToUse[ownIndex++];
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fetch random from TheMealDB, retry if duplicate
|
||||
for (int attempt = 0; attempt < 5; attempt++)
|
||||
{
|
||||
var candidate = await mealDbClient.GetRandomAsync();
|
||||
if (candidate is null) continue;
|
||||
if (candidate.ExternalId is not null && usedRecipeIds.Any(id =>
|
||||
db.Recipes.Any(r => r.Id == id && r.ExternalId == candidate.ExternalId)))
|
||||
continue;
|
||||
|
||||
recipe = await recipeService.EnsurePersistedAsync(candidate);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (recipe is null) continue;
|
||||
|
||||
usedRecipeIds.Add(recipe.Id);
|
||||
entries.Add(new MealPlanEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
MealPlanId = plan.Id,
|
||||
Date = date,
|
||||
RecipeId = recipe.Id,
|
||||
});
|
||||
}
|
||||
|
||||
plan.Entries = entries;
|
||||
db.MealPlans.Add(plan);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return await LoadPlanWithDetailsAsync(plan.Id) ?? plan;
|
||||
}
|
||||
|
||||
public async Task<MealPlan?> GetCurrentPlanAsync(string userId)
|
||||
{
|
||||
var today = DateOnly.FromDateTime(DateTime.UtcNow);
|
||||
var weekStart = GetWeekStart(today);
|
||||
return await GetPlanAsync(userId, weekStart);
|
||||
}
|
||||
|
||||
public async Task<MealPlan?> GetPlanAsync(string userId, DateOnly weekStart)
|
||||
{
|
||||
var plan = await db.MealPlans
|
||||
.Where(p => p.UserId == userId && p.WeekStart == weekStart)
|
||||
.Select(p => p.Id)
|
||||
.FirstOrDefaultAsync();
|
||||
|
||||
if (plan == default) return null;
|
||||
return await LoadPlanWithDetailsAsync(plan);
|
||||
}
|
||||
|
||||
public async Task<MealPlanEntry?> SwapEntryAsync(Guid entryId, Guid newRecipeId, string userId)
|
||||
{
|
||||
var entry = await db.MealPlanEntries
|
||||
.Include(e => e.MealPlan)
|
||||
.FirstOrDefaultAsync(e => e.Id == entryId);
|
||||
|
||||
if (entry is null) return null;
|
||||
if (entry.MealPlan.UserId != userId) throw new UnauthorizedAccessException();
|
||||
|
||||
var recipe = await db.Recipes.FindAsync(newRecipeId);
|
||||
if (recipe is null) return null;
|
||||
|
||||
entry.RecipeId = newRecipeId;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return await db.MealPlanEntries
|
||||
.Include(e => e.Recipe).ThenInclude(r => r.Ingredients)
|
||||
.FirstOrDefaultAsync(e => e.Id == entryId);
|
||||
}
|
||||
|
||||
public async Task<MealPlanEntry?> RerollEntryAsync(Guid entryId, string userId)
|
||||
{
|
||||
var entry = await db.MealPlanEntries
|
||||
.Include(e => e.MealPlan).ThenInclude(p => p.Entries)
|
||||
.FirstOrDefaultAsync(e => e.Id == entryId);
|
||||
|
||||
if (entry is null) return null;
|
||||
if (entry.MealPlan.UserId != userId) throw new UnauthorizedAccessException();
|
||||
|
||||
var usedExternalIds = await db.MealPlanEntries
|
||||
.Where(e => e.MealPlanId == entry.MealPlanId)
|
||||
.Include(e => e.Recipe)
|
||||
.Select(e => e.Recipe.ExternalId)
|
||||
.ToListAsync();
|
||||
|
||||
Recipe? newRecipe = null;
|
||||
for (int attempt = 0; attempt < 10; attempt++)
|
||||
{
|
||||
var candidate = await mealDbClient.GetRandomAsync();
|
||||
if (candidate is null) continue;
|
||||
if (usedExternalIds.Contains(candidate.ExternalId)) continue;
|
||||
|
||||
newRecipe = await recipeService.EnsurePersistedAsync(candidate);
|
||||
break;
|
||||
}
|
||||
|
||||
if (newRecipe is null) return null;
|
||||
|
||||
entry.RecipeId = newRecipe.Id;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return await db.MealPlanEntries
|
||||
.Include(e => e.Recipe).ThenInclude(r => r.Ingredients)
|
||||
.FirstOrDefaultAsync(e => e.Id == entryId);
|
||||
}
|
||||
|
||||
private async Task<MealPlan?> LoadPlanWithDetailsAsync(Guid planId)
|
||||
{
|
||||
return await db.MealPlans
|
||||
.Include(p => p.Entries)
|
||||
.ThenInclude(e => e.Recipe)
|
||||
.ThenInclude(r => r.Ingredients)
|
||||
.FirstOrDefaultAsync(p => p.Id == planId);
|
||||
}
|
||||
|
||||
public static DateOnly GetWeekStart(DateOnly date)
|
||||
{
|
||||
// Monday as week start
|
||||
int diff = ((int)date.DayOfWeek - (int)DayOfWeek.Monday + 7) % 7;
|
||||
return date.AddDays(-diff);
|
||||
}
|
||||
}
|
||||
128
backend/Services/RecipeService.cs
Normal file
128
backend/Services/RecipeService.cs
Normal file
@@ -0,0 +1,128 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MealPlanner.Data;
|
||||
using MealPlanner.Models;
|
||||
|
||||
namespace MealPlanner.Services;
|
||||
|
||||
public class RecipeService(AppDbContext db, TheMealDbClient mealDbClient)
|
||||
{
|
||||
public async Task<List<Recipe>> GetOwnRecipesAsync(string userId)
|
||||
{
|
||||
return await db.Recipes
|
||||
.Include(r => r.Ingredients)
|
||||
.Where(r => r.UserId == userId)
|
||||
.OrderByDescending(r => r.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Recipe?> GetByIdAsync(Guid id)
|
||||
{
|
||||
return await db.Recipes
|
||||
.Include(r => r.Ingredients)
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
}
|
||||
|
||||
public async Task<Recipe?> GetByIdOrFetchAsync(Guid id)
|
||||
{
|
||||
var recipe = await GetByIdAsync(id);
|
||||
return recipe;
|
||||
}
|
||||
|
||||
public async Task<List<Recipe>> SearchAsync(string query, string userId)
|
||||
{
|
||||
// Local own recipes
|
||||
var own = await db.Recipes
|
||||
.Include(r => r.Ingredients)
|
||||
.Where(r => r.UserId == userId && r.Title.ToLower().Contains(query.ToLower()))
|
||||
.ToListAsync();
|
||||
|
||||
// External results
|
||||
var external = await mealDbClient.SearchAsync(query);
|
||||
|
||||
// Deduplicate external by ExternalId
|
||||
var existingExternalIds = await db.Recipes
|
||||
.Where(r => r.Source == RecipeSource.TheMealDb && r.ExternalId != null)
|
||||
.Select(r => r.ExternalId!)
|
||||
.ToListAsync();
|
||||
|
||||
var newExternal = external
|
||||
.Where(r => r.ExternalId != null && !existingExternalIds.Contains(r.ExternalId))
|
||||
.ToList();
|
||||
|
||||
return [.. own, .. newExternal];
|
||||
}
|
||||
|
||||
public async Task<Recipe> CreateAsync(string userId, Recipe recipe)
|
||||
{
|
||||
recipe.Id = Guid.NewGuid();
|
||||
recipe.UserId = userId;
|
||||
recipe.Source = RecipeSource.Own;
|
||||
recipe.CreatedAt = DateTime.UtcNow;
|
||||
|
||||
foreach (var ing in recipe.Ingredients)
|
||||
{
|
||||
ing.Id = Guid.NewGuid();
|
||||
ing.RecipeId = recipe.Id;
|
||||
}
|
||||
|
||||
db.Recipes.Add(recipe);
|
||||
await db.SaveChangesAsync();
|
||||
return recipe;
|
||||
}
|
||||
|
||||
public async Task<Recipe?> UpdateAsync(Guid id, string userId, Recipe updated)
|
||||
{
|
||||
var recipe = await db.Recipes
|
||||
.Include(r => r.Ingredients)
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
if (recipe is null) return null;
|
||||
if (recipe.UserId != userId) throw new UnauthorizedAccessException();
|
||||
|
||||
recipe.Title = updated.Title;
|
||||
recipe.Instructions = updated.Instructions;
|
||||
recipe.ImageUrl = updated.ImageUrl;
|
||||
|
||||
// Replace ingredients
|
||||
db.RecipeIngredients.RemoveRange(recipe.Ingredients);
|
||||
recipe.Ingredients = updated.Ingredients.Select(ing => new RecipeIngredient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RecipeId = recipe.Id,
|
||||
Name = ing.Name,
|
||||
Amount = ing.Amount,
|
||||
Unit = ing.Unit,
|
||||
Category = ing.Category,
|
||||
}).ToList();
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return recipe;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(Guid id, string userId)
|
||||
{
|
||||
var recipe = await db.Recipes.FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (recipe is null) return false;
|
||||
if (recipe.UserId != userId) throw new UnauthorizedAccessException();
|
||||
|
||||
db.Recipes.Remove(recipe);
|
||||
await db.SaveChangesAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Save an external recipe to DB so it can be referenced in meal plans
|
||||
public async Task<Recipe> EnsurePersistedAsync(Recipe recipe)
|
||||
{
|
||||
if (recipe.ExternalId is not null)
|
||||
{
|
||||
var existing = await db.Recipes
|
||||
.Include(r => r.Ingredients)
|
||||
.FirstOrDefaultAsync(r => r.ExternalId == recipe.ExternalId);
|
||||
if (existing is not null) return existing;
|
||||
}
|
||||
|
||||
db.Recipes.Add(recipe);
|
||||
await db.SaveChangesAsync();
|
||||
return recipe;
|
||||
}
|
||||
}
|
||||
91
backend/Services/ShoppingListService.cs
Normal file
91
backend/Services/ShoppingListService.cs
Normal file
@@ -0,0 +1,91 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MealPlanner.Data;
|
||||
using MealPlanner.Models;
|
||||
|
||||
namespace MealPlanner.Services;
|
||||
|
||||
public class ShoppingListService(AppDbContext db)
|
||||
{
|
||||
public async Task<List<ShoppingItem>> GetShoppingListAsync(Guid mealPlanId, string userId)
|
||||
{
|
||||
var plan = await db.MealPlans
|
||||
.Include(p => p.Entries)
|
||||
.ThenInclude(e => e.Recipe)
|
||||
.ThenInclude(r => r.Ingredients)
|
||||
.FirstOrDefaultAsync(p => p.Id == mealPlanId);
|
||||
|
||||
if (plan is null) return [];
|
||||
if (plan.UserId != userId) throw new UnauthorizedAccessException();
|
||||
|
||||
var settings = await db.UserSettings.FindAsync(userId);
|
||||
int householdSize = settings?.HouseholdSize ?? 2;
|
||||
|
||||
var checkedItems = await db.CheckedShoppingItems
|
||||
.Where(c => c.MealPlanId == mealPlanId && c.UserId == userId)
|
||||
.ToDictionaryAsync(c => c.ItemName.ToLowerInvariant(), c => c.IsChecked);
|
||||
|
||||
// Aggregate ingredients
|
||||
var aggregated = new Dictionary<string, ShoppingItem>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var entry in plan.Entries)
|
||||
{
|
||||
foreach (var ing in entry.Recipe.Ingredients)
|
||||
{
|
||||
var key = ing.Name.Trim().ToLowerInvariant();
|
||||
|
||||
if (!aggregated.TryGetValue(key, out var item))
|
||||
{
|
||||
item = new ShoppingItem
|
||||
{
|
||||
Name = ing.Name.Trim(),
|
||||
Unit = ing.Unit,
|
||||
Category = ing.Category,
|
||||
TotalAmount = null,
|
||||
IsChecked = checkedItems.TryGetValue(key, out var chk) && chk,
|
||||
};
|
||||
aggregated[key] = item;
|
||||
}
|
||||
|
||||
if (ing.Amount.HasValue)
|
||||
{
|
||||
item.TotalAmount = (item.TotalAmount ?? 0) + ing.Amount.Value * householdSize;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [.. aggregated.Values.OrderBy(i => i.Category).ThenBy(i => i.Name)];
|
||||
}
|
||||
|
||||
public async Task<CheckedShoppingItem> ToggleCheckAsync(Guid mealPlanId, string itemName, string userId)
|
||||
{
|
||||
var plan = await db.MealPlans.FindAsync(mealPlanId);
|
||||
if (plan is null) throw new KeyNotFoundException("Meal plan not found");
|
||||
if (plan.UserId != userId) throw new UnauthorizedAccessException();
|
||||
|
||||
var key = itemName.ToLowerInvariant();
|
||||
|
||||
var existing = await db.CheckedShoppingItems
|
||||
.FirstOrDefaultAsync(c => c.MealPlanId == mealPlanId && c.UserId == userId
|
||||
&& c.ItemName.ToLower() == key);
|
||||
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new CheckedShoppingItem
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
MealPlanId = mealPlanId,
|
||||
ItemName = itemName,
|
||||
UserId = userId,
|
||||
IsChecked = true,
|
||||
};
|
||||
db.CheckedShoppingItems.Add(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.IsChecked = !existing.IsChecked;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
134
backend/Services/TheMealDbClient.cs
Normal file
134
backend/Services/TheMealDbClient.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using System.Text.Json;
|
||||
using MealPlanner.Models;
|
||||
|
||||
namespace MealPlanner.Services;
|
||||
|
||||
public class TheMealDbClient(HttpClient httpClient)
|
||||
{
|
||||
private const string BaseUrl = "https://www.themealdb.com/api/json/v1/1/";
|
||||
|
||||
public async Task<Recipe?> GetRandomAsync()
|
||||
{
|
||||
var response = await httpClient.GetAsync($"{BaseUrl}random.php");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var meals = ParseMeals(json);
|
||||
return meals.FirstOrDefault();
|
||||
}
|
||||
|
||||
public async Task<List<Recipe>> SearchAsync(string query)
|
||||
{
|
||||
var response = await httpClient.GetAsync($"{BaseUrl}search.php?s={Uri.EscapeDataString(query)}");
|
||||
if (!response.IsSuccessStatusCode) return [];
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
return ParseMeals(json);
|
||||
}
|
||||
|
||||
public async Task<Recipe?> GetByIdAsync(string id)
|
||||
{
|
||||
var response = await httpClient.GetAsync($"{BaseUrl}lookup.php?i={id}");
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
return ParseMeals(json).FirstOrDefault();
|
||||
}
|
||||
|
||||
private static List<Recipe> ParseMeals(string json)
|
||||
{
|
||||
var result = new List<Recipe>();
|
||||
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (!doc.RootElement.TryGetProperty("meals", out var mealsEl) || mealsEl.ValueKind == JsonValueKind.Null)
|
||||
return result;
|
||||
|
||||
foreach (var meal in mealsEl.EnumerateArray())
|
||||
{
|
||||
var recipe = MapMeal(meal);
|
||||
if (recipe is not null)
|
||||
result.Add(recipe);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Recipe? MapMeal(JsonElement meal)
|
||||
{
|
||||
var id = meal.GetStringOrNull("idMeal");
|
||||
var title = meal.GetStringOrNull("strMeal");
|
||||
if (id is null || title is null) return null;
|
||||
|
||||
var recipe = new Recipe
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
ExternalId = id,
|
||||
Title = title,
|
||||
Instructions = meal.GetStringOrNull("strInstructions") ?? "",
|
||||
ImageUrl = meal.GetStringOrNull("strMealThumb"),
|
||||
Source = RecipeSource.TheMealDb,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
|
||||
// Parse strIngredient1-20 / strMeasure1-20
|
||||
for (int i = 1; i <= 20; i++)
|
||||
{
|
||||
var name = meal.GetStringOrNull($"strIngredient{i}");
|
||||
var measure = meal.GetStringOrNull($"strMeasure{i}");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||
|
||||
var ingredient = new RecipeIngredient
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
RecipeId = recipe.Id,
|
||||
Name = name.Trim(),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(measure))
|
||||
{
|
||||
ParseMeasure(measure.Trim(), out var amount, out var unit);
|
||||
ingredient.Amount = amount;
|
||||
ingredient.Unit = unit;
|
||||
}
|
||||
|
||||
recipe.Ingredients.Add(ingredient);
|
||||
}
|
||||
|
||||
return recipe;
|
||||
}
|
||||
|
||||
private static void ParseMeasure(string measure, out decimal? amount, out string? unit)
|
||||
{
|
||||
amount = null;
|
||||
unit = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(measure)) return;
|
||||
|
||||
// Try to extract leading number
|
||||
var parts = measure.Trim().Split(' ', 2);
|
||||
if (parts.Length > 0 && decimal.TryParse(parts[0], System.Globalization.NumberStyles.Any,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var parsed))
|
||||
{
|
||||
amount = parsed;
|
||||
unit = parts.Length > 1 ? parts[1].Trim() : null;
|
||||
}
|
||||
else
|
||||
{
|
||||
unit = measure;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file static class JsonElementExtensions
|
||||
{
|
||||
public static string? GetStringOrNull(this JsonElement el, string propertyName)
|
||||
{
|
||||
if (el.TryGetProperty(propertyName, out var prop) && prop.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var val = prop.GetString();
|
||||
return string.IsNullOrWhiteSpace(val) ? null : val;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user