6c8f0167e5
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.
171 lines
8.9 KiB
C#
171 lines
8.9 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using YesChef.Api.Entities;
|
|
|
|
namespace YesChef.Api.Data;
|
|
|
|
public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
|
{
|
|
public DbSet<Family> Families => Set<Family>();
|
|
public DbSet<FamilyMembership> FamilyMemberships => Set<FamilyMembership>();
|
|
public DbSet<User> Users => Set<User>();
|
|
public DbSet<Store> Stores => Set<Store>();
|
|
public DbSet<StoreSection> StoreSections => Set<StoreSection>();
|
|
public DbSet<ShoppingList> ShoppingLists => Set<ShoppingList>();
|
|
public DbSet<ShoppingListItem> ShoppingListItems => Set<ShoppingListItem>();
|
|
public DbSet<Recipe> Recipes => Set<Recipe>();
|
|
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)
|
|
{
|
|
modelBuilder.Entity<Family>(e =>
|
|
{
|
|
e.HasIndex(f => f.InviteCode).IsUnique();
|
|
e.Property(f => f.Name).HasMaxLength(100);
|
|
e.Property(f => f.InviteCode).HasMaxLength(100);
|
|
});
|
|
|
|
modelBuilder.Entity<FamilyMembership>(e =>
|
|
{
|
|
e.HasKey(m => new { m.UserId, m.FamilyId });
|
|
e.HasOne(m => m.User).WithMany().HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade);
|
|
e.HasOne(m => m.Family).WithMany().HasForeignKey(m => m.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
|
e.Property(m => m.Role).HasConversion<int>();
|
|
});
|
|
|
|
modelBuilder.Entity<User>(e =>
|
|
{
|
|
e.HasIndex(u => u.Name).IsUnique();
|
|
e.Property(u => u.Name).HasMaxLength(100);
|
|
e.Property(u => u.Email).HasMaxLength(254);
|
|
e.HasIndex(u => u.Email).IsUnique();
|
|
});
|
|
|
|
modelBuilder.Entity<PasswordResetToken>(e =>
|
|
{
|
|
e.HasOne(t => t.User).WithMany().HasForeignKey(t => t.UserId).OnDelete(DeleteBehavior.Cascade);
|
|
e.Property(t => t.TokenHash).HasMaxLength(64);
|
|
e.HasIndex(t => t.TokenHash).IsUnique();
|
|
e.HasIndex(t => new { t.UserId, t.ConsumedAt });
|
|
});
|
|
|
|
modelBuilder.Entity<Invite>(e =>
|
|
{
|
|
e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
|
e.HasOne(i => i.IssuedByUser).WithMany().HasForeignKey(i => i.IssuedByUserId).OnDelete(DeleteBehavior.Restrict);
|
|
e.HasOne(i => i.ConsumedByUser).WithMany().HasForeignKey(i => i.ConsumedByUserId).OnDelete(DeleteBehavior.SetNull);
|
|
e.Property(i => i.Email).HasMaxLength(254);
|
|
e.Property(i => i.TokenHash).HasMaxLength(64);
|
|
e.HasIndex(i => i.TokenHash).IsUnique();
|
|
e.HasIndex(i => new { i.FamilyId, i.ConsumedAt });
|
|
});
|
|
|
|
modelBuilder.Entity<Store>(e =>
|
|
{
|
|
e.HasOne(s => s.Family).WithMany().HasForeignKey(s => s.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
|
e.HasIndex(s => new { s.FamilyId, s.Name }).IsUnique();
|
|
e.Property(s => s.Name).HasMaxLength(100);
|
|
});
|
|
|
|
modelBuilder.Entity<StoreSection>(e =>
|
|
{
|
|
e.HasOne(s => s.Family).WithMany().HasForeignKey(s => s.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
|
e.HasOne(s => s.Store).WithMany().HasForeignKey(s => s.StoreId).OnDelete(DeleteBehavior.Cascade);
|
|
e.HasIndex(s => new { s.StoreId, s.Name }).IsUnique();
|
|
e.Property(s => s.Name).HasMaxLength(100);
|
|
});
|
|
|
|
modelBuilder.Entity<ShoppingList>(e =>
|
|
{
|
|
e.Property(l => l.Name).HasMaxLength(200);
|
|
e.HasOne(l => l.Family).WithMany().HasForeignKey(l => l.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
|
e.HasOne(l => l.Store).WithMany().HasForeignKey(l => l.StoreId);
|
|
e.HasOne(l => l.CreatedByUser).WithMany().HasForeignKey(l => l.CreatedByUserId);
|
|
e.HasMany(l => l.Items).WithOne(i => i.ShoppingList).HasForeignKey(i => i.ShoppingListId).OnDelete(DeleteBehavior.Cascade);
|
|
e.HasIndex(l => l.FamilyId);
|
|
});
|
|
|
|
modelBuilder.Entity<ShoppingListItem>(e =>
|
|
{
|
|
e.Property(i => i.Name).HasMaxLength(300);
|
|
e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
|
e.HasOne(i => i.CheckedByUser).WithMany().HasForeignKey(i => i.CheckedByUserId).OnDelete(DeleteBehavior.SetNull);
|
|
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);
|
|
});
|
|
|
|
modelBuilder.Entity<Recipe>(e =>
|
|
{
|
|
e.Property(r => r.Title).HasMaxLength(300);
|
|
e.HasOne(r => r.Family).WithMany().HasForeignKey(r => r.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
|
e.HasOne(r => r.CreatedByUser).WithMany().HasForeignKey(r => r.CreatedByUserId);
|
|
e.HasMany(r => r.Ingredients).WithOne(i => i.Recipe).HasForeignKey(i => i.RecipeId).OnDelete(DeleteBehavior.Cascade);
|
|
e.HasIndex(r => r.FamilyId);
|
|
});
|
|
|
|
modelBuilder.Entity<RecipeIngredient>(e =>
|
|
{
|
|
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");
|
|
});
|
|
}
|
|
}
|