.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>
181 lines
5.9 KiB
C#
181 lines
5.9 KiB
C#
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);
|
|
}
|
|
}
|