Initial commit: YesChef family shopping list and recipe app

Backend (.NET 10 minimal API):
- Vertical slice architecture with feature folders
- Postgres via EF Core with initial migration
- JWT auth with family invite code registration
- REST endpoints for stores, shopping lists, items, recipes
- SignalR hub for real-time list collaboration (per-list groups
  and lists-overview group for live list creation/archival/progress)
- Multi-stage Dockerfile

Frontend (SvelteKit + Svelte 5 runes, Tailwind v4):
- Mobile-first PWA with web manifest and service worker
- Bottom-nav layout, login/register, lists overview, list detail,
  stores management, recipes (list/create/detail with add-to-list)
- SignalR client with reference-counted connection
- Real-time updates on both lists overview and list detail pages

Infrastructure:
- docker-compose.yml with postgres, backend, frontend services
  and Traefik labels for path-based routing (/api, /hubs to backend)
- .env.example with required config

End-to-end tests (Playwright):
- test-e2e.mjs: single-user flow (auth, stores, lists, items, recipes)
- test-e2e-multiuser.mjs: two-user real-time sync coverage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Josh Rogers
2026-05-06 19:32:39 -05:00
commit 48d30df07b
64 changed files with 5873 additions and 0 deletions
@@ -0,0 +1,308 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using YesChef.Api.Data;
#nullable disable
namespace YesChef.Api.Migrations
{
[DbContext(typeof(YesChefDb))]
partial class YesChefDbModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<string>("Instructions")
.HasColumnType("text");
b.Property<int?>("Servings")
.HasColumnType("integer");
b.Property<string>("SourceUrl")
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CreatedByUserId");
b.ToTable("Recipes");
});
modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Quantity")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("RecipeId")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("RecipeId");
b.ToTable("RecipeIngredients");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<bool>("IsArchived")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("StoreId")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CreatedByUserId");
b.HasIndex("StoreId");
b.ToTable("ShoppingLists");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CheckedByUserId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsChecked")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<int?>("RecipeId")
.HasColumnType("integer");
b.Property<int>("ShoppingListId")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CheckedByUserId");
b.HasIndex("RecipeId");
b.HasIndex("ShoppingListId");
b.ToTable("ShoppingListItems");
});
modelBuilder.Entity("YesChef.Api.Entities.Store", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Stores");
});
modelBuilder.Entity("YesChef.Api.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.HasOne("YesChef.Api.Entities.User", "CreatedByUser")
.WithMany()
.HasForeignKey("CreatedByUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CreatedByUser");
});
modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b =>
{
b.HasOne("YesChef.Api.Entities.Recipe", "Recipe")
.WithMany("Ingredients")
.HasForeignKey("RecipeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Recipe");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b =>
{
b.HasOne("YesChef.Api.Entities.User", "CreatedByUser")
.WithMany()
.HasForeignKey("CreatedByUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Store", "Store")
.WithMany()
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CreatedByUser");
b.Navigation("Store");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b =>
{
b.HasOne("YesChef.Api.Entities.User", "CheckedByUser")
.WithMany()
.HasForeignKey("CheckedByUserId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.Recipe", "Recipe")
.WithMany()
.HasForeignKey("RecipeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList")
.WithMany("Items")
.HasForeignKey("ShoppingListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CheckedByUser");
b.Navigation("Recipe");
b.Navigation("ShoppingList");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.Navigation("Ingredients");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b =>
{
b.Navigation("Items");
});
#pragma warning restore 612, 618
}
}
}