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
+54
View File
@@ -16,6 +16,10 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
public DbSet<RecipeIngredient> RecipeIngredients => Set<RecipeIngredient>();
public DbSet<Invite> Invites => Set<Invite>();
public DbSet<PasswordResetToken> PasswordResetTokens => Set<PasswordResetToken>();
public DbSet<Product> Products => Set<Product>();
public DbSet<FamilyProduct> FamilyProducts => Set<FamilyProduct>();
public DbSet<FamilyProductOverride> FamilyProductOverrides => Set<FamilyProductOverride>();
public DbSet<ProductStoreSection> ProductStoreSections => Set<ProductStoreSection>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -94,6 +98,8 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.HasOne(i => i.RemovedByUser).WithMany().HasForeignKey(i => i.RemovedByUserId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(i => i.Recipe).WithMany().HasForeignKey(i => i.RecipeId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(i => i.Section).WithMany().HasForeignKey(i => i.SectionId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(i => i.Product).WithMany().HasForeignKey(i => i.ProductId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(i => i.FamilyProduct).WithMany().HasForeignKey(i => i.FamilyProductId).OnDelete(DeleteBehavior.SetNull);
e.HasIndex(i => i.FamilyId);
});
@@ -111,6 +117,54 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.Property(i => i.Name).HasMaxLength(200);
e.Property(i => i.Quantity).HasMaxLength(50);
e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(i => i.Product).WithMany().HasForeignKey(i => i.ProductId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(i => i.FamilyProduct).WithMany().HasForeignKey(i => i.FamilyProductId).OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity<Product>(e =>
{
e.Property(p => p.Name).HasMaxLength(200);
e.Property(p => p.Brand).HasMaxLength(200);
e.Property(p => p.Notes).HasMaxLength(1000);
e.HasIndex(p => p.Name).IsUnique();
});
modelBuilder.Entity<FamilyProduct>(e =>
{
e.Property(p => p.Name).HasMaxLength(200);
e.Property(p => p.Brand).HasMaxLength(200);
e.Property(p => p.Notes).HasMaxLength(1000);
e.HasOne(p => p.Family).WithMany().HasForeignKey(p => p.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(p => new { p.FamilyId, p.Name }).IsUnique();
});
modelBuilder.Entity<FamilyProductOverride>(e =>
{
e.HasKey(o => new { o.FamilyId, o.ProductId });
e.Property(o => o.Name).HasMaxLength(200);
e.Property(o => o.Brand).HasMaxLength(200);
e.Property(o => o.Notes).HasMaxLength(1000);
e.HasOne(o => o.Family).WithMany().HasForeignKey(o => o.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(o => o.Product).WithMany().HasForeignKey(o => o.ProductId).OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<ProductStoreSection>(e =>
{
e.HasOne(p => p.Family).WithMany().HasForeignKey(p => p.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(p => p.Store).WithMany().HasForeignKey(p => p.StoreId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(p => p.StoreSection).WithMany().HasForeignKey(p => p.StoreSectionId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(p => p.Product).WithMany().HasForeignKey(p => p.ProductId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(p => p.FamilyProduct).WithMany().HasForeignKey(p => p.FamilyProductId).OnDelete(DeleteBehavior.Cascade);
// Filtered unique indexes — at most one row per (Family, Store, Product)
// and one per (Family, Store, FamilyProduct). Postgres treats NULLs
// as distinct, so the partial WHERE keeps the index from caring
// about the inactive variant on each row.
e.HasIndex(p => new { p.FamilyId, p.StoreId, p.ProductId })
.IsUnique()
.HasFilter(@"""ProductId"" IS NOT NULL");
e.HasIndex(p => new { p.FamilyId, p.StoreId, p.FamilyProductId })
.IsUnique()
.HasFilter(@"""FamilyProductId"" IS NOT NULL");
});
}
}