From 6c8f0167e5e5b0d4b6cca8f1835aee1062c8d455 Mon Sep 17 00:00:00 2001 From: Josh Rogers Date: Sat, 9 May 2026 21:29:51 -0500 Subject: [PATCH] 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. --- .../Features/ProductEndpointsTests.cs | 256 +++++ .../Features/RecipeEndpointsTests.cs | 61 ++ .../Features/ShoppingListEndpointsTests.cs | 193 ++++ .../YesChef.Api/Data/Seed/CatalogSeeder.cs | 66 ++ .../YesChef.Api/Data/Seed/products.json | 142 +++ src/backend/YesChef.Api/Data/YesChefDb.cs | 54 + .../YesChef.Api/Entities/FamilyProduct.cs | 16 + .../Entities/FamilyProductOverride.cs | 19 + src/backend/YesChef.Api/Entities/Product.cs | 15 + .../Entities/ProductStoreSection.cs | 27 + .../YesChef.Api/Entities/RecipeIngredient.cs | 5 + .../YesChef.Api/Entities/ShoppingListItem.cs | 6 + .../Features/Products/ProductEndpoints.cs | 194 ++++ .../Features/Recipes/RecipeEndpoints.cs | 26 +- .../ShoppingLists/ShoppingListEndpoints.cs | 137 ++- ...260509044440_AddProductCatalog.Designer.cs | 784 +++++++++++++++ .../20260509044440_AddProductCatalog.cs | 113 +++ ...2603_LinkItemsToProductCatalog.Designer.cs | 832 ++++++++++++++++ ...0260510012603_LinkItemsToProductCatalog.cs | 142 +++ ...0021855_AddProductStoreSection.Designer.cs | 920 ++++++++++++++++++ .../20260510021855_AddProductStoreSection.cs | 105 ++ .../Migrations/YesChefDbModelSnapshot.cs | 263 +++++ src/backend/YesChef.Api/Program.cs | 12 + src/backend/YesChef.Api/YesChef.Api.csproj | 4 + src/frontend/src/lib/ProductTypeahead.svelte | 174 ++++ .../src/routes/lists/[id]/+page.svelte | 38 +- .../src/routes/recipes/new/+page.svelte | 53 +- 27 files changed, 4621 insertions(+), 36 deletions(-) create mode 100644 src/backend/YesChef.Api.IntegrationTests/Features/ProductEndpointsTests.cs create mode 100644 src/backend/YesChef.Api/Data/Seed/CatalogSeeder.cs create mode 100644 src/backend/YesChef.Api/Data/Seed/products.json create mode 100644 src/backend/YesChef.Api/Entities/FamilyProduct.cs create mode 100644 src/backend/YesChef.Api/Entities/FamilyProductOverride.cs create mode 100644 src/backend/YesChef.Api/Entities/Product.cs create mode 100644 src/backend/YesChef.Api/Entities/ProductStoreSection.cs create mode 100644 src/backend/YesChef.Api/Features/Products/ProductEndpoints.cs create mode 100644 src/backend/YesChef.Api/Migrations/20260509044440_AddProductCatalog.Designer.cs create mode 100644 src/backend/YesChef.Api/Migrations/20260509044440_AddProductCatalog.cs create mode 100644 src/backend/YesChef.Api/Migrations/20260510012603_LinkItemsToProductCatalog.Designer.cs create mode 100644 src/backend/YesChef.Api/Migrations/20260510012603_LinkItemsToProductCatalog.cs create mode 100644 src/backend/YesChef.Api/Migrations/20260510021855_AddProductStoreSection.Designer.cs create mode 100644 src/backend/YesChef.Api/Migrations/20260510021855_AddProductStoreSection.cs create mode 100644 src/frontend/src/lib/ProductTypeahead.svelte diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/ProductEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/ProductEndpointsTests.cs new file mode 100644 index 0000000..fafde4b --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Features/ProductEndpointsTests.cs @@ -0,0 +1,256 @@ +using System.Net; +using System.Net.Http.Json; +using YesChef.Api.Entities; +using YesChef.Api.Features.Products; +using YesChef.Api.IntegrationTests.Infrastructure; + +namespace YesChef.Api.IntegrationTests.Features; + +public class ProductEndpointsTests : AuthenticatedIntegrationTest +{ + private Task GetFamilyIdAsync() => UseDbAsync(db => + db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync()); + + [Test] + public async Task Search_returns_global_and_family_products_merged() + { + var familyId = await GetFamilyIdAsync(); + await UseDbAsync(async db => + { + db.Products.Add(new Product { Name = "Bananas" }); + db.Products.Add(new Product { Name = "Carrots" }); + db.FamilyProducts.Add(new FamilyProduct { FamilyId = familyId, Name = "House Bread" }); + await db.SaveChangesAsync(); + }); + + var results = await Client.GetFromJsonAsync>("/api/products?q="); + + await Assert.That(results!.Select(r => r.Name)).IsEquivalentTo(new[] { "Bananas", "Carrots", "House Bread" }); + } + + [Test] + public async Task Search_filters_case_insensitively_on_effective_name() + { + await UseDbAsync(async db => + { + db.Products.Add(new Product { Name = "Bananas" }); + db.Products.Add(new Product { Name = "Apples" }); + await db.SaveChangesAsync(); + }); + + var results = await Client.GetFromJsonAsync>("/api/products?q=ban"); + + await Assert.That(results!.Select(r => r.Name)).IsEquivalentTo(new[] { "Bananas" }); + } + + [Test] + public async Task Search_applies_family_override_to_global_product() + { + var familyId = await GetFamilyIdAsync(); + Product apples = null!; + await UseDbAsync(async db => + { + apples = new Product { Name = "Apples", Brand = "Generic" }; + db.Products.Add(apples); + await db.SaveChangesAsync(); + db.FamilyProductOverrides.Add(new FamilyProductOverride + { + FamilyId = familyId, + ProductId = apples.Id, + Brand = "Honeycrisp", + }); + await db.SaveChangesAsync(); + }); + + var results = await Client.GetFromJsonAsync>("/api/products?q=apple"); + + var dto = results!.Single(); + await Assert.That(dto.Name).IsEqualTo("Apples"); + await Assert.That(dto.Brand).IsEqualTo("Honeycrisp"); + await Assert.That(dto.IsOverridden).IsTrue(); + } + + [Test] + public async Task Search_does_not_leak_other_family_private_products() + { + await UseDbAsync(async db => + { + var otherFamily = new Family { Name = "Other", InviteCode = "other-code" }; + db.Families.Add(otherFamily); + await db.SaveChangesAsync(); + db.FamilyProducts.Add(new FamilyProduct { FamilyId = otherFamily.Id, Name = "Other Family Bread" }); + await db.SaveChangesAsync(); + }); + + var results = await Client.GetFromJsonAsync>("/api/products?q=bread"); + + await Assert.That(results!).IsEmpty(); + } + + [Test] + public async Task Search_does_not_leak_other_family_overrides() + { + await UseDbAsync(async db => + { + var apples = new Product { Name = "Apples", Brand = "Generic" }; + db.Products.Add(apples); + var otherFamily = new Family { Name = "Other", InviteCode = "other-code" }; + db.Families.Add(otherFamily); + await db.SaveChangesAsync(); + db.FamilyProductOverrides.Add(new FamilyProductOverride + { + FamilyId = otherFamily.Id, + ProductId = apples.Id, + Brand = "Their Honeycrisp", + }); + await db.SaveChangesAsync(); + }); + + var results = await Client.GetFromJsonAsync>("/api/products?q=apple"); + + var dto = results!.Single(); + await Assert.That(dto.Brand).IsEqualTo("Generic"); + await Assert.That(dto.IsOverridden).IsFalse(); + } + + [Test] + public async Task Create_persists_family_product_and_returns_201() + { + var response = await Client.PostAsJsonAsync("/api/products", + new ProductEndpoints.CreateProductRequest("Sourdough", "Local Bakery", null)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var dto = await response.Content.ReadFromJsonAsync(); + await Assert.That(dto!.Kind).IsEqualTo(ProductEndpoints.ProductKind.Family); + await Assert.That(dto.Brand).IsEqualTo("Local Bakery"); + + var persisted = await UseDbAsync(db => db.FamilyProducts.SingleAsync()); + await Assert.That(persisted.Name).IsEqualTo("Sourdough"); + } + + [Test] + public async Task Create_returns_409_for_duplicate_name_within_family() + { + await Client.PostAsJsonAsync("/api/products", + new ProductEndpoints.CreateProductRequest("Sourdough", null, null)); + + var response = await Client.PostAsJsonAsync("/api/products", + new ProductEndpoints.CreateProductRequest("Sourdough", null, null)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + + [Test] + public async Task Create_returns_400_when_name_missing() + { + var response = await Client.PostAsJsonAsync("/api/products", + new ProductEndpoints.CreateProductRequest(" ", null, null)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Update_family_product_changes_fields() + { + var familyId = await GetFamilyIdAsync(); + var product = await UseDbAsync(async db => + { + var p = new FamilyProduct { FamilyId = familyId, Name = "Old", Brand = "OldBrand" }; + db.FamilyProducts.Add(p); + await db.SaveChangesAsync(); + return p; + }); + + var response = await Client.PutAsJsonAsync($"/api/products/family/{product.Id}", + new ProductEndpoints.UpdateProductRequest("New", "NewBrand", "New notes")); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + var refreshed = await UseDbAsync(db => db.FamilyProducts.SingleAsync(p => p.Id == product.Id)); + await Assert.That(refreshed.Name).IsEqualTo("New"); + await Assert.That(refreshed.Brand).IsEqualTo("NewBrand"); + await Assert.That(refreshed.Notes).IsEqualTo("New notes"); + } + + [Test] + public async Task Update_family_product_404_for_unknown_id() + { + var response = await Client.PutAsJsonAsync("/api/products/family/99999", + new ProductEndpoints.UpdateProductRequest("x", null, null)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task Update_global_product_creates_override_for_this_family_only() + { + var familyId = await GetFamilyIdAsync(); + var apples = await UseDbAsync(async db => + { + var p = new Product { Name = "Apples", Brand = "Generic" }; + db.Products.Add(p); + await db.SaveChangesAsync(); + return p; + }); + + var response = await Client.PutAsJsonAsync($"/api/products/global/{apples.Id}", + new ProductEndpoints.UpdateProductRequest(null, "Honeycrisp", null)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + var dto = await response.Content.ReadFromJsonAsync(); + await Assert.That(dto!.Brand).IsEqualTo("Honeycrisp"); + await Assert.That(dto.IsOverridden).IsTrue(); + + // Global row untouched. + var global = await UseDbAsync(db => db.Products.SingleAsync(p => p.Id == apples.Id)); + await Assert.That(global.Brand).IsEqualTo("Generic"); + + // Override stored under the caller's family. + var ovr = await UseDbAsync(db => db.FamilyProductOverrides.SingleAsync()); + await Assert.That(ovr.FamilyId).IsEqualTo(familyId); + await Assert.That(ovr.Brand).IsEqualTo("Honeycrisp"); + } + + [Test] + public async Task Update_global_product_upserts_existing_override() + { + var familyId = await GetFamilyIdAsync(); + var apples = await UseDbAsync(async db => + { + var p = new Product { Name = "Apples", Brand = "Generic" }; + db.Products.Add(p); + await db.SaveChangesAsync(); + db.FamilyProductOverrides.Add(new FamilyProductOverride + { + FamilyId = familyId, + ProductId = p.Id, + Brand = "Honeycrisp", + }); + await db.SaveChangesAsync(); + return p; + }); + + var response = await Client.PutAsJsonAsync($"/api/products/global/{apples.Id}", + new ProductEndpoints.UpdateProductRequest(null, "Pink Lady", "Family favorite")); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + var ovr = await UseDbAsync(db => db.FamilyProductOverrides.SingleAsync()); + await Assert.That(ovr.Brand).IsEqualTo("Pink Lady"); + await Assert.That(ovr.Notes).IsEqualTo("Family favorite"); + } + + [Test] + public async Task Update_global_product_404_for_unknown_id() + { + var response = await Client.PutAsJsonAsync("/api/products/global/99999", + new ProductEndpoints.UpdateProductRequest(null, "x", null)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + } + + [Test] + public async Task Endpoints_require_authentication() + { + var response = await AnonymousClient.GetAsync("/api/products"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized); + } +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs index ce79a41..b6ccad6 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs @@ -37,6 +37,67 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest await Assert.That(recipe.Ingredients.Count).IsEqualTo(2); } + [Test] + public async Task Create_links_ingredient_to_global_product() + { + var product = await UseDbAsync(async db => + { + var p = new Product { Name = "Pasta" }; + db.Products.Add(p); + await db.SaveChangesAsync(); + return p; + }); + + var request = new RecipeEndpoints.CreateRecipeRequest( + Title: "Pasta", + Description: null, + Instructions: null, + Servings: null, + SourceUrl: null, + Ingredients: [new RecipeEndpoints.IngredientRequest("pasta", "1 lb", 1, ProductId: product.Id)]); + + var response = await Client.PostAsJsonAsync("/api/recipes", request); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var ingredient = await UseDbAsync(db => db.RecipeIngredients.SingleAsync()); + await Assert.That(ingredient.ProductId).IsEqualTo(product.Id); + } + + [Test] + public async Task Create_rejects_ingredient_with_both_product_ids() + { + var request = new RecipeEndpoints.CreateRecipeRequest( + Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null, + Ingredients: [new RecipeEndpoints.IngredientRequest("x", null, 1, ProductId: 1, FamilyProductId: 1)]); + + var response = await Client.PostAsJsonAsync("/api/recipes", request); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Create_rejects_ingredient_with_other_familys_family_product() + { + var foreignProductId = await UseDbAsync(async db => + { + var otherFamily = new Family { Name = "Other", InviteCode = "other-code" }; + db.Families.Add(otherFamily); + await db.SaveChangesAsync(); + var p = new FamilyProduct { FamilyId = otherFamily.Id, Name = "Theirs" }; + db.FamilyProducts.Add(p); + await db.SaveChangesAsync(); + return p.Id; + }); + + var request = new RecipeEndpoints.CreateRecipeRequest( + Title: "x", Description: null, Instructions: null, Servings: null, SourceUrl: null, + Ingredients: [new RecipeEndpoints.IngredientRequest("x", null, 1, FamilyProductId: foreignProductId)]); + + var response = await Client.PostAsJsonAsync("/api/recipes", request); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + [Test] public async Task Get_by_id_returns_full_recipe_including_creator_and_ingredients() { diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs index 4faf258..a1366c2 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs @@ -140,6 +140,199 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); } + [Test] + public async Task Add_item_links_global_product_when_id_supplied() + { + var list = await CreateListAsync(); + var product = await UseDbAsync(async db => + { + var p = new Product { Name = "Bananas" }; + db.Products.Add(p); + await db.SaveChangesAsync(); + return p; + }); + + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("Bananas", ProductId: product.Id)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync()); + await Assert.That(item.ProductId).IsEqualTo(product.Id); + await Assert.That(item.FamilyProductId).IsNull(); + } + + [Test] + public async Task Add_item_links_family_product_when_id_supplied() + { + var list = await CreateListAsync(); + var familyId = await UseDbAsync(db => + db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync()); + var familyProduct = await UseDbAsync(async db => + { + var p = new FamilyProduct { FamilyId = familyId, Name = "House Bread" }; + db.FamilyProducts.Add(p); + await db.SaveChangesAsync(); + return p; + }); + + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("House Bread", FamilyProductId: familyProduct.Id)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync()); + await Assert.That(item.FamilyProductId).IsEqualTo(familyProduct.Id); + await Assert.That(item.ProductId).IsNull(); + } + + [Test] + public async Task Add_item_rejects_setting_both_product_ids() + { + var list = await CreateListAsync(); + + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("eggs", ProductId: 1, FamilyProductId: 1)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Add_item_rejects_unknown_product_id() + { + var list = await CreateListAsync(); + + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("eggs", ProductId: 99999)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Add_item_records_section_memory_and_auto_assigns_on_next_add() + { + var list = await CreateListAsync(); + var section = await UseDbAsync(async db => + { + var s = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 }; + db.StoreSections.Add(s); + await db.SaveChangesAsync(); + return s; + }); + var product = await UseDbAsync(async db => + { + var p = new Product { Name = "Bananas-Memory" }; + db.Products.Add(p); + await db.SaveChangesAsync(); + return p; + }); + + // First add: explicit section + product → memory recorded. + var first = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("Bananas", SectionId: section.Id, ProductId: product.Id)); + await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.Created); + + // Second add: same product, no section → auto-assigned from memory. + var second = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("More Bananas", ProductId: product.Id)); + await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.Created); + + var items = await UseDbAsync(db => db.ShoppingListItems + .Where(i => i.ShoppingListId == list.Id).OrderBy(i => i.Id).ToListAsync()); + await Assert.That(items.Count).IsEqualTo(2); + await Assert.That(items[1].SectionId).IsEqualTo(section.Id); + } + + [Test] + public async Task Patch_item_section_updates_memory_for_next_add() + { + var list = await CreateListAsync(); + var (originalSection, correctedSection) = await UseDbAsync(async db => + { + var s1 = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Pantry", SortOrder = 1 }; + var s2 = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Bakery", SortOrder = 2 }; + db.StoreSections.AddRange(s1, s2); + await db.SaveChangesAsync(); + return (s1, s2); + }); + var product = await UseDbAsync(async db => + { + var p = new Product { Name = "Bread-Memory" }; + db.Products.Add(p); + await db.SaveChangesAsync(); + return p; + }); + + // Add with section A; that records (Product → A) memory. + var addResponse = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("Bread", SectionId: originalSection.Id, ProductId: product.Id)); + var addBody = await addResponse.Content.ReadFromJsonAsync(); + var firstItemId = addBody.GetProperty("id").GetInt32(); + + // User corrects to section B → memory should update. + var patchResponse = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{firstItemId}/section", + new ShoppingListEndpoints.SetItemSectionRequest(correctedSection.Id)); + await Assert.That(patchResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + // Next add (no section) should now pull section B. + var second = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("More Bread", ProductId: product.Id)); + var secondBody = await second.Content.ReadFromJsonAsync(); + await Assert.That(secondBody.GetProperty("sectionId").GetInt32()).IsEqualTo(correctedSection.Id); + } + + [Test] + public async Task Section_memory_is_scoped_per_store() + { + var listA = await CreateListAsync(); + var otherStore = await Data.CreateStoreAsync(b => b.Named("Other Store")); + var listB = await Data.CreateListAsync(b => b.ForStore(otherStore).CreatedBy(User)); + var (sectionA, sectionB) = await UseDbAsync(async db => + { + var a = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce A", SortOrder = 1 }; + var b = new StoreSection { FamilyId = otherStore.FamilyId, StoreId = otherStore.Id, Name = "Produce B", SortOrder = 1 }; + db.StoreSections.AddRange(a, b); + await db.SaveChangesAsync(); + return (a, b); + }); + var product = await UseDbAsync(async db => + { + var p = new Product { Name = "Apples-Memory" }; + db.Products.Add(p); + await db.SaveChangesAsync(); + return p; + }); + + // Memorize at store A only. + await Client.PostAsJsonAsync($"/api/lists/{listA.Id}/items", + new ShoppingListEndpoints.AddItemRequest("Apples", SectionId: sectionA.Id, ProductId: product.Id)); + + // Add at store B with no section → no memory yet for store B. + var response = await Client.PostAsJsonAsync($"/api/lists/{listB.Id}/items", + new ShoppingListEndpoints.AddItemRequest("Apples", ProductId: product.Id)); + var body = await response.Content.ReadFromJsonAsync(); + await Assert.That(body.GetProperty("sectionId").ValueKind).IsEqualTo(JsonValueKind.Null); + } + + [Test] + public async Task Add_item_rejects_other_familys_family_product() + { + var list = await CreateListAsync(); + var foreignProductId = await UseDbAsync(async db => + { + var otherFamily = new Family { Name = "Other", InviteCode = "other-code" }; + db.Families.Add(otherFamily); + await db.SaveChangesAsync(); + var p = new FamilyProduct { FamilyId = otherFamily.Id, Name = "Theirs" }; + db.FamilyProducts.Add(p); + await db.SaveChangesAsync(); + return p.Id; + }); + + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("eggs", FamilyProductId: foreignProductId)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + [Test] public async Task Check_toggles_item_state_and_attributes_to_user() { diff --git a/src/backend/YesChef.Api/Data/Seed/CatalogSeeder.cs b/src/backend/YesChef.Api/Data/Seed/CatalogSeeder.cs new file mode 100644 index 0000000..566fde9 --- /dev/null +++ b/src/backend/YesChef.Api/Data/Seed/CatalogSeeder.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; + +namespace YesChef.Api.Data.Seed; + +/// +/// Loads the curated product catalog from the embedded JSON resource and +/// inserts any rows that aren't already present (matched by Name). Idempotent +/// — safe to call on every startup; only the first call does real work. +/// +public static class CatalogSeeder +{ + private const string ResourceName = "YesChef.Api.Data.Seed.products.json"; + + private record SeedEntry(string Name, string? Brand, string? Notes); + private record SeedFile(List Products); + + public static async Task SeedAsync(YesChefDb db, ILogger logger, CancellationToken ct = default) + { + var entries = LoadEntries(); + if (entries.Count == 0) return; + + // Insert names + any provided brand/notes via two passes: + // 1) bulk insert names (one statement, ON CONFLICT DO NOTHING). + // 2) per-entry update to fill brand/notes when present. + // The two-step shape sidesteps EF's ExecuteSqlRawAsync rejecting + // DBNull in mixed-nullability parameter lists, while still giving + // future seed entries somewhere to put brand/notes data. + var nameParams = entries.Select(e => (object)e.Name).ToList(); + var nameValues = string.Join(", ", + Enumerable.Range(0, entries.Count).Select(i => $"({{{i}}}, NOW())")); + var nameSql = $""" + INSERT INTO "Products" ("Name", "CreatedAt") + VALUES {nameValues} + ON CONFLICT ("Name") DO NOTHING; + """; + + var inserted = await db.Database.ExecuteSqlRawAsync(nameSql, nameParams, ct); + + // Apply brand/notes only for entries that have them; harmless if the + // global row was overridden by a different name in a prior seed. + foreach (var entry in entries.Where(e => e.Brand is not null || e.Notes is not null)) + { + await db.Database.ExecuteSqlInterpolatedAsync($""" + UPDATE "Products" + SET "Brand" = COALESCE("Brand", {entry.Brand}), + "Notes" = COALESCE("Notes", {entry.Notes}) + WHERE "Name" = {entry.Name}; + """, ct); + } + + if (inserted > 0) + logger.LogInformation("Catalog seed inserted {Count} new products.", inserted); + } + + private static List LoadEntries() + { + using var stream = typeof(CatalogSeeder).Assembly.GetManifestResourceStream(ResourceName) + ?? throw new InvalidOperationException($"Embedded seed resource '{ResourceName}' not found."); + var seed = JsonSerializer.Deserialize(stream, new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + }); + return seed?.Products ?? []; + } +} diff --git a/src/backend/YesChef.Api/Data/Seed/products.json b/src/backend/YesChef.Api/Data/Seed/products.json new file mode 100644 index 0000000..bdde1b0 --- /dev/null +++ b/src/backend/YesChef.Api/Data/Seed/products.json @@ -0,0 +1,142 @@ +{ + "$schema-comment": "Seed catalog of common grocery products. Loaded once into the global Products table by CatalogSeeder. Add new items here; CatalogSeeder is idempotent on Name. Keep names short, generic, and singular where possible (let the user add quantity).", + "products": [ + { "name": "Apples" }, + { "name": "Avocado" }, + { "name": "Bananas" }, + { "name": "Blueberries" }, + { "name": "Strawberries" }, + { "name": "Raspberries" }, + { "name": "Grapes" }, + { "name": "Lemons" }, + { "name": "Limes" }, + { "name": "Oranges" }, + { "name": "Pears" }, + { "name": "Pineapple" }, + { "name": "Watermelon" }, + + { "name": "Bell Peppers" }, + { "name": "Broccoli" }, + { "name": "Carrots" }, + { "name": "Cauliflower" }, + { "name": "Celery" }, + { "name": "Cucumber" }, + { "name": "Garlic" }, + { "name": "Green Beans" }, + { "name": "Lettuce" }, + { "name": "Mushrooms" }, + { "name": "Onions" }, + { "name": "Potatoes" }, + { "name": "Spinach" }, + { "name": "Sweet Potatoes" }, + { "name": "Tomatoes" }, + { "name": "Zucchini" }, + { "name": "Kale" }, + { "name": "Cabbage" }, + + { "name": "Basil" }, + { "name": "Cilantro" }, + { "name": "Parsley" }, + { "name": "Mint" }, + { "name": "Rosemary" }, + { "name": "Thyme" }, + + { "name": "Milk" }, + { "name": "Whole Milk" }, + { "name": "Skim Milk" }, + { "name": "Heavy Cream" }, + { "name": "Half and Half" }, + { "name": "Butter" }, + { "name": "Eggs" }, + { "name": "Yogurt" }, + { "name": "Greek Yogurt" }, + { "name": "Sour Cream" }, + { "name": "Cream Cheese" }, + { "name": "Cheddar Cheese" }, + { "name": "Mozzarella" }, + { "name": "Parmesan" }, + { "name": "Feta" }, + + { "name": "Chicken Breast" }, + { "name": "Chicken Thighs" }, + { "name": "Whole Chicken" }, + { "name": "Ground Beef" }, + { "name": "Ground Turkey" }, + { "name": "Bacon" }, + { "name": "Pork Chops" }, + { "name": "Sausage" }, + { "name": "Salmon" }, + { "name": "Shrimp" }, + { "name": "Tuna" }, + + { "name": "Bread" }, + { "name": "Sourdough Bread" }, + { "name": "Whole Wheat Bread" }, + { "name": "Bagels" }, + { "name": "Tortillas" }, + { "name": "Hamburger Buns" }, + { "name": "Hot Dog Buns" }, + + { "name": "All-Purpose Flour" }, + { "name": "Sugar" }, + { "name": "Brown Sugar" }, + { "name": "Salt" }, + { "name": "Black Pepper" }, + { "name": "Olive Oil" }, + { "name": "Vegetable Oil" }, + { "name": "Vinegar" }, + { "name": "Soy Sauce" }, + { "name": "Honey" }, + { "name": "Maple Syrup" }, + { "name": "Peanut Butter" }, + { "name": "Jelly" }, + + { "name": "Pasta" }, + { "name": "Spaghetti" }, + { "name": "Rice" }, + { "name": "Brown Rice" }, + { "name": "Oats" }, + { "name": "Cereal" }, + { "name": "Granola" }, + + { "name": "Black Beans" }, + { "name": "Chickpeas" }, + { "name": "Diced Tomatoes" }, + { "name": "Tomato Sauce" }, + { "name": "Chicken Broth" }, + { "name": "Beef Broth" }, + + { "name": "Frozen Peas" }, + { "name": "Frozen Corn" }, + { "name": "Frozen Berries" }, + { "name": "Ice Cream" }, + { "name": "Frozen Pizza" }, + + { "name": "Coffee" }, + { "name": "Tea" }, + { "name": "Orange Juice" }, + { "name": "Apple Juice" }, + { "name": "Sparkling Water" }, + + { "name": "Chips" }, + { "name": "Tortilla Chips" }, + { "name": "Salsa" }, + { "name": "Crackers" }, + { "name": "Popcorn" }, + { "name": "Chocolate" }, + + { "name": "Ketchup" }, + { "name": "Mustard" }, + { "name": "Mayonnaise" }, + { "name": "Hot Sauce" }, + { "name": "Ranch Dressing" }, + + { "name": "Paper Towels" }, + { "name": "Toilet Paper" }, + { "name": "Dish Soap" }, + { "name": "Laundry Detergent" }, + { "name": "Trash Bags" }, + { "name": "Aluminum Foil" }, + { "name": "Plastic Wrap" } + ] +} diff --git a/src/backend/YesChef.Api/Data/YesChefDb.cs b/src/backend/YesChef.Api/Data/YesChefDb.cs index 77801d5..3226739 100644 --- a/src/backend/YesChef.Api/Data/YesChefDb.cs +++ b/src/backend/YesChef.Api/Data/YesChefDb.cs @@ -16,6 +16,10 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) public DbSet RecipeIngredients => Set(); public DbSet Invites => Set(); public DbSet PasswordResetTokens => Set(); + public DbSet Products => Set(); + public DbSet FamilyProducts => Set(); + public DbSet FamilyProductOverrides => Set(); + public DbSet ProductStoreSections => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -94,6 +98,8 @@ public class YesChefDb(DbContextOptions 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 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(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(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(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(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"); }); } } diff --git a/src/backend/YesChef.Api/Entities/FamilyProduct.cs b/src/backend/YesChef.Api/Entities/FamilyProduct.cs new file mode 100644 index 0000000..cf33a8c --- /dev/null +++ b/src/backend/YesChef.Api/Entities/FamilyProduct.cs @@ -0,0 +1,16 @@ +namespace YesChef.Api.Entities; + +/// +/// Family-owned product not present in the global catalog. Only visible to +/// the owning family. +/// +public class FamilyProduct +{ + public int Id { get; set; } + public int FamilyId { get; set; } + public Family Family { get; set; } = null!; + public required string Name { get; set; } + public string? Brand { get; set; } + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/backend/YesChef.Api/Entities/FamilyProductOverride.cs b/src/backend/YesChef.Api/Entities/FamilyProductOverride.cs new file mode 100644 index 0000000..7e5d8bf --- /dev/null +++ b/src/backend/YesChef.Api/Entities/FamilyProductOverride.cs @@ -0,0 +1,19 @@ +namespace YesChef.Api.Entities; + +/// +/// A family's override of a global . Each non-null field +/// replaces the global value when the family views the catalog. Composite key +/// (, ) — at most one override +/// per family per product. +/// +public class FamilyProductOverride +{ + public int FamilyId { get; set; } + public Family Family { get; set; } = null!; + public int ProductId { get; set; } + public Product Product { get; set; } = null!; + public string? Name { get; set; } + public string? Brand { get; set; } + public string? Notes { get; set; } + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/backend/YesChef.Api/Entities/Product.cs b/src/backend/YesChef.Api/Entities/Product.cs new file mode 100644 index 0000000..09a8664 --- /dev/null +++ b/src/backend/YesChef.Api/Entities/Product.cs @@ -0,0 +1,15 @@ +namespace YesChef.Api.Entities; + +/// +/// Global, read-only product catalog entry. Visible to every family. +/// Per-family edits go in ; family-only +/// products live in . +/// +public class Product +{ + public int Id { get; set; } + public required string Name { get; set; } + public string? Brand { get; set; } + public string? Notes { get; set; } + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/backend/YesChef.Api/Entities/ProductStoreSection.cs b/src/backend/YesChef.Api/Entities/ProductStoreSection.cs new file mode 100644 index 0000000..c77d877 --- /dev/null +++ b/src/backend/YesChef.Api/Entities/ProductStoreSection.cs @@ -0,0 +1,27 @@ +namespace YesChef.Api.Entities; + +/// +/// Remembers which section a particular product was last placed in at a +/// particular store, scoped per-family. Used to auto-assign sections when +/// the same product is added to a list at the same store later. +/// +/// Exactly one of / is +/// set on a row, mirroring the at-most-one product link on items and +/// ingredients. A surrogate Id keeps the row addressable; uniqueness is +/// enforced by filtered indexes (see DbContext config). +/// +public class ProductStoreSection +{ + public int Id { get; set; } + public int FamilyId { get; set; } + public Family Family { get; set; } = null!; + public int StoreId { get; set; } + public Store Store { get; set; } = null!; + public int? ProductId { get; set; } + public Product? Product { get; set; } + public int? FamilyProductId { get; set; } + public FamilyProduct? FamilyProduct { get; set; } + public int StoreSectionId { get; set; } + public StoreSection StoreSection { get; set; } = null!; + public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; +} diff --git a/src/backend/YesChef.Api/Entities/RecipeIngredient.cs b/src/backend/YesChef.Api/Entities/RecipeIngredient.cs index 379724e..2dc50f5 100644 --- a/src/backend/YesChef.Api/Entities/RecipeIngredient.cs +++ b/src/backend/YesChef.Api/Entities/RecipeIngredient.cs @@ -10,4 +10,9 @@ public class RecipeIngredient public required string Name { get; set; } public string? Quantity { get; set; } public int SortOrder { get; set; } + // At most one of ProductId / FamilyProductId is set; both null = free-form. + public int? ProductId { get; set; } + public Product? Product { get; set; } + public int? FamilyProductId { get; set; } + public FamilyProduct? FamilyProduct { get; set; } } diff --git a/src/backend/YesChef.Api/Entities/ShoppingListItem.cs b/src/backend/YesChef.Api/Entities/ShoppingListItem.cs index 17ecfb9..1663087 100644 --- a/src/backend/YesChef.Api/Entities/ShoppingListItem.cs +++ b/src/backend/YesChef.Api/Entities/ShoppingListItem.cs @@ -16,6 +16,12 @@ public class ShoppingListItem public Recipe? Recipe { get; set; } public int? SectionId { get; set; } public StoreSection? Section { get; set; } + // At most one of ProductId / FamilyProductId is set on a given row. + // Both null = free-form text entry (no catalog linkage). + public int? ProductId { get; set; } + public Product? Product { get; set; } + public int? FamilyProductId { get; set; } + public FamilyProduct? FamilyProduct { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? RemovedAt { get; set; } public int? RemovedByUserId { get; set; } diff --git a/src/backend/YesChef.Api/Features/Products/ProductEndpoints.cs b/src/backend/YesChef.Api/Features/Products/ProductEndpoints.cs new file mode 100644 index 0000000..81f4c2a --- /dev/null +++ b/src/backend/YesChef.Api/Features/Products/ProductEndpoints.cs @@ -0,0 +1,194 @@ +using Microsoft.EntityFrameworkCore; +using YesChef.Api.Auth; +using YesChef.Api.Data; +using YesChef.Api.Entities; + +namespace YesChef.Api.Features.Products; + +public static class ProductEndpoints +{ + /// Discriminator on product DTOs so the client can route subsequent + /// PUT/POST calls to the right code path. The catalog UI itself doesn't + /// need to surface this — both kinds render the same way. + public enum ProductKind { Global, Family } + + public record ProductDto( + int Id, + ProductKind Kind, + string Name, + string? Brand, + string? Notes, + bool IsOverridden); + + public record CreateProductRequest(string Name, string? Brand, string? Notes); + public record UpdateProductRequest(string? Name, string? Brand, string? Notes); + + private const int SearchResultLimit = 50; + + public static RouteGroupBuilder MapProductEndpoints(this RouteGroupBuilder group) + { + // Search the effective catalog: global products with this family's + // overrides applied, unioned with the family's private products. + // Substring match (case-insensitive) on the *effective* name. + group.MapGet("/", async (string? q, YesChefDb db, HttpContext http) => + { + var familyId = http.User.GetFamilyId(); + var query = (q ?? string.Empty).Trim(); + var pattern = $"%{query}%"; + + // Two separate queries merged in memory. EF can't translate a + // union over the global+override join (the projection is treated + // as client-side), and the result set is bounded by SearchResultLimit + // so doing it client-side is fine. + var globalRows = await ( + from p in db.Products.AsNoTracking() + join o in db.FamilyProductOverrides.Where(o => o.FamilyId == familyId) + on p.Id equals o.ProductId into overrides + from o in overrides.DefaultIfEmpty() + where query == string.Empty + || EF.Functions.ILike(p.Name, pattern) + || (o != null && o.Name != null && EF.Functions.ILike(o.Name, pattern)) + select new + { + p.Id, + GlobalName = p.Name, + GlobalBrand = p.Brand, + GlobalNotes = p.Notes, + OverrideName = o != null ? o.Name : null, + OverrideBrand = o != null ? o.Brand : null, + OverrideNotes = o != null ? o.Notes : null, + HasOverride = o != null, + }) + .Take(SearchResultLimit) + .ToListAsync(); + + var familyRows = await db.FamilyProducts.AsNoTracking() + .Where(p => p.FamilyId == familyId) + .Where(p => query == string.Empty || EF.Functions.ILike(p.Name, pattern)) + .Take(SearchResultLimit) + .ToListAsync(); + + var globalDtos = globalRows.Select(r => + { + var name = r.OverrideName ?? r.GlobalName; + // After applying overrides, drop rows that no longer match + // the query — guards against the case where the global name + // matched but the override renames it away from the query. + if (query.Length > 0 && !name.Contains(query, StringComparison.OrdinalIgnoreCase)) + return null; + return new ProductDto( + r.Id, + ProductKind.Global, + name, + r.OverrideBrand ?? r.GlobalBrand, + r.OverrideNotes ?? r.GlobalNotes, + r.HasOverride); + }).Where(d => d is not null).Cast(); + + var familyDtos = familyRows.Select(p => + new ProductDto(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false)); + + var results = globalDtos.Concat(familyDtos) + .OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase) + .Take(SearchResultLimit) + .ToList(); + + return Results.Ok(results); + }); + + // Always creates a FamilyProduct. Editing a global product is done + // via PUT /global/{id}, which writes a FamilyProductOverride. + group.MapPost("/", async (CreateProductRequest request, YesChefDb db, HttpContext http) => + { + var familyId = http.User.GetFamilyId(); + var name = request.Name?.Trim(); + if (string.IsNullOrEmpty(name)) + return Results.BadRequest(new { error = "Name is required." }); + + if (await db.FamilyProducts.AnyAsync(p => p.FamilyId == familyId && p.Name == name)) + return Results.Conflict(new { error = $"A product named \"{name}\" already exists." }); + + var product = new FamilyProduct + { + FamilyId = familyId, + Name = name, + Brand = request.Brand, + Notes = request.Notes, + }; + db.FamilyProducts.Add(product); + await db.SaveChangesAsync(); + + return Results.Created($"/api/products/family/{product.Id}", ToDto(product)); + }); + + // Update a family-owned product directly. + group.MapPut("/family/{id:int}", async (int id, UpdateProductRequest request, YesChefDb db, HttpContext http) => + { + var familyId = http.User.GetFamilyId(); + var product = await db.FamilyProducts.FirstOrDefaultAsync(p => p.Id == id && p.FamilyId == familyId); + if (product is null) return Results.NotFound(); + + if (request.Name is not null) + { + var name = request.Name.Trim(); + if (string.IsNullOrEmpty(name)) + return Results.BadRequest(new { error = "Name cannot be empty." }); + + if (name != product.Name && + await db.FamilyProducts.AnyAsync(p => p.FamilyId == familyId && p.Name == name && p.Id != id)) + return Results.Conflict(new { error = $"A product named \"{name}\" already exists." }); + + product.Name = name; + } + product.Brand = request.Brand; + product.Notes = request.Notes; + await db.SaveChangesAsync(); + + return Results.Ok(ToDto(product)); + }); + + // Update a global product for this family by upserting an override. + // Each non-null field becomes the override; null fields fall back to + // the global value. To "reset" a field to the global, send null — + // this matches how the merged search projects fields. + group.MapPut("/global/{id:int}", async (int id, UpdateProductRequest request, YesChefDb db, HttpContext http) => + { + var familyId = http.User.GetFamilyId(); + var product = await db.Products.AsNoTracking().FirstOrDefaultAsync(p => p.Id == id); + if (product is null) return Results.NotFound(); + + var ovr = await db.FamilyProductOverrides + .FirstOrDefaultAsync(o => o.FamilyId == familyId && o.ProductId == id); + if (ovr is null) + { + ovr = new FamilyProductOverride { FamilyId = familyId, ProductId = id }; + db.FamilyProductOverrides.Add(ovr); + } + + // A name override that collides with the global name is meaningless; + // disallow rather than silently storing a redundant value. + var trimmedName = request.Name?.Trim(); + if (trimmedName is not null && trimmedName.Length == 0) + return Results.BadRequest(new { error = "Name cannot be empty." }); + + ovr.Name = trimmedName; + ovr.Brand = request.Brand; + ovr.Notes = request.Notes; + ovr.UpdatedAt = DateTime.UtcNow; + await db.SaveChangesAsync(); + + return Results.Ok(new ProductDto( + product.Id, + ProductKind.Global, + ovr.Name ?? product.Name, + ovr.Brand ?? product.Brand, + ovr.Notes ?? product.Notes, + IsOverridden: true)); + }); + + return group; + } + + private static ProductDto ToDto(FamilyProduct p) => + new(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false); +} diff --git a/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs b/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs index c25a367..f294273 100644 --- a/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs +++ b/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs @@ -2,12 +2,13 @@ using Microsoft.EntityFrameworkCore; using YesChef.Api.Auth; using YesChef.Api.Data; using YesChef.Api.Entities; +using YesChef.Api.Features.ShoppingLists; namespace YesChef.Api.Features.Recipes; public static class RecipeEndpoints { - public record IngredientRequest(string Name, string? Quantity, int SortOrder); + public record IngredientRequest(string Name, string? Quantity, int SortOrder, int? ProductId = null, int? FamilyProductId = null); public record CreateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List Ingredients); public record UpdateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List Ingredients); @@ -36,6 +37,13 @@ public static class RecipeEndpoints group.MapPost("/", async (CreateRecipeRequest request, YesChefDb db, HttpContext http) => { var familyId = http.User.GetFamilyId(); + + foreach (var ing in request.Ingredients) + { + if (await ShoppingListEndpoints.ValidateProductLink(db, familyId, ing.ProductId, ing.FamilyProductId) is { } error) + return error; + } + var recipe = new Recipe { FamilyId = familyId, @@ -50,7 +58,9 @@ public static class RecipeEndpoints FamilyId = familyId, Name = i.Name, Quantity = i.Quantity, - SortOrder = i.SortOrder + SortOrder = i.SortOrder, + ProductId = i.ProductId, + FamilyProductId = i.FamilyProductId }).ToList() }; @@ -80,7 +90,7 @@ public static class RecipeEndpoints recipe.SourceUrl, CreatedBy = recipe.CreatedByUser.Name, recipe.UpdatedAt, - Ingredients = recipe.Ingredients.Select(i => new { i.Id, i.Name, i.Quantity, i.SortOrder }) + Ingredients = recipe.Ingredients.Select(i => new { i.Id, i.Name, i.Quantity, i.SortOrder, i.ProductId, i.FamilyProductId }) }); }); @@ -93,6 +103,12 @@ public static class RecipeEndpoints .FirstOrDefaultAsync(); if (recipe is null) return Results.NotFound(); + foreach (var ing in request.Ingredients) + { + if (await ShoppingListEndpoints.ValidateProductLink(db, familyId, ing.ProductId, ing.FamilyProductId) is { } error) + return error; + } + recipe.Title = request.Title; recipe.Description = request.Description; recipe.Instructions = request.Instructions; @@ -106,7 +122,9 @@ public static class RecipeEndpoints FamilyId = familyId, Name = i.Name, Quantity = i.Quantity, - SortOrder = i.SortOrder + SortOrder = i.SortOrder, + ProductId = i.ProductId, + FamilyProductId = i.FamilyProductId }).ToList(); await db.SaveChangesAsync(); diff --git a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs index f848589..47ef38d 100644 --- a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs +++ b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs @@ -10,7 +10,7 @@ public static class ShoppingListEndpoints { public record CreateListRequest(string Name, int StoreId); public record UpdateListRequest(string Name, int StoreId); - public record AddItemRequest(string Name, int SortOrder = 0, int? SectionId = null); + public record AddItemRequest(string Name, int SortOrder = 0, int? SectionId = null, int? ProductId = null, int? FamilyProductId = null); public record SetItemSectionRequest(int? SectionId); private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}"; @@ -127,7 +127,9 @@ public static class ShoppingListEndpoints CheckedByUserName = i.CheckedByUser?.Name, i.SortOrder, i.SectionId, - RecipeTitle = i.Recipe?.Title + RecipeTitle = i.Recipe?.Title, + i.ProductId, + i.FamilyProductId }) }); }); @@ -174,25 +176,43 @@ public static class ShoppingListEndpoints if (list is null) return Results.NotFound(); // Reject section IDs that don't belong to the list's store/family. - if (request.SectionId is int sectionId && - !await db.StoreSections.AnyAsync(s => s.Id == sectionId && s.StoreId == list.StoreId && s.FamilyId == familyId)) + if (request.SectionId is int explicitSectionId && + !await db.StoreSections.AnyAsync(s => s.Id == explicitSectionId && s.StoreId == list.StoreId && s.FamilyId == familyId)) return Results.BadRequest(new { error = "Unknown section." }); + if (await ValidateProductLink(db, familyId, request.ProductId, request.FamilyProductId) is { } productError) + return productError; + + // Auto-assign a section from memory when caller didn't pick one + // but supplied a product link — "we put bananas in Produce last + // time we shopped here, do it again." + var resolvedSectionId = request.SectionId + ?? await LookupRememberedSectionAsync(db, familyId, list.StoreId, request.ProductId, request.FamilyProductId); + var item = new ShoppingListItem { FamilyId = familyId, ShoppingListId = listId, Name = request.Name, SortOrder = request.SortOrder, - SectionId = request.SectionId, + SectionId = resolvedSectionId, + ProductId = request.ProductId, + FamilyProductId = request.FamilyProductId, }; db.ShoppingListItems.Add(item); list.UpdatedAt = DateTime.UtcNow; + + // If the caller explicitly chose a section, record/update memory + // for next time. Auto-assigned sections don't need a write back — + // the existing memory row already says exactly this. + if (request.SectionId.HasValue) + await RememberSectionAsync(db, familyId, list.StoreId, request.ProductId, request.FamilyProductId, request.SectionId); + await db.SaveChangesAsync(); - await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId }); + await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId }); await BroadcastListSummary(hub, db, listId, familyId); - return Results.Created($"/api/lists/{listId}/items/{item.Id}", new { item.Id, item.Name, item.SortOrder, item.SectionId }); + return Results.Created($"/api/lists/{listId}/items/{item.Id}", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId }); }); group.MapPatch("/{listId:int}/items/{itemId:int}/section", async (int listId, int itemId, SetItemSectionRequest request, YesChefDb db, HttpContext http, IHubContext hub) => @@ -208,6 +228,9 @@ public static class ShoppingListEndpoints return Results.BadRequest(new { error = "Unknown section." }); item.SectionId = request.SectionId; + // Manual section change is the user correcting the memory: persist + // the new mapping so future adds at this store learn from it. + await RememberSectionAsync(db, familyId, item.ShoppingList.StoreId, item.ProductId, item.FamilyProductId, request.SectionId); await db.SaveChangesAsync(); await hub.Clients.Group($"list-{listId}").SendAsync("ItemSectionChanged", new { item.Id, item.SectionId }); @@ -272,7 +295,9 @@ public static class ShoppingListEndpoints CheckedByUserName = item.CheckedByUser?.Name, item.SortOrder, item.SectionId, - RecipeTitle = item.Recipe?.Title + RecipeTitle = item.Recipe?.Title, + item.ProductId, + item.FamilyProductId }); await BroadcastListSummary(hub, db, listId, familyId); return Results.NoContent(); @@ -292,14 +317,27 @@ public static class ShoppingListEndpoints var maxSort = await db.ShoppingListItems.Where(i => i.ShoppingListId == listId).MaxAsync(i => (int?)i.SortOrder) ?? 0; - var newItems = recipe.Ingredients.Select((ing, idx) => new ShoppingListItem + var newItems = new List(recipe.Ingredients.Count); + var idx = 0; + foreach (var ing in recipe.Ingredients) { - FamilyId = familyId, - ShoppingListId = listId, - Name = string.IsNullOrEmpty(ing.Quantity) ? ing.Name : $"{ing.Quantity} {ing.Name}", - SortOrder = maxSort + idx + 1, - RecipeId = recipeId - }).ToList(); + // Carry the ingredient's product link onto the list item, and + // use the per-store memory to assign a section if we have one. + var rememberedSectionId = await LookupRememberedSectionAsync(db, familyId, list.StoreId, ing.ProductId, ing.FamilyProductId); + + newItems.Add(new ShoppingListItem + { + FamilyId = familyId, + ShoppingListId = listId, + Name = string.IsNullOrEmpty(ing.Quantity) ? ing.Name : $"{ing.Quantity} {ing.Name}", + SortOrder = maxSort + idx + 1, + RecipeId = recipeId, + ProductId = ing.ProductId, + FamilyProductId = ing.FamilyProductId, + SectionId = rememberedSectionId, + }); + idx++; + } db.ShoppingListItems.AddRange(newItems); list.UpdatedAt = DateTime.UtcNow; @@ -307,7 +345,7 @@ public static class ShoppingListEndpoints foreach (var item in newItems) { - await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId, RecipeTitle = recipe.Title }); + await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId, RecipeTitle = recipe.Title }); } await BroadcastListSummary(hub, db, listId, familyId); @@ -316,4 +354,71 @@ public static class ShoppingListEndpoints return group; } + + /// + /// Validates a (ProductId, FamilyProductId) pair on an item or ingredient + /// payload. Enforces at-most-one and tenant scoping. Returns null on success + /// or a 400 result describing the problem. + /// + internal static async Task ValidateProductLink(YesChefDb db, int familyId, int? productId, int? familyProductId) + { + if (productId.HasValue && familyProductId.HasValue) + return Results.BadRequest(new { error = "An item can link to a global product or a family product, not both." }); + + if (productId.HasValue && !await db.Products.AnyAsync(p => p.Id == productId.Value)) + return Results.BadRequest(new { error = "Unknown product." }); + + if (familyProductId.HasValue && !await db.FamilyProducts.AnyAsync(p => p.Id == familyProductId.Value && p.FamilyId == familyId)) + return Results.BadRequest(new { error = "Unknown product." }); + + return null; + } + + /// + /// Look up the section a product was last placed in at this store, for + /// this family. Returns null if no memory exists or no product link was + /// supplied. + /// + internal static async Task LookupRememberedSectionAsync(YesChefDb db, int familyId, int storeId, int? productId, int? familyProductId) + { + if (!productId.HasValue && !familyProductId.HasValue) return null; + + return await db.ProductStoreSections + .Where(p => p.FamilyId == familyId && p.StoreId == storeId + && p.ProductId == productId && p.FamilyProductId == familyProductId) + .Select(p => (int?)p.StoreSectionId) + .FirstOrDefaultAsync(); + } + + /// + /// Upsert the (Family, Store, Product) → Section memory. No-op if no + /// product link or no section is supplied. Caller must ensure section + /// belongs to the same store and family. + /// + internal static async Task RememberSectionAsync(YesChefDb db, int familyId, int storeId, int? productId, int? familyProductId, int? sectionId) + { + if (!sectionId.HasValue) return; + if (!productId.HasValue && !familyProductId.HasValue) return; + + var existing = await db.ProductStoreSections + .FirstOrDefaultAsync(p => p.FamilyId == familyId && p.StoreId == storeId + && p.ProductId == productId && p.FamilyProductId == familyProductId); + + if (existing is null) + { + db.ProductStoreSections.Add(new ProductStoreSection + { + FamilyId = familyId, + StoreId = storeId, + ProductId = productId, + FamilyProductId = familyProductId, + StoreSectionId = sectionId.Value, + }); + } + else if (existing.StoreSectionId != sectionId.Value) + { + existing.StoreSectionId = sectionId.Value; + existing.UpdatedAt = DateTime.UtcNow; + } + } } diff --git a/src/backend/YesChef.Api/Migrations/20260509044440_AddProductCatalog.Designer.cs b/src/backend/YesChef.Api/Migrations/20260509044440_AddProductCatalog.Designer.cs new file mode 100644 index 0000000..c38764c --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260509044440_AddProductCatalog.Designer.cs @@ -0,0 +1,784 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using YesChef.Api.Data; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + [DbContext(typeof(YesChefDb))] + [Migration("20260509044440_AddProductCatalog")] + partial class AddProductCatalog + { + /// + protected override void BuildTargetModel(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.Family", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InviteCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteCode") + .IsUnique(); + + b.ToTable("Families"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("UserId", "FamilyId"); + + b.HasIndex("FamilyId"); + + b.ToTable("FamilyMemberships"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyProduct", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("FamilyId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedByUserId") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserId") + .HasColumnType("integer"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ConsumedByUserId"); + + b.HasIndex("IssuedByUserId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("FamilyId", "ConsumedAt"); + + b.ToTable("Invites"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ConsumedAt"); + + b.ToTable("PasswordResetTokens"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Instructions") + .HasColumnType("text"); + + b.Property("Servings") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Quantity") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId"); + + b.ToTable("ShoppingLists"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckedByUserId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RemovedByUserId") + .HasColumnType("integer"); + + b.Property("SectionId") + .HasColumnType("integer"); + + b.Property("ShoppingListId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("RemovedByUserId"); + + b.HasIndex("SectionId"); + + b.HasIndex("ShoppingListId"); + + b.ToTable("ShoppingListItems"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Name") + .IsUnique(); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId", "Name") + .IsUnique(); + + b.ToTable("StoreSections"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("EmailConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + 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") + .WithMany() + .HasForeignKey("ConsumedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.User", "IssuedByUser") + .WithMany() + .HasForeignKey("IssuedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ConsumedByUser"); + + b.Navigation("Family"); + + b.Navigation("IssuedByUser"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b => + { + b.HasOne("YesChef.Api.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany("Ingredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + 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.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Family"); + + 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.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany() + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.User", "RemovedByUser") + .WithMany() + .HasForeignKey("RemovedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.StoreSection", "Section") + .WithMany() + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList") + .WithMany("Items") + .HasForeignKey("ShoppingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CheckedByUser"); + + b.Navigation("Family"); + + b.Navigation("Recipe"); + + b.Navigation("RemovedByUser"); + + b.Navigation("Section"); + + b.Navigation("ShoppingList"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("Store"); + }); + + 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 + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260509044440_AddProductCatalog.cs b/src/backend/YesChef.Api/Migrations/20260509044440_AddProductCatalog.cs new file mode 100644 index 0000000..5ca83c6 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260509044440_AddProductCatalog.cs @@ -0,0 +1,113 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + /// + public partial class AddProductCatalog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "FamilyProducts", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FamilyId = table.Column(type: "integer", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Brand = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Notes = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FamilyProducts", x => x.Id); + table.ForeignKey( + name: "FK_FamilyProducts_Families_FamilyId", + column: x => x.FamilyId, + principalTable: "Families", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "Products", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Brand = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Notes = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Products", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "FamilyProductOverrides", + columns: table => new + { + FamilyId = table.Column(type: "integer", nullable: false), + ProductId = table.Column(type: "integer", nullable: false), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Brand = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Notes = table.Column(type: "character varying(1000)", maxLength: 1000, nullable: true), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FamilyProductOverrides", x => new { x.FamilyId, x.ProductId }); + table.ForeignKey( + name: "FK_FamilyProductOverrides_Families_FamilyId", + column: x => x.FamilyId, + principalTable: "Families", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_FamilyProductOverrides_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_FamilyProductOverrides_ProductId", + table: "FamilyProductOverrides", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_FamilyProducts_FamilyId_Name", + table: "FamilyProducts", + columns: new[] { "FamilyId", "Name" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Products_Name", + table: "Products", + column: "Name", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FamilyProductOverrides"); + + migrationBuilder.DropTable( + name: "FamilyProducts"); + + migrationBuilder.DropTable( + name: "Products"); + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260510012603_LinkItemsToProductCatalog.Designer.cs b/src/backend/YesChef.Api/Migrations/20260510012603_LinkItemsToProductCatalog.Designer.cs new file mode 100644 index 0000000..53b0754 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260510012603_LinkItemsToProductCatalog.Designer.cs @@ -0,0 +1,832 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using YesChef.Api.Data; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + [DbContext(typeof(YesChefDb))] + [Migration("20260510012603_LinkItemsToProductCatalog")] + partial class LinkItemsToProductCatalog + { + /// + protected override void BuildTargetModel(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.Family", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InviteCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteCode") + .IsUnique(); + + b.ToTable("Families"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("UserId", "FamilyId"); + + b.HasIndex("FamilyId"); + + b.ToTable("FamilyMemberships"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyProduct", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("FamilyId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedByUserId") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserId") + .HasColumnType("integer"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ConsumedByUserId"); + + b.HasIndex("IssuedByUserId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("FamilyId", "ConsumedAt"); + + b.ToTable("Invites"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ConsumedAt"); + + b.ToTable("PasswordResetTokens"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Instructions") + .HasColumnType("text"); + + b.Property("Servings") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("FamilyProductId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("FamilyProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId"); + + b.ToTable("ShoppingLists"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckedByUserId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("FamilyProductId") + .HasColumnType("integer"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RemovedByUserId") + .HasColumnType("integer"); + + b.Property("SectionId") + .HasColumnType("integer"); + + b.Property("ShoppingListId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("FamilyProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("RemovedByUserId"); + + b.HasIndex("SectionId"); + + b.HasIndex("ShoppingListId"); + + b.ToTable("ShoppingListItems"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Name") + .IsUnique(); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId", "Name") + .IsUnique(); + + b.ToTable("StoreSections"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("EmailConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + 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") + .WithMany() + .HasForeignKey("ConsumedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.User", "IssuedByUser") + .WithMany() + .HasForeignKey("IssuedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ConsumedByUser"); + + b.Navigation("Family"); + + b.Navigation("IssuedByUser"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b => + { + b.HasOne("YesChef.Api.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.HasOne("YesChef.Api.Entities.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", 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.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") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("FamilyProduct"); + + b.Navigation("Product"); + + 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.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Family"); + + 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.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .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") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.User", "RemovedByUser") + .WithMany() + .HasForeignKey("RemovedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.StoreSection", "Section") + .WithMany() + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList") + .WithMany("Items") + .HasForeignKey("ShoppingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CheckedByUser"); + + b.Navigation("Family"); + + b.Navigation("FamilyProduct"); + + b.Navigation("Product"); + + b.Navigation("Recipe"); + + b.Navigation("RemovedByUser"); + + b.Navigation("Section"); + + b.Navigation("ShoppingList"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("Store"); + }); + + 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 + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260510012603_LinkItemsToProductCatalog.cs b/src/backend/YesChef.Api/Migrations/20260510012603_LinkItemsToProductCatalog.cs new file mode 100644 index 0000000..71cef19 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260510012603_LinkItemsToProductCatalog.cs @@ -0,0 +1,142 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + /// + public partial class LinkItemsToProductCatalog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FamilyProductId", + table: "ShoppingListItems", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "ProductId", + table: "ShoppingListItems", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "FamilyProductId", + table: "RecipeIngredients", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "ProductId", + table: "RecipeIngredients", + type: "integer", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_ShoppingListItems_FamilyProductId", + table: "ShoppingListItems", + column: "FamilyProductId"); + + migrationBuilder.CreateIndex( + name: "IX_ShoppingListItems_ProductId", + table: "ShoppingListItems", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_RecipeIngredients_FamilyProductId", + table: "RecipeIngredients", + column: "FamilyProductId"); + + migrationBuilder.CreateIndex( + name: "IX_RecipeIngredients_ProductId", + table: "RecipeIngredients", + column: "ProductId"); + + migrationBuilder.AddForeignKey( + name: "FK_RecipeIngredients_FamilyProducts_FamilyProductId", + table: "RecipeIngredients", + column: "FamilyProductId", + principalTable: "FamilyProducts", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_RecipeIngredients_Products_ProductId", + table: "RecipeIngredients", + column: "ProductId", + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_ShoppingListItems_FamilyProducts_FamilyProductId", + table: "ShoppingListItems", + column: "FamilyProductId", + principalTable: "FamilyProducts", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_ShoppingListItems_Products_ProductId", + table: "ShoppingListItems", + column: "ProductId", + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_RecipeIngredients_FamilyProducts_FamilyProductId", + table: "RecipeIngredients"); + + migrationBuilder.DropForeignKey( + name: "FK_RecipeIngredients_Products_ProductId", + table: "RecipeIngredients"); + + migrationBuilder.DropForeignKey( + name: "FK_ShoppingListItems_FamilyProducts_FamilyProductId", + table: "ShoppingListItems"); + + migrationBuilder.DropForeignKey( + name: "FK_ShoppingListItems_Products_ProductId", + table: "ShoppingListItems"); + + migrationBuilder.DropIndex( + name: "IX_ShoppingListItems_FamilyProductId", + table: "ShoppingListItems"); + + migrationBuilder.DropIndex( + name: "IX_ShoppingListItems_ProductId", + table: "ShoppingListItems"); + + migrationBuilder.DropIndex( + name: "IX_RecipeIngredients_FamilyProductId", + table: "RecipeIngredients"); + + migrationBuilder.DropIndex( + name: "IX_RecipeIngredients_ProductId", + table: "RecipeIngredients"); + + migrationBuilder.DropColumn( + name: "FamilyProductId", + table: "ShoppingListItems"); + + migrationBuilder.DropColumn( + name: "ProductId", + table: "ShoppingListItems"); + + migrationBuilder.DropColumn( + name: "FamilyProductId", + table: "RecipeIngredients"); + + migrationBuilder.DropColumn( + name: "ProductId", + table: "RecipeIngredients"); + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260510021855_AddProductStoreSection.Designer.cs b/src/backend/YesChef.Api/Migrations/20260510021855_AddProductStoreSection.Designer.cs new file mode 100644 index 0000000..c70f85e --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260510021855_AddProductStoreSection.Designer.cs @@ -0,0 +1,920 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using YesChef.Api.Data; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + [DbContext(typeof(YesChefDb))] + [Migration("20260510021855_AddProductStoreSection")] + partial class AddProductStoreSection + { + /// + protected override void BuildTargetModel(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.Family", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InviteCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteCode") + .IsUnique(); + + b.ToTable("Families"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("UserId", "FamilyId"); + + b.HasIndex("FamilyId"); + + b.ToTable("FamilyMemberships"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyProduct", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("FamilyId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedByUserId") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserId") + .HasColumnType("integer"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ConsumedByUserId"); + + b.HasIndex("IssuedByUserId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("FamilyId", "ConsumedAt"); + + b.ToTable("Invites"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ConsumedAt"); + + b.ToTable("PasswordResetTokens"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("FamilyProductId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("StoreSectionId") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Instructions") + .HasColumnType("text"); + + b.Property("Servings") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("FamilyProductId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("FamilyProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId"); + + b.ToTable("ShoppingLists"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckedByUserId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("FamilyProductId") + .HasColumnType("integer"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RemovedByUserId") + .HasColumnType("integer"); + + b.Property("SectionId") + .HasColumnType("integer"); + + b.Property("ShoppingListId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("FamilyProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("RemovedByUserId"); + + b.HasIndex("SectionId"); + + b.HasIndex("ShoppingListId"); + + b.ToTable("ShoppingListItems"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Name") + .IsUnique(); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId", "Name") + .IsUnique(); + + b.ToTable("StoreSections"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("EmailConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + 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") + .WithMany() + .HasForeignKey("ConsumedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.User", "IssuedByUser") + .WithMany() + .HasForeignKey("IssuedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ConsumedByUser"); + + b.Navigation("Family"); + + b.Navigation("IssuedByUser"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b => + { + b.HasOne("YesChef.Api.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", 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.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") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("FamilyProduct"); + + b.Navigation("Product"); + + 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.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CreatedByUser"); + + b.Navigation("Family"); + + 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.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .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") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.User", "RemovedByUser") + .WithMany() + .HasForeignKey("RemovedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.StoreSection", "Section") + .WithMany() + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList") + .WithMany("Items") + .HasForeignKey("ShoppingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CheckedByUser"); + + b.Navigation("Family"); + + b.Navigation("FamilyProduct"); + + b.Navigation("Product"); + + b.Navigation("Recipe"); + + b.Navigation("RemovedByUser"); + + b.Navigation("Section"); + + b.Navigation("ShoppingList"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("Store"); + }); + + 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 + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260510021855_AddProductStoreSection.cs b/src/backend/YesChef.Api/Migrations/20260510021855_AddProductStoreSection.cs new file mode 100644 index 0000000..01cdd3b --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260510021855_AddProductStoreSection.cs @@ -0,0 +1,105 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + /// + public partial class AddProductStoreSection : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "ProductStoreSections", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + FamilyId = table.Column(type: "integer", nullable: false), + StoreId = table.Column(type: "integer", nullable: false), + ProductId = table.Column(type: "integer", nullable: true), + FamilyProductId = table.Column(type: "integer", nullable: true), + StoreSectionId = table.Column(type: "integer", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProductStoreSections", x => x.Id); + table.ForeignKey( + name: "FK_ProductStoreSections_Families_FamilyId", + column: x => x.FamilyId, + principalTable: "Families", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ProductStoreSections_FamilyProducts_FamilyProductId", + column: x => x.FamilyProductId, + principalTable: "FamilyProducts", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ProductStoreSections_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ProductStoreSections_StoreSections_StoreSectionId", + column: x => x.StoreSectionId, + principalTable: "StoreSections", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ProductStoreSections_Stores_StoreId", + column: x => x.StoreId, + principalTable: "Stores", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_ProductStoreSections_FamilyId_StoreId_FamilyProductId", + table: "ProductStoreSections", + columns: new[] { "FamilyId", "StoreId", "FamilyProductId" }, + unique: true, + filter: "\"FamilyProductId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_ProductStoreSections_FamilyId_StoreId_ProductId", + table: "ProductStoreSections", + columns: new[] { "FamilyId", "StoreId", "ProductId" }, + unique: true, + filter: "\"ProductId\" IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_ProductStoreSections_FamilyProductId", + table: "ProductStoreSections", + column: "FamilyProductId"); + + migrationBuilder.CreateIndex( + name: "IX_ProductStoreSections_ProductId", + table: "ProductStoreSections", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_ProductStoreSections_StoreId", + table: "ProductStoreSections", + column: "StoreId"); + + migrationBuilder.CreateIndex( + name: "IX_ProductStoreSections_StoreSectionId", + table: "ProductStoreSections", + column: "StoreSectionId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ProductStoreSections"); + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs index 311d8a3..1e02da1 100644 --- a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs +++ b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs @@ -72,6 +72,71 @@ namespace YesChef.Api.Migrations b.ToTable("FamilyMemberships"); }); + modelBuilder.Entity("YesChef.Api.Entities.FamilyProduct", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("FamilyId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("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("Id") @@ -157,6 +222,85 @@ namespace YesChef.Api.Migrations b.ToTable("PasswordResetTokens"); }); + modelBuilder.Entity("YesChef.Api.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("FamilyProductId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("StoreSectionId") + .HasColumnType("integer"); + + b.Property("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("Id") @@ -214,11 +358,17 @@ namespace YesChef.Api.Migrations b.Property("FamilyId") .HasColumnType("integer"); + b.Property("FamilyProductId") + .HasColumnType("integer"); + b.Property("Name") .IsRequired() .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("ProductId") + .HasColumnType("integer"); + b.Property("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("FamilyId") .HasColumnType("integer"); + b.Property("FamilyProductId") + .HasColumnType("integer"); + b.Property("IsChecked") .HasColumnType("boolean"); @@ -305,6 +462,9 @@ namespace YesChef.Api.Migrations .HasMaxLength(300) .HasColumnType("character varying(300)"); + b.Property("ProductId") + .HasColumnType("integer"); + b.Property("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"); diff --git a/src/backend/YesChef.Api/Program.cs b/src/backend/YesChef.Api/Program.cs index b728025..02e71b5 100644 --- a/src/backend/YesChef.Api/Program.cs +++ b/src/backend/YesChef.Api/Program.cs @@ -8,9 +8,11 @@ using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Tokens; using YesChef.Api.Auth; using YesChef.Api.Data; +using YesChef.Api.Data.Seed; using YesChef.Api.Email; using YesChef.Api.Entities; using YesChef.Api.Features.Families; +using YesChef.Api.Features.Products; using YesChef.Api.Features.Recipes; using YesChef.Api.Features.ShoppingLists; using YesChef.Api.Features.Stores; @@ -194,6 +196,15 @@ using (var scope = app.Services.CreateScope()) } if (familiesNeedingAdmin.Count > 0) await db.SaveChangesAsync(); + + // Seed the global product catalog. Idempotent (ON CONFLICT DO NOTHING), + // so re-runs are cheap. Skipped under Testing so integration tests get + // a clean slate to insert their own catalog entries without collisions. + if (!app.Environment.IsEnvironment("Testing")) + { + var seedLogger = scope.ServiceProvider.GetRequiredService>(); + await CatalogSeeder.SeedAsync(db, seedLogger); + } } app.UseRateLimiter(); @@ -213,6 +224,7 @@ var storesGroup = app.MapGroup("/api/stores").MapStoreEndpoints().RequireAuthori storesGroup.MapGroup("/{storeId:int}/sections").MapStoreSectionEndpoints(); app.MapGroup("/api/lists").MapShoppingListEndpoints().RequireAuthorization(); app.MapGroup("/api/recipes").MapRecipeEndpoints().RequireAuthorization(); +app.MapGroup("/api/products").MapProductEndpoints().RequireAuthorization(); app.MapHub("/hubs/shopping-list"); app.Run(); diff --git a/src/backend/YesChef.Api/YesChef.Api.csproj b/src/backend/YesChef.Api/YesChef.Api.csproj index d5c8bff..542a7de 100644 --- a/src/backend/YesChef.Api/YesChef.Api.csproj +++ b/src/backend/YesChef.Api/YesChef.Api.csproj @@ -6,6 +6,10 @@ enable + + + + diff --git a/src/frontend/src/lib/ProductTypeahead.svelte b/src/frontend/src/lib/ProductTypeahead.svelte new file mode 100644 index 0000000..0bbf61f --- /dev/null +++ b/src/frontend/src/lib/ProductTypeahead.svelte @@ -0,0 +1,174 @@ + + + + +
+ { + if (suggestions.length > 0) showDropdown = true; + }} + {placeholder} + aria-label={ariaLabel} + aria-autocomplete="list" + aria-expanded={showDropdown} + aria-controls={listboxId} + role="combobox" + class={inputClass} + autocomplete="off" + /> + + {#if showDropdown} +
    + {#each suggestions as suggestion, i (suggestion.kind + suggestion.id)} +
  • { + // mousedown rather than click so we beat the blur handler. + e.preventDefault(); + selectSuggestion(suggestion); + }} + > + {suggestion.name} + {#if suggestion.brand} + {suggestion.brand} + {/if} +
  • + {/each} +
+ {/if} +
diff --git a/src/frontend/src/routes/lists/[id]/+page.svelte b/src/frontend/src/routes/lists/[id]/+page.svelte index e571992..5928684 100644 --- a/src/frontend/src/routes/lists/[id]/+page.svelte +++ b/src/frontend/src/routes/lists/[id]/+page.svelte @@ -5,6 +5,7 @@ import { api } from '$lib/api'; import { startConnection, stopConnection } from '$lib/signalr'; import { toast } from '$lib/toast.svelte'; + import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte'; import type { HubConnection } from '@microsoft/signalr'; interface ListItem { @@ -39,6 +40,8 @@ let sections = $state([]); let newItemName = $state(''); let newItemSectionId = $state(null); + let newItemProductId = $state(null); + let newItemFamilyProductId = $state(null); let loading = $state(true); let connection: HubConnection | null = null; @@ -149,10 +152,27 @@ body: JSON.stringify({ name: newItemName, sortOrder: maxSort + 1, - sectionId: newItemSectionId + sectionId: newItemSectionId, + productId: newItemProductId, + familyProductId: newItemFamilyProductId }) }); newItemName = ''; + newItemProductId = null; + newItemFamilyProductId = null; + } + + function onItemProductChange(product: ProductSuggestion | null) { + if (product === null) { + newItemProductId = null; + newItemFamilyProductId = null; + } else if (product.kind === 'Global') { + newItemProductId = product.id; + newItemFamilyProductId = null; + } else { + newItemProductId = null; + newItemFamilyProductId = product.id; + } } async function toggleItem(itemId: number) { @@ -210,12 +230,16 @@
{ e.preventDefault(); addItem(); }} class="mb-4 flex flex-wrap gap-2"> - +
+ +
{#if sections.length > 0} +
+ onIngredientProductChange(idx, p)} + /> +
{#if ingredients.length > 1}