diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/ProductEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/ProductEndpointsTests.cs index f2d1e30..6e62c72 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/ProductEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/ProductEndpointsTests.cs @@ -1,6 +1,5 @@ using System.Net; using System.Net.Http.Json; -using System.Text.Json; using YesChef.Api.Entities; using YesChef.Api.Features.Products; using YesChef.Api.IntegrationTests.Infrastructure; @@ -365,130 +364,94 @@ public class ProductEndpointsTests : AuthenticatedIntegrationTest } [Test] - public async Task Get_global_product_section_returns_remembered_section() + public async Task Create_family_product_persists_default_section() { - var familyId = await GetFamilyIdAsync(); - var store = await Data.CreateStoreAsync(); - var (product, section) = await UseDbAsync(async db => - { - var p = new Product { Name = "Bananas-Lookup" }; - var s = new StoreSection { FamilyId = familyId, StoreId = store.Id, Name = "Produce", SortOrder = 1 }; - db.Products.Add(p); - db.StoreSections.Add(s); - await db.SaveChangesAsync(); - db.ProductStoreSections.Add(new ProductStoreSection - { - FamilyId = familyId, - StoreId = store.Id, - ProductId = p.Id, - StoreSectionId = s.Id, - }); - await db.SaveChangesAsync(); - return (p, s); - }); + var response = await Client.PostAsJsonAsync("/api/products", + new ProductEndpoints.CreateProductRequest("Sourdough", null, null, UnitCategoryFlags.None, " Bakery ")); - var body = await Client.GetFromJsonAsync( - $"/api/products/global/{product.Id}/section?storeId={store.Id}"); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var dto = await response.Content.ReadFromJsonAsync(); + await Assert.That(dto!.DefaultSection).IsEqualTo("Bakery"); - await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(section.Id); + var stored = await UseDbAsync(db => db.FamilyProducts.SingleAsync()); + await Assert.That(stored.DefaultSection).IsEqualTo("Bakery"); } [Test] - public async Task Get_global_product_section_returns_null_when_no_memory() + public async Task Update_family_product_clears_default_section_when_blank() { - var store = await Data.CreateStoreAsync(); + var familyId = await GetFamilyIdAsync(); var product = await UseDbAsync(async db => { - var p = new Product { Name = "Unknown-Lookup" }; + var p = new FamilyProduct { FamilyId = familyId, Name = "Flour", DefaultSection = "Pantry" }; + db.FamilyProducts.Add(p); + await db.SaveChangesAsync(); + return p; + }); + + var response = await Client.PutAsJsonAsync($"/api/products/family/{product.Id}", + new ProductEndpoints.UpdateProductRequest(null, null, null, null, " ")); + 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.DefaultSection).IsNull(); + } + + [Test] + public async Task Update_global_product_writes_default_section_to_override() + { + var familyId = await GetFamilyIdAsync(); + var product = await UseDbAsync(async db => + { + var p = new Product { Name = "Bananas", DefaultSection = "Produce" }; db.Products.Add(p); await db.SaveChangesAsync(); return p; }); - var body = await Client.GetFromJsonAsync( - $"/api/products/global/{product.Id}/section?storeId={store.Id}"); + var response = await Client.PutAsJsonAsync($"/api/products/global/{product.Id}", + new ProductEndpoints.UpdateProductRequest(null, null, null, null, "Frozen")); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + var dto = await response.Content.ReadFromJsonAsync(); + await Assert.That(dto!.DefaultSection).IsEqualTo("Frozen"); - await Assert.That(body.GetProperty("sectionId").ValueKind).IsEqualTo(JsonValueKind.Null); + // Global row untouched. + var global = await UseDbAsync(db => db.Products.SingleAsync(p => p.Id == product.Id)); + await Assert.That(global.DefaultSection).IsEqualTo("Produce"); + + var ovr = await UseDbAsync(db => db.FamilyProductOverrides + .SingleAsync(o => o.FamilyId == familyId && o.ProductId == product.Id)); + await Assert.That(ovr.DefaultSection).IsEqualTo("Frozen"); } [Test] - public async Task Get_global_product_section_is_scoped_to_store() + public async Task Search_projects_effective_default_section() { var familyId = await GetFamilyIdAsync(); - var storeA = await Data.CreateStoreAsync(b => b.Named("A")); - var storeB = await Data.CreateStoreAsync(b => b.Named("B")); - var product = await UseDbAsync(async db => + await UseDbAsync(async db => { - var p = new Product { Name = "Apples-Lookup" }; - var sectionA = new StoreSection { FamilyId = familyId, StoreId = storeA.Id, Name = "Produce A", SortOrder = 1 }; - db.Products.Add(p); - db.StoreSections.Add(sectionA); + db.Products.Add(new Product { Name = "Apples", DefaultSection = "Produce" }); + db.Products.Add(new Product { Name = "Berries", DefaultSection = "Produce" }); await db.SaveChangesAsync(); - db.ProductStoreSections.Add(new ProductStoreSection + var berries = db.Products.Single(p => p.Name == "Berries"); + db.FamilyProductOverrides.Add(new YesChef.Api.Entities.FamilyProductOverride { FamilyId = familyId, - StoreId = storeA.Id, - ProductId = p.Id, - StoreSectionId = sectionA.Id, + ProductId = berries.Id, + DefaultSection = "Frozen", }); - await db.SaveChangesAsync(); - return p; - }); - - // Lookup at store B — no memory there even though store A has one. - var body = await Client.GetFromJsonAsync( - $"/api/products/global/{product.Id}/section?storeId={storeB.Id}"); - - await Assert.That(body.GetProperty("sectionId").ValueKind).IsEqualTo(JsonValueKind.Null); - } - - [Test] - public async Task Get_family_product_section_returns_remembered_section() - { - var familyId = await GetFamilyIdAsync(); - var store = await Data.CreateStoreAsync(); - var (product, section) = await UseDbAsync(async db => - { - var p = new FamilyProduct { FamilyId = familyId, Name = "House Bread" }; - var s = new StoreSection { FamilyId = familyId, StoreId = store.Id, Name = "Bakery", SortOrder = 1 }; - db.FamilyProducts.Add(p); - db.StoreSections.Add(s); - await db.SaveChangesAsync(); - db.ProductStoreSections.Add(new ProductStoreSection + db.FamilyProducts.Add(new FamilyProduct { FamilyId = familyId, - StoreId = store.Id, - FamilyProductId = p.Id, - StoreSectionId = s.Id, + Name = "House Bread", + DefaultSection = "Bakery", }); await db.SaveChangesAsync(); - return (p, s); }); - var body = await Client.GetFromJsonAsync( - $"/api/products/family/{product.Id}/section?storeId={store.Id}"); - - await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(section.Id); - } - - [Test] - public async Task Get_family_product_section_returns_404_for_other_family_product() - { - var store = await Data.CreateStoreAsync(); - var otherFamilyProductId = 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 = "Their Bread" }; - db.FamilyProducts.Add(p); - await db.SaveChangesAsync(); - return p.Id; - }); - - var response = await Client.GetAsync( - $"/api/products/family/{otherFamilyProductId}/section?storeId={store.Id}"); - - await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound); + var results = (await Client.GetFromJsonAsync>("/api/products?q="))!; + await Assert.That(results.Single(r => r.Name == "Apples").DefaultSection).IsEqualTo("Produce"); + await Assert.That(results.Single(r => r.Name == "Berries").DefaultSection).IsEqualTo("Frozen"); + await Assert.That(results.Single(r => r.Name == "House Bread").DefaultSection).IsEqualTo("Bakery"); } } diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs index d56705a..ad40d9a 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs @@ -207,10 +207,10 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest } [Test] - public async Task Add_item_records_section_memory_and_auto_assigns_on_next_add() + public async Task Add_item_resolves_section_from_global_product_default() { var list = await CreateListAsync(); - var section = await UseDbAsync(async db => + var produce = await UseDbAsync(async db => { var s = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 }; db.StoreSections.Add(s); @@ -219,99 +219,312 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest }); var product = await UseDbAsync(async db => { - var p = new Product { Name = "Bananas-Memory" }; + var p = new Product { Name = "Bananas-Default", DefaultSection = "Produce" }; 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); + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("Bananas", ProductId: product.Id)); + var body = await response.Content.ReadFromJsonAsync(); - // 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); + await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(produce.Id); } [Test] - public async Task Patch_item_section_updates_memory_for_next_add() + public async Task Add_item_resolves_section_with_case_insensitive_name_match() { var list = await CreateListAsync(); - var (originalSection, correctedSection) = await UseDbAsync(async db => + var produce = 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); + var s = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 }; + db.StoreSections.Add(s); await db.SaveChangesAsync(); - return (s1, s2); + return s; }); var product = await UseDbAsync(async db => { - var p = new Product { Name = "Bread-Memory" }; + var p = new Product { Name = "Bananas-Case", DefaultSection = " produce " }; 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(); + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("Bananas", ProductId: product.Id)); + var body = await response.Content.ReadFromJsonAsync(); - // 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); + await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(produce.Id); } [Test] - public async Task Section_memory_is_scoped_per_store() + public async Task Add_item_resolves_to_null_when_default_section_does_not_exist_in_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 list = await CreateListAsync(); var product = await UseDbAsync(async db => { - var p = new Product { Name = "Apples-Memory" }; + var p = new Product { Name = "Imports-Only", DefaultSection = "Specialty" }; 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 response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("Saffron", 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_prefers_family_override_default_over_global_product_default() + { + var list = await CreateListAsync(); + var familyId = Store.FamilyId; + var (produce, frozen) = await UseDbAsync(async db => + { + var p = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 }; + var f = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Frozen", SortOrder = 2 }; + db.StoreSections.AddRange(p, f); + await db.SaveChangesAsync(); + return (p, f); + }); + var product = await UseDbAsync(async db => + { + var p = new Product { Name = "Berries", DefaultSection = "Produce" }; + db.Products.Add(p); + await db.SaveChangesAsync(); + db.FamilyProductOverrides.Add(new YesChef.Api.Entities.FamilyProductOverride + { + FamilyId = familyId, + ProductId = p.Id, + DefaultSection = "Frozen", + }); + await db.SaveChangesAsync(); + return p; + }); + + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("Berries", ProductId: product.Id)); + var body = await response.Content.ReadFromJsonAsync(); + + await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(frozen.Id); + } + + [Test] + public async Task Add_item_prefers_per_store_memory_over_family_default() + { + var list = await CreateListAsync(); + var familyId = Store.FamilyId; + var (produce, pantry) = await UseDbAsync(async db => + { + var p = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 }; + var pn = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Pantry", SortOrder = 2 }; + db.StoreSections.AddRange(p, pn); + await db.SaveChangesAsync(); + return (p, pn); + }); + var product = await UseDbAsync(async db => + { + var p = new Product { Name = "Garlic", DefaultSection = "Produce" }; + db.Products.Add(p); + await db.SaveChangesAsync(); + db.ProductStoreSections.Add(new ProductStoreSection + { + FamilyId = familyId, + StoreId = Store.Id, + ProductId = p.Id, + StoreSectionId = pantry.Id, + }); + await db.SaveChangesAsync(); + return p; + }); + + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("Garlic", ProductId: product.Id)); + var body = await response.Content.ReadFromJsonAsync(); + + await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(pantry.Id); + } + + [Test] + public async Task Patch_item_section_silently_creates_family_override_when_no_default_exists() + { + var list = await CreateListAsync(b => b.WithItem("Bananas")); + var familyId = Store.FamilyId; + var produce = await UseDbAsync(async db => + { + var s = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 }; + db.StoreSections.Add(s); + await db.SaveChangesAsync(); + return s; + }); + var (itemId, productId) = await UseDbAsync(async db => + { + var p = new Product { Name = "Bananas-Silent" }; + db.Products.Add(p); + await db.SaveChangesAsync(); + var item = await db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id); + item.ProductId = p.Id; + await db.SaveChangesAsync(); + return (item.Id, p.Id); + }); + + var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section", + new ShoppingListEndpoints.SetItemSectionRequest(produce.Id)); + var body = await response.Content.ReadFromJsonAsync(); + + await Assert.That(body.GetProperty("promptSaveDefault").GetBoolean()).IsFalse(); + var ovr = await UseDbAsync(db => db.FamilyProductOverrides + .SingleAsync(o => o.FamilyId == familyId && o.ProductId == productId)); + await Assert.That(ovr.DefaultSection).IsEqualTo("Produce"); + } + + [Test] + public async Task Patch_item_section_returns_prompt_when_existing_family_default_differs() + { + var list = await CreateListAsync(b => b.WithItem("Bananas")); + var familyId = Store.FamilyId; + var (produce, bakery) = await UseDbAsync(async db => + { + var p = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 }; + var b = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Bakery", SortOrder = 2 }; + db.StoreSections.AddRange(p, b); + await db.SaveChangesAsync(); + return (p, b); + }); + var (itemId, productId) = await UseDbAsync(async db => + { + var p = new Product { Name = "Banana-Bread" }; + db.Products.Add(p); + await db.SaveChangesAsync(); + db.FamilyProductOverrides.Add(new YesChef.Api.Entities.FamilyProductOverride + { + FamilyId = familyId, + ProductId = p.Id, + DefaultSection = "Bakery", + }); + var item = await db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id); + item.ProductId = p.Id; + await db.SaveChangesAsync(); + return (item.Id, p.Id); + }); + + // First call without saveAsDefault — should prompt. + var promptResponse = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section", + new ShoppingListEndpoints.SetItemSectionRequest(produce.Id)); + var promptBody = await promptResponse.Content.ReadFromJsonAsync(); + await Assert.That(promptBody.GetProperty("promptSaveDefault").GetBoolean()).IsTrue(); + await Assert.That(promptBody.GetProperty("currentDefaultSection").GetString()).IsEqualTo("Bakery"); + await Assert.That(promptBody.GetProperty("newSectionName").GetString()).IsEqualTo("Produce"); + + // Override still says Bakery — item section changed but default untouched. + var ovrBefore = await UseDbAsync(db => db.FamilyProductOverrides + .SingleAsync(o => o.FamilyId == familyId && o.ProductId == productId)); + await Assert.That(ovrBefore.DefaultSection).IsEqualTo("Bakery"); + var itemBefore = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.Id == itemId)); + await Assert.That(itemBefore.SectionId).IsEqualTo(produce.Id); + + // Second call with saveAsDefault=true — override updates. + var acceptResponse = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section", + new ShoppingListEndpoints.SetItemSectionRequest(produce.Id, SaveAsDefault: true)); + await Assert.That(acceptResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + var ovrAfter = await UseDbAsync(db => db.FamilyProductOverrides + .SingleAsync(o => o.FamilyId == familyId && o.ProductId == productId)); + await Assert.That(ovrAfter.DefaultSection).IsEqualTo("Produce"); + } + + [Test] + public async Task Patch_item_section_no_prompt_when_default_already_matches() + { + var list = await CreateListAsync(b => b.WithItem("Bananas")); + var familyId = Store.FamilyId; + var produce = await UseDbAsync(async db => + { + var s = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 }; + db.StoreSections.Add(s); + await db.SaveChangesAsync(); + return s; + }); + var itemId = await UseDbAsync(async db => + { + var p = new Product { Name = "Bananas-Match" }; + db.Products.Add(p); + await db.SaveChangesAsync(); + db.FamilyProductOverrides.Add(new YesChef.Api.Entities.FamilyProductOverride + { + FamilyId = familyId, + ProductId = p.Id, + DefaultSection = "Produce", + }); + var item = await db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id); + item.ProductId = p.Id; + await db.SaveChangesAsync(); + return item.Id; + }); + + var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section", + new ShoppingListEndpoints.SetItemSectionRequest(produce.Id)); + var body = await response.Content.ReadFromJsonAsync(); + + await Assert.That(body.GetProperty("promptSaveDefault").GetBoolean()).IsFalse(); + } + + [Test] + public async Task Patch_item_section_sets_family_product_default_for_family_products() + { + var list = await CreateListAsync(b => b.WithItem("House Bread")); + var familyId = Store.FamilyId; + var bakery = await UseDbAsync(async db => + { + var s = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Bakery", SortOrder = 1 }; + db.StoreSections.Add(s); + await db.SaveChangesAsync(); + return s; + }); + var (itemId, productId) = await UseDbAsync(async db => + { + var p = new FamilyProduct { FamilyId = familyId, Name = "House Bread" }; + db.FamilyProducts.Add(p); + await db.SaveChangesAsync(); + var item = await db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id); + item.FamilyProductId = p.Id; + await db.SaveChangesAsync(); + return (item.Id, p.Id); + }); + + var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section", + new ShoppingListEndpoints.SetItemSectionRequest(bakery.Id)); + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK); + + var fp = await UseDbAsync(db => db.FamilyProducts.SingleAsync(p => p.Id == productId)); + await Assert.That(fp.DefaultSection).IsEqualTo("Bakery"); + } + + [Test] + public async Task Patch_item_section_without_product_link_does_not_touch_defaults() + { + var list = await CreateListAsync(b => b.WithItem("free-form")); + var familyId = Store.FamilyId; + var produce = await UseDbAsync(async db => + { + var s = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 }; + db.StoreSections.Add(s); + await db.SaveChangesAsync(); + return s; + }); + var itemId = await UseDbAsync(db => db.ShoppingListItems + .Where(i => i.ShoppingListId == list.Id).Select(i => i.Id).SingleAsync()); + + var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section", + new ShoppingListEndpoints.SetItemSectionRequest(produce.Id)); + var body = await response.Content.ReadFromJsonAsync(); + + await Assert.That(body.GetProperty("promptSaveDefault").GetBoolean()).IsFalse(); + await Assert.That(await UseDbAsync(db => db.FamilyProductOverrides.AnyAsync())).IsFalse(); + } + [Test] public async Task Add_item_rejects_other_familys_family_product() { diff --git a/src/backend/YesChef.Api/Data/YesChefDb.cs b/src/backend/YesChef.Api/Data/YesChefDb.cs index d4521e1..315f6d6 100644 --- a/src/backend/YesChef.Api/Data/YesChefDb.cs +++ b/src/backend/YesChef.Api/Data/YesChefDb.cs @@ -141,6 +141,7 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) e.Property(p => p.Brand).HasMaxLength(200); e.Property(p => p.Notes).HasMaxLength(1000); e.Property(p => p.AllowedUnitCategories).HasConversion(); + e.Property(p => p.DefaultSection).HasMaxLength(100); e.HasIndex(p => p.Name).IsUnique(); }); @@ -150,6 +151,7 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) e.Property(p => p.Brand).HasMaxLength(200); e.Property(p => p.Notes).HasMaxLength(1000); e.Property(p => p.AllowedUnitCategories).HasConversion(); + e.Property(p => p.DefaultSection).HasMaxLength(100); e.HasOne(p => p.Family).WithMany().HasForeignKey(p => p.FamilyId).OnDelete(DeleteBehavior.Cascade); e.HasIndex(p => new { p.FamilyId, p.Name }).IsUnique(); }); @@ -161,6 +163,7 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) e.Property(o => o.Brand).HasMaxLength(200); e.Property(o => o.Notes).HasMaxLength(1000); e.Property(o => o.AllowedUnitCategories).HasConversion(); + e.Property(o => o.DefaultSection).HasMaxLength(100); 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); }); diff --git a/src/backend/YesChef.Api/Entities/FamilyProduct.cs b/src/backend/YesChef.Api/Entities/FamilyProduct.cs index 6f15089..6ebedc5 100644 --- a/src/backend/YesChef.Api/Entities/FamilyProduct.cs +++ b/src/backend/YesChef.Api/Entities/FamilyProduct.cs @@ -13,5 +13,6 @@ public class FamilyProduct public string? Brand { get; set; } public string? Notes { get; set; } public UnitCategoryFlags AllowedUnitCategories { get; set; } + public string? DefaultSection { 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 index 3ed7efc..eabd90a 100644 --- a/src/backend/YesChef.Api/Entities/FamilyProductOverride.cs +++ b/src/backend/YesChef.Api/Entities/FamilyProductOverride.cs @@ -18,5 +18,8 @@ public class FamilyProductOverride // Nullable so "inherit global" (null) is distinguishable from // "explicitly None / any unit" (UnitCategoryFlags.None). public UnitCategoryFlags? AllowedUnitCategories { get; set; } + // Null = inherit Product.DefaultSection. Non-null = family override of + // the recommended section name. + public string? DefaultSection { 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 index 37e9da2..79d9f0f 100644 --- a/src/backend/YesChef.Api/Entities/Product.cs +++ b/src/backend/YesChef.Api/Entities/Product.cs @@ -14,5 +14,10 @@ public class Product // None = "any unit". Non-zero narrows the unit-dropdown suggestions to the // flagged categories. Families can replace this with FamilyProductOverride. public UnitCategoryFlags AllowedUnitCategories { get; set; } + // Recommended section name (e.g. "Produce"). Resolved at runtime against + // the active store's StoreSection rows by case-insensitive name match — + // sections are family/store-scoped so there's no FK relationship. Family + // overrides take precedence; per-store memory takes precedence over both. + public string? DefaultSection { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; } diff --git a/src/backend/YesChef.Api/Features/Products/ProductEndpoints.cs b/src/backend/YesChef.Api/Features/Products/ProductEndpoints.cs index bfb28ac..7fc3e49 100644 --- a/src/backend/YesChef.Api/Features/Products/ProductEndpoints.cs +++ b/src/backend/YesChef.Api/Features/Products/ProductEndpoints.cs @@ -3,7 +3,6 @@ using Microsoft.EntityFrameworkCore; using YesChef.Api.Auth; using YesChef.Api.Data; using YesChef.Api.Entities; -using YesChef.Api.Features.ShoppingLists; namespace YesChef.Api.Features.Products; @@ -22,10 +21,11 @@ public static class ProductEndpoints string? Brand, string? Notes, bool IsOverridden, - UnitCategoryFlags AllowedUnitCategories); + UnitCategoryFlags AllowedUnitCategories, + string? DefaultSection); - public record CreateProductRequest(string Name, string? Brand, string? Notes, UnitCategoryFlags AllowedUnitCategories = UnitCategoryFlags.None); - public record UpdateProductRequest(string? Name, string? Brand, string? Notes, UnitCategoryFlags? AllowedUnitCategories = null); + public record CreateProductRequest(string Name, string? Brand, string? Notes, UnitCategoryFlags AllowedUnitCategories = UnitCategoryFlags.None, string? DefaultSection = null); + public record UpdateProductRequest(string? Name, string? Brand, string? Notes, UnitCategoryFlags? AllowedUnitCategories = null, string? DefaultSection = null); private const int SearchResultLimit = 50; @@ -59,10 +59,12 @@ public static class ProductEndpoints GlobalBrand = p.Brand, GlobalNotes = p.Notes, GlobalAllowedUnitCategories = p.AllowedUnitCategories, + GlobalDefaultSection = p.DefaultSection, OverrideName = o != null ? o.Name : null, OverrideBrand = o != null ? o.Brand : null, OverrideNotes = o != null ? o.Notes : null, OverrideAllowedUnitCategories = o != null ? o.AllowedUnitCategories : null, + OverrideDefaultSection = o != null ? o.DefaultSection : null, HasOverride = o != null, }) .Take(SearchResultLimit) @@ -89,11 +91,12 @@ public static class ProductEndpoints r.OverrideBrand ?? r.GlobalBrand, r.OverrideNotes ?? r.GlobalNotes, r.HasOverride, - r.OverrideAllowedUnitCategories ?? r.GlobalAllowedUnitCategories); + r.OverrideAllowedUnitCategories ?? r.GlobalAllowedUnitCategories, + r.OverrideDefaultSection ?? r.GlobalDefaultSection); }).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, p.AllowedUnitCategories)); + new ProductDto(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false, p.AllowedUnitCategories, p.DefaultSection)); var results = globalDtos.Concat(familyDtos) .OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase) @@ -122,6 +125,7 @@ public static class ProductEndpoints Brand = request.Brand, Notes = request.Notes, AllowedUnitCategories = request.AllowedUnitCategories, + DefaultSection = NormalizeSection(request.DefaultSection), }; db.FamilyProducts.Add(product); await db.SaveChangesAsync(); @@ -151,6 +155,7 @@ public static class ProductEndpoints product.Brand = request.Brand; product.Notes = request.Notes; if (request.AllowedUnitCategories is { } cats) product.AllowedUnitCategories = cats; + product.DefaultSection = NormalizeSection(request.DefaultSection); await db.SaveChangesAsync(); return Results.Ok(ToDto(product)); @@ -195,7 +200,8 @@ public static class ProductEndpoints product.Brand, product.Notes, IsOverridden: false, - product.AllowedUnitCategories)); + product.AllowedUnitCategories, + product.DefaultSection)); }); // Update a global product for this family by upserting an override. @@ -229,6 +235,7 @@ public static class ProductEndpoints // itself be null, i.e. "inherit global"). Pass an explicit value to // either narrow categories or restore "any" (UnitCategoryFlags.None). if (request.AllowedUnitCategories is { } cats) ovr.AllowedUnitCategories = cats; + ovr.DefaultSection = NormalizeSection(request.DefaultSection); ovr.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); @@ -239,36 +246,26 @@ public static class ProductEndpoints ovr.Brand ?? product.Brand, ovr.Notes ?? product.Notes, IsOverridden: true, - ovr.AllowedUnitCategories ?? product.AllowedUnitCategories)); - }); - - // Look up the per-store section memory for a product so the add-item - // UI can pre-fill the dropdown the moment the user picks a product - // from the typeahead. The backend already applies this memory on POST - // when no section is supplied — this endpoint just lets the client - // mirror that decision so the user can see (and override) it. - // Returns { sectionId: int | null }. No memory → null, not 404. - group.MapGet("/global/{id:int}/section", async (int id, int storeId, YesChefDb db, HttpContext http) => - { - var familyId = http.User.GetFamilyId(); - var sectionId = await ShoppingListEndpoints.LookupRememberedSectionAsync(db, familyId, storeId, id, null); - return Results.Ok(new { sectionId }); - }); - - group.MapGet("/family/{id:int}/section", async (int id, int storeId, YesChefDb db, HttpContext http) => - { - var familyId = http.User.GetFamilyId(); - // FamilyProducts are tenant-scoped — refuse to leak even the - // existence of another family's product id. - if (!await db.FamilyProducts.AnyAsync(p => p.Id == id && p.FamilyId == familyId)) - return Results.NotFound(); - var sectionId = await ShoppingListEndpoints.LookupRememberedSectionAsync(db, familyId, storeId, null, id); - return Results.Ok(new { sectionId }); + ovr.AllowedUnitCategories ?? product.AllowedUnitCategories, + ovr.DefaultSection ?? product.DefaultSection)); }); return group; } private static ProductDto ToDto(FamilyProduct p) => - new(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false, p.AllowedUnitCategories); + new(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false, p.AllowedUnitCategories, p.DefaultSection); + + /// + /// Strip whitespace; turn empty into null so "no default" round-trips + /// consistently regardless of whether the client sends `""` or omits the + /// field. Storing whitespace would also break the case-insensitive match + /// against StoreSection names. + /// + private static string? NormalizeSection(string? value) + { + if (value is null) return null; + var trimmed = value.Trim(); + return trimmed.Length == 0 ? null : trimmed; + } } diff --git a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs index 1d62cf5..855b980 100644 --- a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs +++ b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs @@ -22,7 +22,19 @@ public static class ShoppingListEndpoints int? FamilyUnitOfMeasureId = null, bool IsApproximate = false, string? QuantityNote = null); - public record SetItemSectionRequest(int? SectionId); + public record SetItemSectionRequest(int? SectionId, bool SaveAsDefault = false); + + /// + /// Response shape for the section PATCH. When the item is linked to a + /// product and the family-level default exists with a different name, + /// is true and the client should ask the + /// user whether to update the default. Re-call with SaveAsDefault=true to + /// accept. + /// + public record SetItemSectionResponse( + bool PromptSaveDefault, + string? CurrentDefaultSection, + string? NewSectionName); private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}"; @@ -216,11 +228,10 @@ public static class ShoppingListEndpoints if (await ValidateUnitLink(db, familyId, request.UnitOfMeasureId, request.FamilyUnitOfMeasureId) is { } unitError) return unitError; - // 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." + // No explicit section → resolve through the product's defaults + // (per-store memory → family default → global default). var resolvedSectionId = request.SectionId - ?? await LookupRememberedSectionAsync(db, familyId, list.StoreId, request.ProductId, request.FamilyProductId); + ?? await ResolveSectionAsync(db, familyId, list.StoreId, request.ProductId, request.FamilyProductId); var item = new ShoppingListItem { @@ -240,12 +251,6 @@ public static class ShoppingListEndpoints 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 AllowedUnitCategoryLearner.LearnAsync(db, familyId, new[] { new AllowedUnitCategoryLearner.Pair(request.ProductId, request.FamilyProductId, item.UnitOfMeasureId, item.FamilyUnitOfMeasureId), @@ -266,18 +271,47 @@ public static class ShoppingListEndpoints .FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId && i.FamilyId == familyId && i.RemovedAt == null); if (item is null) return Results.NotFound(); - if (request.SectionId is int sectionId && - !await db.StoreSections.AnyAsync(s => s.Id == sectionId && s.StoreId == item.ShoppingList.StoreId && s.FamilyId == familyId)) - return Results.BadRequest(new { error = "Unknown section." }); + string? newSectionName = null; + if (request.SectionId is int sectionId) + { + var section = await db.StoreSections + .Where(s => s.Id == sectionId && s.StoreId == item.ShoppingList.StoreId && s.FamilyId == familyId) + .Select(s => new { s.Name }) + .FirstOrDefaultAsync(); + if (section is null) return Results.BadRequest(new { error = "Unknown section." }); + newSectionName = section.Name; + } 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); + + // For product-linked items, sync the family-level default. The + // rules per spec: + // - No existing default (override or family product) → set it + // silently. Creates a FamilyProductOverride for globals. + // - Existing default matches → no-op. + // - Existing default differs: + // * SaveAsDefault=true → update. + // * Otherwise → return PromptSaveDefault=true so the client + // can ask the user. The item section change still sticks. + var prompt = false; + string? currentDefault = null; + if (newSectionName is not null && (item.ProductId.HasValue || item.FamilyProductId.HasValue)) + { + currentDefault = await GetFamilyDefaultSectionNameAsync(db, familyId, item.ProductId, item.FamilyProductId); + var matches = currentDefault is not null + && string.Equals(currentDefault.Trim(), newSectionName.Trim(), StringComparison.OrdinalIgnoreCase); + var noDefaultYet = string.IsNullOrWhiteSpace(currentDefault); + + if (noDefaultYet || request.SaveAsDefault) + await SetFamilyDefaultSectionAsync(db, familyId, item.ProductId, item.FamilyProductId, newSectionName); + else if (!matches) + prompt = true; + } + await db.SaveChangesAsync(); await hub.Clients.Group($"list-{listId}").SendAsync("ItemSectionChanged", new { item.Id, item.SectionId }); - return Results.Ok(); + return Results.Ok(new SetItemSectionResponse(prompt, currentDefault, newSectionName)); }); group.MapPatch("/{listId:int}/items/{itemId:int}/check", async (int listId, int itemId, YesChefDb db, IHubContext hub, HttpContext http) => @@ -370,9 +404,9 @@ public static class ShoppingListEndpoints var idx = 0; foreach (var ing in recipe.Ingredients) { - // 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); + // Carry the ingredient's product link onto the list item and + // resolve a section through the product's defaults if any. + var rememberedSectionId = await ResolveSectionAsync(db, familyId, list.StoreId, ing.ProductId, ing.FamilyProductId); newItems.Add(new ShoppingListItem { @@ -470,50 +504,127 @@ public static class ShoppingListEndpoints } /// - /// 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. + /// Resolve the effective section for a product-linked item at this store. + /// Tiers, highest priority first: + /// 1. ProductStoreSection — per-store explicit override. + /// 2. Family default — FamilyProductOverride.DefaultSection (global product) + /// or FamilyProduct.DefaultSection (family product), matched by name + /// against this store's sections. + /// 3. Global default — Product.DefaultSection, matched by name. + /// Returns null when no tier produces a section (or no product is linked). /// - internal static async Task LookupRememberedSectionAsync(YesChefDb db, int familyId, int storeId, int? productId, int? familyProductId) + internal static async Task ResolveSectionAsync(YesChefDb db, int familyId, int storeId, int? productId, int? familyProductId) { if (!productId.HasValue && !familyProductId.HasValue) return null; - return await db.ProductStoreSections + var perStore = await db.ProductStoreSections .Where(p => p.FamilyId == familyId && p.StoreId == storeId && p.ProductId == productId && p.FamilyProductId == familyProductId) .Select(p => (int?)p.StoreSectionId) .FirstOrDefaultAsync(); + if (perStore.HasValue) return perStore; + + var defaultName = await GetEffectiveDefaultSectionNameAsync(db, familyId, productId, familyProductId); + if (string.IsNullOrWhiteSpace(defaultName)) return null; + + return await db.StoreSections + .Where(s => s.StoreId == storeId && s.FamilyId == familyId + && EF.Functions.ILike(s.Name, defaultName.Trim())) + .Select(s => (int?)s.Id) + .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. + /// Family-level default section name only — FamilyProductOverride for a + /// global product, or FamilyProduct.DefaultSection for a family product. + /// Does NOT fall back to the global Product default. Use this when + /// deciding whether the family has its own override and whether a prompt + /// is needed before changing it. /// - internal static async Task RememberSectionAsync(YesChefDb db, int familyId, int storeId, int? productId, int? familyProductId, int? sectionId) + internal static async Task GetFamilyDefaultSectionNameAsync(YesChefDb db, int familyId, int? productId, int? familyProductId) { - 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) + if (productId.HasValue) { - db.ProductStoreSections.Add(new ProductStoreSection - { - FamilyId = familyId, - StoreId = storeId, - ProductId = productId, - FamilyProductId = familyProductId, - StoreSectionId = sectionId.Value, - }); + return await db.FamilyProductOverrides.AsNoTracking() + .Where(o => o.FamilyId == familyId && o.ProductId == productId) + .Select(o => o.DefaultSection) + .FirstOrDefaultAsync(); } - else if (existing.StoreSectionId != sectionId.Value) + if (familyProductId.HasValue) { - existing.StoreSectionId = sectionId.Value; - existing.UpdatedAt = DateTime.UtcNow; + return await db.FamilyProducts.AsNoTracking() + .Where(p => p.Id == familyProductId && p.FamilyId == familyId) + .Select(p => p.DefaultSection) + .FirstOrDefaultAsync(); + } + return null; + } + + /// + /// Persist the family-level default section. For global products, upserts + /// a FamilyProductOverride; for family products, mutates the row directly. + /// Caller is responsible for SaveChangesAsync. + /// + internal static async Task SetFamilyDefaultSectionAsync(YesChefDb db, int familyId, int? productId, int? familyProductId, string sectionName) + { + if (productId.HasValue) + { + var ovr = await db.FamilyProductOverrides + .FirstOrDefaultAsync(o => o.FamilyId == familyId && o.ProductId == productId); + if (ovr is null) + { + db.FamilyProductOverrides.Add(new Entities.FamilyProductOverride + { + FamilyId = familyId, + ProductId = productId.Value, + DefaultSection = sectionName, + }); + } + else + { + ovr.DefaultSection = sectionName; + ovr.UpdatedAt = DateTime.UtcNow; + } + return; + } + if (familyProductId.HasValue) + { + var fp = await db.FamilyProducts + .FirstOrDefaultAsync(p => p.Id == familyProductId && p.FamilyId == familyId); + if (fp is not null) + fp.DefaultSection = sectionName; } } + + /// + /// Effective default section name for a product, walking the family + /// override (or family product) and falling back to the global product. + /// Pure read — does not consider per-store memory or section availability. + /// + internal static async Task GetEffectiveDefaultSectionNameAsync(YesChefDb db, int familyId, int? productId, int? familyProductId) + { + if (productId.HasValue) + { + var row = await ( + from p in db.Products.AsNoTracking() + where p.Id == productId + join o in db.FamilyProductOverrides.Where(o => o.FamilyId == familyId) + on p.Id equals o.ProductId into overrides + from o in overrides.DefaultIfEmpty() + select new { Override = o != null ? o.DefaultSection : null, Global = p.DefaultSection } + ).FirstOrDefaultAsync(); + + return row?.Override ?? row?.Global; + } + + if (familyProductId.HasValue) + { + return await db.FamilyProducts.AsNoTracking() + .Where(p => p.Id == familyProductId && p.FamilyId == familyId) + .Select(p => p.DefaultSection) + .FirstOrDefaultAsync(); + } + + return null; + } } diff --git a/src/backend/YesChef.Api/Migrations/20260516025713_AddProductDefaultSection.Designer.cs b/src/backend/YesChef.Api/Migrations/20260516025713_AddProductDefaultSection.Designer.cs new file mode 100644 index 0000000..9e11ae8 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260516025713_AddProductDefaultSection.Designer.cs @@ -0,0 +1,1112 @@ +// +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("20260516025713_AddProductDefaultSection")] + partial class AddProductDefaultSection + { + /// + 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("AllowedUnitCategories") + .HasColumnType("integer"); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultSection") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + 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("AllowedUnitCategories") + .HasColumnType("integer"); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("DefaultSection") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + 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.FamilyUnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Abbreviation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("PluralName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SingularName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Abbreviation") + .IsUnique(); + + b.ToTable("FamilyUnitsOfMeasure"); + }); + + 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("AllowedUnitCategories") + .HasColumnType("integer"); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DefaultSection") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + 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("FamilyUnitOfMeasureId") + .HasColumnType("integer"); + + b.Property("IsApproximate") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasPrecision(12, 4) + .HasColumnType("numeric(12,4)"); + + b.Property("QuantityNote") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UnitOfMeasureId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("FamilyProductId"); + + b.HasIndex("FamilyUnitOfMeasureId"); + + b.HasIndex("ProductId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("UnitOfMeasureId"); + + 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("FamilyUnitOfMeasureId") + .HasColumnType("integer"); + + b.Property("IsApproximate") + .HasColumnType("boolean"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasPrecision(12, 4) + .HasColumnType("numeric(12,4)"); + + b.Property("QuantityNote") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + 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.Property("UnitOfMeasureId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("FamilyProductId"); + + b.HasIndex("FamilyUnitOfMeasureId"); + + b.HasIndex("ProductId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("RemovedByUserId"); + + b.HasIndex("SectionId"); + + b.HasIndex("ShoppingListId"); + + b.HasIndex("UnitOfMeasureId"); + + 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.UnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Abbreviation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBase") + .HasColumnType("boolean"); + + b.Property("PluralName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SingularName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Abbreviation") + .IsUnique(); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("UnitsOfMeasure"); + }); + + 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.FamilyUnitOfMeasure", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + }); + + 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.FamilyUnitOfMeasure", "FamilyUnitOfMeasure") + .WithMany() + .HasForeignKey("FamilyUnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict); + + 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.HasOne("YesChef.Api.Entities.UnitOfMeasure", "UnitOfMeasure") + .WithMany() + .HasForeignKey("UnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Family"); + + b.Navigation("FamilyProduct"); + + b.Navigation("FamilyUnitOfMeasure"); + + b.Navigation("Product"); + + b.Navigation("Recipe"); + + b.Navigation("UnitOfMeasure"); + }); + + 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.FamilyUnitOfMeasure", "FamilyUnitOfMeasure") + .WithMany() + .HasForeignKey("FamilyUnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict); + + 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.HasOne("YesChef.Api.Entities.UnitOfMeasure", "UnitOfMeasure") + .WithMany() + .HasForeignKey("UnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CheckedByUser"); + + b.Navigation("Family"); + + b.Navigation("FamilyProduct"); + + b.Navigation("FamilyUnitOfMeasure"); + + b.Navigation("Product"); + + b.Navigation("Recipe"); + + b.Navigation("RemovedByUser"); + + b.Navigation("Section"); + + b.Navigation("ShoppingList"); + + b.Navigation("UnitOfMeasure"); + }); + + 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/20260516025713_AddProductDefaultSection.cs b/src/backend/YesChef.Api/Migrations/20260516025713_AddProductDefaultSection.cs new file mode 100644 index 0000000..9876973 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260516025713_AddProductDefaultSection.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + /// + public partial class AddProductDefaultSection : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DefaultSection", + table: "Products", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "DefaultSection", + table: "FamilyProducts", + type: "character varying(100)", + maxLength: 100, + nullable: true); + + migrationBuilder.AddColumn( + name: "DefaultSection", + table: "FamilyProductOverrides", + type: "character varying(100)", + maxLength: 100, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "DefaultSection", + table: "Products"); + + migrationBuilder.DropColumn( + name: "DefaultSection", + table: "FamilyProducts"); + + migrationBuilder.DropColumn( + name: "DefaultSection", + table: "FamilyProductOverrides"); + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs index 07a1204..55f1c60 100644 --- a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs +++ b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs @@ -90,6 +90,10 @@ namespace YesChef.Api.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("DefaultSection") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + b.Property("FamilyId") .HasColumnType("integer"); @@ -125,6 +129,10 @@ namespace YesChef.Api.Migrations .HasMaxLength(200) .HasColumnType("character varying(200)"); + b.Property("DefaultSection") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + b.Property("Name") .HasMaxLength(200) .HasColumnType("character varying(200)"); @@ -289,6 +297,10 @@ namespace YesChef.Api.Migrations b.Property("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("DefaultSection") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + b.Property("Name") .IsRequired() .HasMaxLength(200) diff --git a/src/frontend/src/lib/ProductTypeahead.svelte b/src/frontend/src/lib/ProductTypeahead.svelte index 6243453..c768e4e 100644 --- a/src/frontend/src/lib/ProductTypeahead.svelte +++ b/src/frontend/src/lib/ProductTypeahead.svelte @@ -17,6 +17,7 @@ notes: string | null; isOverridden: boolean; allowedUnitCategories: number; + defaultSection: string | null; } diff --git a/src/frontend/src/lib/TextCombobox.svelte b/src/frontend/src/lib/TextCombobox.svelte new file mode 100644 index 0000000..5bbccc4 --- /dev/null +++ b/src/frontend/src/lib/TextCombobox.svelte @@ -0,0 +1,124 @@ + + +
+ { + if (filtered.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 filtered as suggestion, i (suggestion)} +
  • { + // mousedown rather than click so we beat the blur handler. + e.preventDefault(); + selectSuggestion(suggestion); + }} + > + {suggestion} +
  • + {/each} +
+ {/if} +
diff --git a/src/frontend/src/lib/typicalSections.ts b/src/frontend/src/lib/typicalSections.ts new file mode 100644 index 0000000..e293ffe --- /dev/null +++ b/src/frontend/src/lib/typicalSections.ts @@ -0,0 +1,15 @@ +// Suggested grocery store section names — used to seed autocomplete for the +// product default-section field. Mirrors StoreSectionDefaults.Names on the +// backend; kept here as a static constant since it changes rarely and the +// list is tiny. Custom entries are still allowed. +export const typicalSections = [ + 'Produce', + 'Meat & Seafood', + 'Dairy', + 'Bakery', + 'Frozen', + 'Pantry', + 'Condiments', + 'Beverages', + 'Other', +] as const; diff --git a/src/frontend/src/routes/lists/[id]/+page.svelte b/src/frontend/src/routes/lists/[id]/+page.svelte index 045fb1d..e03e1dc 100644 --- a/src/frontend/src/routes/lists/[id]/+page.svelte +++ b/src/frontend/src/routes/lists/[id]/+page.svelte @@ -47,7 +47,6 @@ let items = $state([]); let sections = $state([]); let newItemName = $state(''); - let newItemSectionId = $state(null); let newItemProductId = $state(null); let newItemFamilyProductId = $state(null); let newItemQuantity = $state({ quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null }); @@ -56,9 +55,6 @@ let newItemAllowedUnitCategories = $state(0); let loading = $state(true); let connection: HubConnection | null = null; - // Seq guard so a slow section lookup can't overwrite the section a - // later (faster) product pick already set. - let sectionLookupSeq = 0; const listId = $derived(Number(page.params.id)); const uncheckedItems = $derived(items.filter((i) => !i.isChecked)); @@ -173,12 +169,14 @@ async function addItem() { if (!newItemName.trim()) return; const maxSort = items.length > 0 ? Math.max(...items.map((i) => i.sortOrder)) : 0; + // No explicit section is sent — the backend resolves one from the + // product's defaults (per-store memory → family default → global + // default). The user adjusts after the fact on the list row. await api('/api/lists/' + listId + '/items', { method: 'POST', body: JSON.stringify({ name: newItemName, sortOrder: maxSort + 1, - sectionId: newItemSectionId, productId: newItemProductId, familyProductId: newItemFamilyProductId, quantity: newItemIsApproximate ? null : newItemQuantity.quantity, @@ -206,14 +204,11 @@ } } - async function onItemProductChange(product: ProductSuggestion | null) { - const seq = ++sectionLookupSeq; + function onItemProductChange(product: ProductSuggestion | null) { if (product === null) { newItemProductId = null; newItemFamilyProductId = null; newItemAllowedUnitCategories = 0; - // Don't clobber the user's section choice when they edit the - // name and the link clears — they may have set it deliberately. return; } if (product.kind === 'Global') { @@ -224,22 +219,6 @@ newItemFamilyProductId = product.id; } newItemAllowedUnitCategories = product.allowedUnitCategories; - - // Mirror the backend's auto-assign rule: pre-fill the section dropdown - // from per-store memory so the user can see (and override) what's - // about to happen. Null result means no memory — reset to "No section". - if (!list) return; - const path = product.kind === 'Global' ? 'global' : 'family'; - try { - const res = await api<{ sectionId: number | null }>( - `/api/products/${path}/${product.id}/section?storeId=${list.store.id}` - ); - if (seq !== sectionLookupSeq) return; - newItemSectionId = res.sectionId; - } catch { - // Lookup failure shouldn't block adding an item — leave the - // section alone and let the backend auto-assign on POST. - } } async function toggleItem(itemId: number) { @@ -250,10 +229,29 @@ // Optimistic — the SignalR ItemSectionChanged echo will reconcile. items = items.map((i) => (i.id === itemId ? { ...i, sectionId } : i)); try { - await api(`/api/lists/${listId}/items/${itemId}/section`, { + const res = await api<{ + promptSaveDefault: boolean; + currentDefaultSection: string | null; + newSectionName: string | null; + }>(`/api/lists/${listId}/items/${itemId}/section`, { method: 'PATCH', body: JSON.stringify({ sectionId }) }); + + // Product had an existing family default that differs from this + // new choice. Ask whether to update the default — accepting + // re-PATCHes with saveAsDefault, declining leaves it as a one-off. + if (res.promptSaveDefault && res.newSectionName) { + const productName = items.find((i) => i.id === itemId)?.name ?? 'this product'; + const message = `Save “${productName}” → ${res.newSectionName} as the default? ` + + `(currently ${res.currentDefaultSection})`; + if (confirm(message)) { + await api(`/api/lists/${listId}/items/${itemId}/section`, { + method: 'PATCH', + body: JSON.stringify({ sectionId, saveAsDefault: true }) + }); + } + } } catch (e) { toast.error(e instanceof Error ? e.message : 'Failed to update section'); } @@ -319,7 +317,7 @@ onsubmit={addItem} onProductChange={onItemProductChange} /> -
+
{#if newItemIsApproximate} {/if} - {#if sections.length > 0} - - {/if}
@@ -255,6 +264,24 @@ > +
+ Default section (optional) +
+ +
+ + Used to slot this item into the matching section at each store. Pick a common + name or type your own. + +
Allowed units

Leave all unchecked to allow any unit.