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,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();
}
}
}

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

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

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

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

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

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

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

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

View 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; } = [];
}

View 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
View 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; } = [];
}

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

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

View 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
View 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();

View File

@@ -0,0 +1,13 @@
{
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:8080",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

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

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

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

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

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