Add product catalog with per-store section memory

Introduces a global Products catalog plus per-family overrides and
private FamilyProducts, exposed via /api/products with a merged
search. Shopping list items and recipe ingredients gain optional
ProductId/FamilyProductId links, and a new ProductStoreSection
table remembers which section a product was last placed in at a
given store so future adds auto-assign the right section.

Frontend gets a reusable ProductTypeahead component, wired into
list-item add and recipe ingredient entry with free-form fallback.

A startup CatalogSeeder loads ~115 curated staples from an embedded
JSON resource via INSERT ... ON CONFLICT DO NOTHING; skipped under
the Testing environment so integration tests keep a clean slate.
This commit is contained in:
Josh Rogers
2026-05-09 21:29:51 -05:00
parent 5c6abc1e43
commit 6c8f0167e5
27 changed files with 4621 additions and 36 deletions
@@ -72,6 +72,71 @@ namespace YesChef.Api.Migrations
b.ToTable("FamilyMemberships");
});
modelBuilder.Entity("YesChef.Api.Entities.FamilyProduct", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.HasKey("Id");
b.HasIndex("FamilyId", "Name")
.IsUnique();
b.ToTable("FamilyProducts");
});
modelBuilder.Entity("YesChef.Api.Entities.FamilyProductOverride", b =>
{
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<int>("ProductId")
.HasColumnType("integer");
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("FamilyId", "ProductId");
b.HasIndex("ProductId");
b.ToTable("FamilyProductOverrides");
});
modelBuilder.Entity("YesChef.Api.Entities.Invite", b =>
{
b.Property<int>("Id")
@@ -157,6 +222,85 @@ namespace YesChef.Api.Migrations
b.ToTable("PasswordResetTokens");
});
modelBuilder.Entity("YesChef.Api.Entities.Product", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Notes")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Products");
});
modelBuilder.Entity("YesChef.Api.Entities.ProductStoreSection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<int?>("FamilyProductId")
.HasColumnType("integer");
b.Property<int?>("ProductId")
.HasColumnType("integer");
b.Property<int>("StoreId")
.HasColumnType("integer");
b.Property<int>("StoreSectionId")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("FamilyProductId");
b.HasIndex("ProductId");
b.HasIndex("StoreId");
b.HasIndex("StoreSectionId");
b.HasIndex("FamilyId", "StoreId", "FamilyProductId")
.IsUnique()
.HasFilter("\"FamilyProductId\" IS NOT NULL");
b.HasIndex("FamilyId", "StoreId", "ProductId")
.IsUnique()
.HasFilter("\"ProductId\" IS NOT NULL");
b.ToTable("ProductStoreSections");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.Property<int>("Id")
@@ -214,11 +358,17 @@ namespace YesChef.Api.Migrations
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<int?>("FamilyProductId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int?>("ProductId")
.HasColumnType("integer");
b.Property<string>("Quantity")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
@@ -233,6 +383,10 @@ namespace YesChef.Api.Migrations
b.HasIndex("FamilyId");
b.HasIndex("FamilyProductId");
b.HasIndex("ProductId");
b.HasIndex("RecipeId");
b.ToTable("RecipeIngredients");
@@ -297,6 +451,9 @@ namespace YesChef.Api.Migrations
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<int?>("FamilyProductId")
.HasColumnType("integer");
b.Property<bool>("IsChecked")
.HasColumnType("boolean");
@@ -305,6 +462,9 @@ namespace YesChef.Api.Migrations
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<int?>("ProductId")
.HasColumnType("integer");
b.Property<int?>("RecipeId")
.HasColumnType("integer");
@@ -329,6 +489,10 @@ namespace YesChef.Api.Migrations
b.HasIndex("FamilyId");
b.HasIndex("FamilyProductId");
b.HasIndex("ProductId");
b.HasIndex("RecipeId");
b.HasIndex("RemovedByUserId");
@@ -460,6 +624,36 @@ namespace YesChef.Api.Migrations
b.Navigation("User");
});
modelBuilder.Entity("YesChef.Api.Entities.FamilyProduct", b =>
{
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Family");
});
modelBuilder.Entity("YesChef.Api.Entities.FamilyProductOverride", b =>
{
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Product", "Product")
.WithMany()
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Family");
b.Navigation("Product");
});
modelBuilder.Entity("YesChef.Api.Entities.Invite", b =>
{
b.HasOne("YesChef.Api.Entities.User", "ConsumedByUser")
@@ -497,6 +691,47 @@ namespace YesChef.Api.Migrations
b.Navigation("User");
});
modelBuilder.Entity("YesChef.Api.Entities.ProductStoreSection", b =>
{
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.FamilyProduct", "FamilyProduct")
.WithMany()
.HasForeignKey("FamilyProductId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("YesChef.Api.Entities.Product", "Product")
.WithMany()
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Cascade);
b.HasOne("YesChef.Api.Entities.Store", "Store")
.WithMany()
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.StoreSection", "StoreSection")
.WithMany()
.HasForeignKey("StoreSectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Family");
b.Navigation("FamilyProduct");
b.Navigation("Product");
b.Navigation("Store");
b.Navigation("StoreSection");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.HasOne("YesChef.Api.Entities.User", "CreatedByUser")
@@ -524,6 +759,16 @@ namespace YesChef.Api.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.FamilyProduct", "FamilyProduct")
.WithMany()
.HasForeignKey("FamilyProductId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.Product", "Product")
.WithMany()
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.Recipe", "Recipe")
.WithMany("Ingredients")
.HasForeignKey("RecipeId")
@@ -532,6 +777,10 @@ namespace YesChef.Api.Migrations
b.Navigation("Family");
b.Navigation("FamilyProduct");
b.Navigation("Product");
b.Navigation("Recipe");
});
@@ -575,6 +824,16 @@ namespace YesChef.Api.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.FamilyProduct", "FamilyProduct")
.WithMany()
.HasForeignKey("FamilyProductId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.Product", "Product")
.WithMany()
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.Recipe", "Recipe")
.WithMany()
.HasForeignKey("RecipeId")
@@ -600,6 +859,10 @@ namespace YesChef.Api.Migrations
b.Navigation("Family");
b.Navigation("FamilyProduct");
b.Navigation("Product");
b.Navigation("Recipe");
b.Navigation("RemovedByUser");