.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>
129 lines
3.9 KiB
C#
129 lines
3.9 KiB
C#
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;
|
|
}
|
|
}
|