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:
2026-04-14 19:10:10 +00:00
parent 660bcd1953
commit f58782774b
51 changed files with 4061 additions and 0 deletions

View 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;
}
}