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:
98
backend/Controllers/MealPlanController.cs
Normal file
98
backend/Controllers/MealPlanController.cs
Normal file
@@ -0,0 +1,98 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MealPlanner.Services;
|
||||
|
||||
namespace MealPlanner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/mealplan")]
|
||||
[Authorize]
|
||||
public class MealPlanController(MealPlanService mealPlanService) : ControllerBase
|
||||
{
|
||||
private string UserId => User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException();
|
||||
|
||||
public record GenerateRequest(string? WeekStart);
|
||||
public record SwapRequest(Guid RecipeId);
|
||||
|
||||
[HttpPost("generate")]
|
||||
public async Task<IActionResult> Generate([FromBody] GenerateRequest? body)
|
||||
{
|
||||
DateOnly weekStart;
|
||||
|
||||
if (body?.WeekStart is not null && DateOnly.TryParse(body.WeekStart, out var parsed))
|
||||
{
|
||||
weekStart = MealPlanService.GetWeekStart(parsed);
|
||||
}
|
||||
else
|
||||
{
|
||||
weekStart = MealPlanService.GetWeekStart(DateOnly.FromDateTime(DateTime.UtcNow));
|
||||
}
|
||||
|
||||
var plan = await mealPlanService.GenerateWeekPlanAsync(UserId, weekStart);
|
||||
return Ok(plan);
|
||||
}
|
||||
|
||||
[HttpGet("current")]
|
||||
public async Task<IActionResult> GetCurrent()
|
||||
{
|
||||
var plan = await mealPlanService.GetCurrentPlanAsync(UserId);
|
||||
if (plan is null) return NotFound();
|
||||
return Ok(plan);
|
||||
}
|
||||
|
||||
[HttpGet("{weekStart}")]
|
||||
public async Task<IActionResult> GetByWeek(string weekStart)
|
||||
{
|
||||
if (!DateOnly.TryParse(weekStart, out var date)) return BadRequest("Invalid date format.");
|
||||
var plan = await mealPlanService.GetPlanAsync(UserId, date);
|
||||
if (plan is null) return NotFound();
|
||||
return Ok(plan);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}/entry/{date}")]
|
||||
public async Task<IActionResult> SwapEntry(Guid id, string date, [FromBody] SwapRequest body)
|
||||
{
|
||||
if (!DateOnly.TryParse(date, out var parsedDate)) return BadRequest("Invalid date format.");
|
||||
|
||||
// Find entry by plan id + date
|
||||
var plan = await mealPlanService.GetPlanAsync(UserId, MealPlanService.GetWeekStart(parsedDate));
|
||||
if (plan is null || plan.Id != id) return NotFound();
|
||||
|
||||
var entry = plan.Entries.FirstOrDefault(e => e.Date == parsedDate);
|
||||
if (entry is null) return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await mealPlanService.SwapEntryAsync(entry.Id, body.RecipeId, UserId);
|
||||
if (updated is null) return NotFound();
|
||||
return Ok(updated);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/entry/{date}/reroll")]
|
||||
public async Task<IActionResult> RerollEntry(Guid id, string date)
|
||||
{
|
||||
if (!DateOnly.TryParse(date, out var parsedDate)) return BadRequest("Invalid date format.");
|
||||
|
||||
var plan = await mealPlanService.GetPlanAsync(UserId, MealPlanService.GetWeekStart(parsedDate));
|
||||
if (plan is null || plan.Id != id) return NotFound();
|
||||
|
||||
var entry = plan.Entries.FirstOrDefault(e => e.Date == parsedDate);
|
||||
if (entry is null) return NotFound();
|
||||
|
||||
try
|
||||
{
|
||||
var updated = await mealPlanService.RerollEntryAsync(entry.Id, UserId);
|
||||
if (updated is null) return StatusCode(503, "Could not fetch a new recipe. Try again.");
|
||||
return Ok(updated);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
}
|
||||
}
|
||||
74
backend/Controllers/RecipeController.cs
Normal file
74
backend/Controllers/RecipeController.cs
Normal file
@@ -0,0 +1,74 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MealPlanner.Models;
|
||||
using MealPlanner.Services;
|
||||
|
||||
namespace MealPlanner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/recipes")]
|
||||
[Authorize]
|
||||
public class RecipeController(RecipeService recipeService) : ControllerBase
|
||||
{
|
||||
private string UserId => User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException();
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetOwn()
|
||||
{
|
||||
var recipes = await recipeService.GetOwnRecipesAsync(UserId);
|
||||
return Ok(recipes);
|
||||
}
|
||||
|
||||
[HttpGet("{id:guid}")]
|
||||
public async Task<IActionResult> GetById(Guid id)
|
||||
{
|
||||
var recipe = await recipeService.GetByIdOrFetchAsync(id);
|
||||
if (recipe is null) return NotFound();
|
||||
return Ok(recipe);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create([FromBody] Recipe recipe)
|
||||
{
|
||||
var created = await recipeService.CreateAsync(UserId, recipe);
|
||||
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
|
||||
}
|
||||
|
||||
[HttpPut("{id:guid}")]
|
||||
public async Task<IActionResult> Update(Guid id, [FromBody] Recipe recipe)
|
||||
{
|
||||
try
|
||||
{
|
||||
var updated = await recipeService.UpdateAsync(id, UserId, recipe);
|
||||
if (updated is null) return NotFound();
|
||||
return Ok(updated);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpDelete("{id:guid}")]
|
||||
public async Task<IActionResult> Delete(Guid id)
|
||||
{
|
||||
try
|
||||
{
|
||||
var deleted = await recipeService.DeleteAsync(id, UserId);
|
||||
if (!deleted) return NotFound();
|
||||
return NoContent();
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("search")]
|
||||
public async Task<IActionResult> Search([FromQuery] string q)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(q)) return BadRequest("Query parameter 'q' is required.");
|
||||
var results = await recipeService.SearchAsync(q, UserId);
|
||||
return Ok(results);
|
||||
}
|
||||
}
|
||||
42
backend/Controllers/SettingsController.cs
Normal file
42
backend/Controllers/SettingsController.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MealPlanner.Data;
|
||||
using MealPlanner.Models;
|
||||
|
||||
namespace MealPlanner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/settings")]
|
||||
[Authorize]
|
||||
public class SettingsController(AppDbContext db) : ControllerBase
|
||||
{
|
||||
private string UserId => User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException();
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> Get()
|
||||
{
|
||||
var settings = await db.UserSettings.FindAsync(UserId);
|
||||
if (settings is null)
|
||||
{
|
||||
settings = new UserSettings { UserId = UserId, HouseholdSize = 2 };
|
||||
db.UserSettings.Add(settings);
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Ok(settings);
|
||||
}
|
||||
|
||||
[HttpPut]
|
||||
public async Task<IActionResult> Update([FromBody] UserSettings updated)
|
||||
{
|
||||
var settings = await db.UserSettings.FindAsync(UserId);
|
||||
if (settings is null)
|
||||
{
|
||||
settings = new UserSettings { UserId = UserId };
|
||||
db.UserSettings.Add(settings);
|
||||
}
|
||||
|
||||
settings.HouseholdSize = updated.HouseholdSize;
|
||||
await db.SaveChangesAsync();
|
||||
return Ok(settings);
|
||||
}
|
||||
}
|
||||
45
backend/Controllers/ShoppingListController.cs
Normal file
45
backend/Controllers/ShoppingListController.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MealPlanner.Services;
|
||||
|
||||
namespace MealPlanner.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/shoppinglist")]
|
||||
[Authorize]
|
||||
public class ShoppingListController(ShoppingListService shoppingListService) : ControllerBase
|
||||
{
|
||||
private string UserId => User.FindFirst("sub")?.Value ?? throw new UnauthorizedAccessException();
|
||||
|
||||
[HttpGet("{mealPlanId:guid}")]
|
||||
public async Task<IActionResult> GetList(Guid mealPlanId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var items = await shoppingListService.GetShoppingListAsync(mealPlanId, UserId);
|
||||
return Ok(items);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("{mealPlanId:guid}/check/{itemName}")]
|
||||
public async Task<IActionResult> ToggleCheck(Guid mealPlanId, string itemName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = await shoppingListService.ToggleCheckAsync(mealPlanId, itemName, UserId);
|
||||
return Ok(result);
|
||||
}
|
||||
catch (KeyNotFoundException ex)
|
||||
{
|
||||
return NotFound(ex.Message);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
return Forbid();
|
||||
}
|
||||
}
|
||||
}
|
||||
71
backend/Data/AppDbContext.cs
Normal file
71
backend/Data/AppDbContext.cs
Normal file
@@ -0,0 +1,71 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MealPlanner.Models;
|
||||
|
||||
namespace MealPlanner.Data;
|
||||
|
||||
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<Recipe> Recipes => Set<Recipe>();
|
||||
public DbSet<RecipeIngredient> RecipeIngredients => Set<RecipeIngredient>();
|
||||
public DbSet<MealPlan> MealPlans => Set<MealPlan>();
|
||||
public DbSet<MealPlanEntry> MealPlanEntries => Set<MealPlanEntry>();
|
||||
public DbSet<UserSettings> UserSettings => Set<UserSettings>();
|
||||
public DbSet<CheckedShoppingItem> CheckedShoppingItems => Set<CheckedShoppingItem>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Recipe
|
||||
modelBuilder.Entity<Recipe>(e =>
|
||||
{
|
||||
e.HasKey(r => r.Id);
|
||||
e.HasIndex(r => r.UserId);
|
||||
e.HasIndex(r => r.ExternalId);
|
||||
e.HasMany(r => r.Ingredients)
|
||||
.WithOne(i => i.Recipe)
|
||||
.HasForeignKey(i => i.RecipeId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// RecipeIngredient
|
||||
modelBuilder.Entity<RecipeIngredient>(e =>
|
||||
{
|
||||
e.HasKey(i => i.Id);
|
||||
});
|
||||
|
||||
// MealPlan
|
||||
modelBuilder.Entity<MealPlan>(e =>
|
||||
{
|
||||
e.HasKey(p => p.Id);
|
||||
e.HasIndex(p => new { p.UserId, p.WeekStart }).IsUnique();
|
||||
e.HasMany(p => p.Entries)
|
||||
.WithOne(en => en.MealPlan)
|
||||
.HasForeignKey(en => en.MealPlanId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
// MealPlanEntry
|
||||
modelBuilder.Entity<MealPlanEntry>(e =>
|
||||
{
|
||||
e.HasKey(en => en.Id);
|
||||
e.HasOne(en => en.Recipe)
|
||||
.WithMany()
|
||||
.HasForeignKey(en => en.RecipeId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
|
||||
// UserSettings
|
||||
modelBuilder.Entity<UserSettings>(e =>
|
||||
{
|
||||
e.HasKey(s => s.UserId);
|
||||
});
|
||||
|
||||
// CheckedShoppingItem
|
||||
modelBuilder.Entity<CheckedShoppingItem>(e =>
|
||||
{
|
||||
e.HasKey(c => c.Id);
|
||||
e.HasIndex(c => new { c.MealPlanId, c.UserId, c.ItemName });
|
||||
});
|
||||
}
|
||||
}
|
||||
225
backend/Migrations/20260414190645_InitialCreate.Designer.cs
generated
Normal file
225
backend/Migrations/20260414190645_InitialCreate.Designer.cs
generated
Normal file
@@ -0,0 +1,225 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using MealPlanner.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace backend.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260414190645_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.26")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.CheckedShoppingItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsChecked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ItemName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("MealPlanId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MealPlanId", "UserId", "ItemName");
|
||||
|
||||
b.ToTable("CheckedShoppingItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.MealPlan", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateOnly>("WeekStart")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "WeekStart")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MealPlans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.MealPlanEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<Guid>("MealPlanId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("RecipeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MealPlanId");
|
||||
|
||||
b.HasIndex("RecipeId");
|
||||
|
||||
b.ToTable("MealPlanEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.Recipe", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ImageUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Instructions")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Source")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExternalId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Recipes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.RecipeIngredient", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal?>("Amount")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("RecipeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipeId");
|
||||
|
||||
b.ToTable("RecipeIngredients");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.UserSettings", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("HouseholdSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("UserId");
|
||||
|
||||
b.ToTable("UserSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.MealPlanEntry", b =>
|
||||
{
|
||||
b.HasOne("MealPlanner.Models.MealPlan", "MealPlan")
|
||||
.WithMany("Entries")
|
||||
.HasForeignKey("MealPlanId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MealPlanner.Models.Recipe", "Recipe")
|
||||
.WithMany()
|
||||
.HasForeignKey("RecipeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("MealPlan");
|
||||
|
||||
b.Navigation("Recipe");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.RecipeIngredient", b =>
|
||||
{
|
||||
b.HasOne("MealPlanner.Models.Recipe", "Recipe")
|
||||
.WithMany("Ingredients")
|
||||
.HasForeignKey("RecipeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Recipe");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.MealPlan", b =>
|
||||
{
|
||||
b.Navigation("Entries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.Recipe", b =>
|
||||
{
|
||||
b.Navigation("Ingredients");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
180
backend/Migrations/20260414190645_InitialCreate.cs
Normal file
180
backend/Migrations/20260414190645_InitialCreate.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace backend.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CheckedShoppingItems",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MealPlanId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ItemName = table.Column<string>(type: "text", nullable: false),
|
||||
UserId = table.Column<string>(type: "text", nullable: false),
|
||||
IsChecked = table.Column<bool>(type: "boolean", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CheckedShoppingItems", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MealPlans",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<string>(type: "text", nullable: false),
|
||||
WeekStart = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MealPlans", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Recipes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
UserId = table.Column<string>(type: "text", nullable: true),
|
||||
ExternalId = table.Column<string>(type: "text", nullable: true),
|
||||
Title = table.Column<string>(type: "text", nullable: false),
|
||||
Instructions = table.Column<string>(type: "text", nullable: false),
|
||||
ImageUrl = table.Column<string>(type: "text", nullable: true),
|
||||
Source = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Recipes", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "UserSettings",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<string>(type: "text", nullable: false),
|
||||
HouseholdSize = table.Column<int>(type: "integer", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_UserSettings", x => x.UserId);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "MealPlanEntries",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
MealPlanId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Date = table.Column<DateOnly>(type: "date", nullable: false),
|
||||
RecipeId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_MealPlanEntries", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_MealPlanEntries_MealPlans_MealPlanId",
|
||||
column: x => x.MealPlanId,
|
||||
principalTable: "MealPlans",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_MealPlanEntries_Recipes_RecipeId",
|
||||
column: x => x.RecipeId,
|
||||
principalTable: "Recipes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "RecipeIngredients",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
RecipeId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Name = table.Column<string>(type: "text", nullable: false),
|
||||
Amount = table.Column<decimal>(type: "numeric", nullable: true),
|
||||
Unit = table.Column<string>(type: "text", nullable: true),
|
||||
Category = table.Column<string>(type: "text", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_RecipeIngredients", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_RecipeIngredients_Recipes_RecipeId",
|
||||
column: x => x.RecipeId,
|
||||
principalTable: "Recipes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CheckedShoppingItems_MealPlanId_UserId_ItemName",
|
||||
table: "CheckedShoppingItems",
|
||||
columns: new[] { "MealPlanId", "UserId", "ItemName" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MealPlanEntries_MealPlanId",
|
||||
table: "MealPlanEntries",
|
||||
column: "MealPlanId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MealPlanEntries_RecipeId",
|
||||
table: "MealPlanEntries",
|
||||
column: "RecipeId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MealPlans_UserId_WeekStart",
|
||||
table: "MealPlans",
|
||||
columns: new[] { "UserId", "WeekStart" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_RecipeIngredients_RecipeId",
|
||||
table: "RecipeIngredients",
|
||||
column: "RecipeId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Recipes_ExternalId",
|
||||
table: "Recipes",
|
||||
column: "ExternalId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Recipes_UserId",
|
||||
table: "Recipes",
|
||||
column: "UserId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CheckedShoppingItems");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MealPlanEntries");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "RecipeIngredients");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "UserSettings");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "MealPlans");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Recipes");
|
||||
}
|
||||
}
|
||||
}
|
||||
222
backend/Migrations/AppDbContextModelSnapshot.cs
Normal file
222
backend/Migrations/AppDbContextModelSnapshot.cs
Normal file
@@ -0,0 +1,222 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using MealPlanner.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace backend.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
partial class AppDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.26")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.CheckedShoppingItem", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsChecked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ItemName")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("MealPlanId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MealPlanId", "UserId", "ItemName");
|
||||
|
||||
b.ToTable("CheckedShoppingItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.MealPlan", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateOnly>("WeekStart")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId", "WeekStart")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("MealPlans");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.MealPlanEntry", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<Guid>("MealPlanId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("RecipeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("MealPlanId");
|
||||
|
||||
b.HasIndex("RecipeId");
|
||||
|
||||
b.ToTable("MealPlanEntries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.Recipe", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("ExternalId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("ImageUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Instructions")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Source")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ExternalId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Recipes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.RecipeIngredient", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal?>("Amount")
|
||||
.HasColumnType("numeric");
|
||||
|
||||
b.Property<string>("Category")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("RecipeId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Unit")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RecipeId");
|
||||
|
||||
b.ToTable("RecipeIngredients");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.UserSettings", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("HouseholdSize")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("UserId");
|
||||
|
||||
b.ToTable("UserSettings");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.MealPlanEntry", b =>
|
||||
{
|
||||
b.HasOne("MealPlanner.Models.MealPlan", "MealPlan")
|
||||
.WithMany("Entries")
|
||||
.HasForeignKey("MealPlanId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("MealPlanner.Models.Recipe", "Recipe")
|
||||
.WithMany()
|
||||
.HasForeignKey("RecipeId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("MealPlan");
|
||||
|
||||
b.Navigation("Recipe");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.RecipeIngredient", b =>
|
||||
{
|
||||
b.HasOne("MealPlanner.Models.Recipe", "Recipe")
|
||||
.WithMany("Ingredients")
|
||||
.HasForeignKey("RecipeId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Recipe");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.MealPlan", b =>
|
||||
{
|
||||
b.Navigation("Entries");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MealPlanner.Models.Recipe", b =>
|
||||
{
|
||||
b.Navigation("Ingredients");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
10
backend/Models/CheckedShoppingItem.cs
Normal file
10
backend/Models/CheckedShoppingItem.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace MealPlanner.Models;
|
||||
|
||||
public class CheckedShoppingItem
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid MealPlanId { get; set; }
|
||||
public string ItemName { get; set; } = "";
|
||||
public string UserId { get; set; } = "";
|
||||
public bool IsChecked { get; set; }
|
||||
}
|
||||
11
backend/Models/MealPlan.cs
Normal file
11
backend/Models/MealPlan.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace MealPlanner.Models;
|
||||
|
||||
public class MealPlan
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string UserId { get; set; } = "";
|
||||
public DateOnly WeekStart { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public List<MealPlanEntry> Entries { get; set; } = [];
|
||||
}
|
||||
12
backend/Models/MealPlanEntry.cs
Normal file
12
backend/Models/MealPlanEntry.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace MealPlanner.Models;
|
||||
|
||||
public class MealPlanEntry
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid MealPlanId { get; set; }
|
||||
public DateOnly Date { get; set; }
|
||||
public Guid RecipeId { get; set; }
|
||||
|
||||
public MealPlan MealPlan { get; set; } = null!;
|
||||
public Recipe Recipe { get; set; } = null!;
|
||||
}
|
||||
21
backend/Models/Recipe.cs
Normal file
21
backend/Models/Recipe.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace MealPlanner.Models;
|
||||
|
||||
public enum RecipeSource
|
||||
{
|
||||
Own,
|
||||
TheMealDb
|
||||
}
|
||||
|
||||
public class Recipe
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string? UserId { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
public string Title { get; set; } = "";
|
||||
public string Instructions { get; set; } = "";
|
||||
public string? ImageUrl { get; set; }
|
||||
public RecipeSource Source { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public List<RecipeIngredient> Ingredients { get; set; } = [];
|
||||
}
|
||||
13
backend/Models/RecipeIngredient.cs
Normal file
13
backend/Models/RecipeIngredient.cs
Normal file
@@ -0,0 +1,13 @@
|
||||
namespace MealPlanner.Models;
|
||||
|
||||
public class RecipeIngredient
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid RecipeId { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public decimal? Amount { get; set; }
|
||||
public string? Unit { get; set; }
|
||||
public string? Category { get; set; }
|
||||
|
||||
public Recipe Recipe { get; set; } = null!;
|
||||
}
|
||||
10
backend/Models/ShoppingItem.cs
Normal file
10
backend/Models/ShoppingItem.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace MealPlanner.Models;
|
||||
|
||||
public class ShoppingItem
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public decimal? TotalAmount { get; set; }
|
||||
public string? Unit { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public bool IsChecked { get; set; }
|
||||
}
|
||||
7
backend/Models/UserSettings.cs
Normal file
7
backend/Models/UserSettings.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace MealPlanner.Models;
|
||||
|
||||
public class UserSettings
|
||||
{
|
||||
public string UserId { get; set; } = "";
|
||||
public int HouseholdSize { get; set; } = 2;
|
||||
}
|
||||
70
backend/Program.cs
Normal file
70
backend/Program.cs
Normal file
@@ -0,0 +1,70 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using MealPlanner.Data;
|
||||
using MealPlanner.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddControllers()
|
||||
.AddJsonOptions(o =>
|
||||
{
|
||||
o.JsonSerializerOptions.ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles;
|
||||
o.JsonSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
|
||||
});
|
||||
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
|
||||
// Zitadel JWT Bearer Auth
|
||||
builder.Services.AddAuthentication("Bearer")
|
||||
.AddJwtBearer("Bearer", options =>
|
||||
{
|
||||
options.Authority = builder.Configuration["Zitadel:Issuer"] ?? "https://auth.kuns.dev";
|
||||
options.Audience = builder.Configuration["Zitadel:ClientId"] ?? "";
|
||||
options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = false, // Zitadel uses client_id as audience in some configs
|
||||
ValidateLifetime = true,
|
||||
NameClaimType = "sub",
|
||||
};
|
||||
});
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
builder.Services.AddHttpClient<TheMealDbClient>();
|
||||
builder.Services.AddScoped<RecipeService>();
|
||||
builder.Services.AddScoped<MealPlanService>();
|
||||
builder.Services.AddScoped<ShoppingListService>();
|
||||
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
var allowedOrigin = builder.Configuration["AllowedOrigin"] ?? "https://essen.kuns.dev";
|
||||
builder.Services.AddCors(options =>
|
||||
options.AddDefaultPolicy(policy =>
|
||||
policy.WithOrigins(allowedOrigin, "http://localhost:5173")
|
||||
.AllowAnyMethod()
|
||||
.AllowAnyHeader()
|
||||
.AllowCredentials()));
|
||||
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
|
||||
app.UseCors();
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/health");
|
||||
app.UseStaticFiles();
|
||||
app.Run();
|
||||
13
backend/Properties/launchSettings.json
Normal file
13
backend/Properties/launchSettings.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:8080",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
6
backend/appsettings.Development.json
Normal file
6
backend/appsettings.Development.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=54320;Database=mealplanner;Username=mika;Password=changeme"
|
||||
},
|
||||
"AllowedOrigin": "http://localhost:5173"
|
||||
}
|
||||
16
backend/appsettings.json
Normal file
16
backend/appsettings.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Host=localhost;Port=54320;Database=mealplanner;Username=mika;Password=changeme"
|
||||
},
|
||||
"Zitadel": {
|
||||
"Issuer": "https://auth.kuns.dev",
|
||||
"ClientId": ""
|
||||
},
|
||||
"AllowedOrigin": "https://essen.kuns.dev",
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.EntityFrameworkCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
16
backend/backend.csproj
Normal file
16
backend/backend.csproj
Normal file
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.*" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.*">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.*" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
Reference in New Issue
Block a user