diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs index 0e53050..0227365 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/RecipeEndpointsTests.cs @@ -298,4 +298,87 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest await Assert.That(salt.GetProperty("isApproximate").GetBoolean()).IsTrue(); await Assert.That(salt.GetProperty("quantityNote").GetString()).IsEqualTo("to taste"); } + + [Test] + public async Task Create_learns_allowed_unit_categories_for_family_product() + { + var (familyProduct, unitId) = await UseDbAsync(async db => + { + var familyId = await db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync(); + var fp = new FamilyProduct { FamilyId = familyId, Name = "House Flour" }; + db.FamilyProducts.Add(fp); + var u = new UnitOfMeasure + { + Code = "cup-recipe-learn", SingularName = "cup", PluralName = "cups", + Abbreviation = "cup-rl", Category = UnitCategory.Volume, + }; + db.UnitsOfMeasure.Add(u); + await db.SaveChangesAsync(); + return (fp, u.Id); + }); + + var request = new RecipeEndpoints.CreateRecipeRequest( + Title: "Pancakes", + Description: null, + Instructions: null, + Servings: null, + SourceUrl: null, + Ingredients: + [ + new RecipeEndpoints.IngredientRequest("flour", 1, + Quantity: 2m, UnitOfMeasureId: unitId, FamilyProductId: familyProduct.Id), + ]); + + var response = await Client.PostAsJsonAsync("/api/recipes", request); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var stored = await UseDbAsync(db => db.FamilyProducts.SingleAsync(p => p.Id == familyProduct.Id)); + await Assert.That(stored.AllowedUnitCategories & UnitCategoryFlags.Volume).IsEqualTo(UnitCategoryFlags.Volume); + } + + [Test] + public async Task Create_accumulates_distinct_categories_across_ingredients() + { + var familyId = await UseDbAsync(db => + db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync()); + var (familyProduct, cupId, ozId) = await UseDbAsync(async db => + { + var fp = new FamilyProduct { FamilyId = familyId, Name = "House Flour-multi" }; + db.FamilyProducts.Add(fp); + var cup = new UnitOfMeasure + { + Code = "cup-acc", SingularName = "cup", PluralName = "cups", + Abbreviation = "cup-acc", Category = UnitCategory.Volume, + }; + var oz = new UnitOfMeasure + { + Code = "oz-acc", SingularName = "oz", PluralName = "oz", + Abbreviation = "oz-acc", Category = UnitCategory.Weight, + }; + db.UnitsOfMeasure.AddRange(cup, oz); + await db.SaveChangesAsync(); + return (fp, cup.Id, oz.Id); + }); + + var request = new RecipeEndpoints.CreateRecipeRequest( + Title: "Bread", + Description: null, + Instructions: null, + Servings: null, + SourceUrl: null, + Ingredients: + [ + new RecipeEndpoints.IngredientRequest("flour-cup", 1, + Quantity: 2m, UnitOfMeasureId: cupId, FamilyProductId: familyProduct.Id), + new RecipeEndpoints.IngredientRequest("flour-oz", 2, + Quantity: 8m, UnitOfMeasureId: ozId, FamilyProductId: familyProduct.Id), + ]); + + var response = await Client.PostAsJsonAsync("/api/recipes", request); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var stored = await UseDbAsync(db => db.FamilyProducts.SingleAsync(p => p.Id == familyProduct.Id)); + await Assert.That(stored.AllowedUnitCategories) + .IsEqualTo(UnitCategoryFlags.Volume | UnitCategoryFlags.Weight); + } } diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs index 7f3b782..6b9838b 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs @@ -601,6 +601,67 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest await Assert.That(salt.GetProperty("quantity").ValueKind).IsEqualTo(JsonValueKind.Null); } + [Test] + public async Task Add_item_learns_family_product_unit_category() + { + var (familyProduct, cupId) = await UseDbAsync(async db => + { + var familyId = await db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync(); + var fp = new FamilyProduct { FamilyId = familyId, Name = "House Milk" }; + db.FamilyProducts.Add(fp); + var u = new UnitOfMeasure + { + Code = "cup-learn", SingularName = "cup", PluralName = "cups", + Abbreviation = "cup-l", Category = UnitCategory.Volume, + }; + db.UnitsOfMeasure.Add(u); + await db.SaveChangesAsync(); + return (fp, u.Id); + }); + var list = await CreateListAsync(); + + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("milk", + FamilyProductId: familyProduct.Id, Quantity: 1m, UnitOfMeasureId: cupId)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var stored = await UseDbAsync(db => db.FamilyProducts.SingleAsync(p => p.Id == familyProduct.Id)); + await Assert.That(stored.AllowedUnitCategories & UnitCategoryFlags.Volume).IsEqualTo(UnitCategoryFlags.Volume); + } + + [Test] + public async Task Add_item_learns_global_product_via_family_override() + { + var (product, cupId) = await UseDbAsync(async db => + { + var p = new Product { Name = "Milk-global-learn" }; + db.Products.Add(p); + var u = new UnitOfMeasure + { + Code = "cup-global-learn", SingularName = "cup", PluralName = "cups", + Abbreviation = "cup-gl", Category = UnitCategory.Volume, + }; + db.UnitsOfMeasure.Add(u); + await db.SaveChangesAsync(); + return (p, u.Id); + }); + var list = await CreateListAsync(); + + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("milk", + ProductId: product.Id, Quantity: 1m, UnitOfMeasureId: cupId)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + + var ovr = await UseDbAsync(db => db.FamilyProductOverrides.SingleOrDefaultAsync(o => o.ProductId == product.Id)); + await Assert.That(ovr).IsNotNull(); + await Assert.That(ovr!.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Volume); + + // Global row stays untouched — only the family-scoped override carries the learned flag. + var global = await UseDbAsync(db => db.Products.SingleAsync(p => p.Id == product.Id)); + await Assert.That(global.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.None); + } + [Test] public async Task Add_recipe_returns_404_when_recipe_missing() { diff --git a/src/backend/YesChef.Api/Features/Products/AllowedUnitCategoryLearner.cs b/src/backend/YesChef.Api/Features/Products/AllowedUnitCategoryLearner.cs new file mode 100644 index 0000000..d03ee17 --- /dev/null +++ b/src/backend/YesChef.Api/Features/Products/AllowedUnitCategoryLearner.cs @@ -0,0 +1,114 @@ +using Microsoft.EntityFrameworkCore; +using YesChef.Api.Data; +using YesChef.Api.Entities; + +namespace YesChef.Api.Features.Products; + +/// +/// Folds the categories of (product, unit) pairings observed at write time +/// into the product's effective AllowedUnitCategories. Family products are +/// mutated directly; global products gain (or extend) a per-family override +/// so the global catalog stays read-only. Only widens — never narrows — the +/// flag set, so repeated calls are idempotent. +/// +public static class AllowedUnitCategoryLearner +{ + public readonly record struct Pair(int? ProductId, int? FamilyProductId, int? UnitOfMeasureId, int? FamilyUnitOfMeasureId); + + public static async Task LearnAsync(YesChefDb db, int familyId, IEnumerable pairs) + { + var valid = pairs.Where(p => + (p.ProductId.HasValue || p.FamilyProductId.HasValue) && + (p.UnitOfMeasureId.HasValue || p.FamilyUnitOfMeasureId.HasValue)).ToList(); + if (valid.Count == 0) return; + + var globalUnitIds = valid.Where(p => p.UnitOfMeasureId.HasValue) + .Select(p => p.UnitOfMeasureId!.Value).Distinct().ToList(); + var familyUnitIds = valid.Where(p => p.FamilyUnitOfMeasureId.HasValue) + .Select(p => p.FamilyUnitOfMeasureId!.Value).Distinct().ToList(); + + var globalCats = globalUnitIds.Count == 0 + ? new Dictionary() + : await db.UnitsOfMeasure.Where(u => globalUnitIds.Contains(u.Id)) + .ToDictionaryAsync(u => u.Id, u => u.Category); + var familyCats = familyUnitIds.Count == 0 + ? new Dictionary() + : await db.FamilyUnitsOfMeasure.Where(u => familyUnitIds.Contains(u.Id) && u.FamilyId == familyId) + .ToDictionaryAsync(u => u.Id, u => u.Category); + + var familyProductDeltas = new Dictionary(); + var globalProductDeltas = new Dictionary(); + + foreach (var p in valid) + { + UnitCategoryFlags flag = UnitCategoryFlags.None; + if (p.UnitOfMeasureId.HasValue && globalCats.TryGetValue(p.UnitOfMeasureId.Value, out var gc)) + flag = gc.ToFlag(); + else if (p.FamilyUnitOfMeasureId.HasValue && familyCats.TryGetValue(p.FamilyUnitOfMeasureId.Value, out var fc)) + flag = fc.ToFlag(); + if (flag == UnitCategoryFlags.None) continue; + + if (p.FamilyProductId.HasValue) + { + familyProductDeltas.TryGetValue(p.FamilyProductId.Value, out var existing); + familyProductDeltas[p.FamilyProductId.Value] = existing | flag; + } + else if (p.ProductId.HasValue) + { + globalProductDeltas.TryGetValue(p.ProductId.Value, out var existing); + globalProductDeltas[p.ProductId.Value] = existing | flag; + } + } + + if (familyProductDeltas.Count > 0) + { + var ids = familyProductDeltas.Keys.ToList(); + var products = await db.FamilyProducts + .Where(p => ids.Contains(p.Id) && p.FamilyId == familyId).ToListAsync(); + foreach (var fp in products) + { + var next = fp.AllowedUnitCategories | familyProductDeltas[fp.Id]; + if (next != fp.AllowedUnitCategories) fp.AllowedUnitCategories = next; + } + } + + if (globalProductDeltas.Count > 0) + { + var ids = globalProductDeltas.Keys.ToList(); + var globalBase = await db.Products.Where(p => ids.Contains(p.Id)) + .Select(p => new { p.Id, p.AllowedUnitCategories }) + .ToDictionaryAsync(p => p.Id, p => p.AllowedUnitCategories); + var overrides = await db.FamilyProductOverrides + .Where(o => o.FamilyId == familyId && ids.Contains(o.ProductId)) + .ToDictionaryAsync(o => o.ProductId, o => o); + + foreach (var (pid, delta) in globalProductDeltas) + { + var baseCats = globalBase.TryGetValue(pid, out var gc) ? gc : UnitCategoryFlags.None; + if (!overrides.TryGetValue(pid, out var ovr)) + { + // No override yet. Skip if the global already covers it + // — otherwise create an override that carries the union. + var next = baseCats | delta; + if (next == baseCats) continue; + db.FamilyProductOverrides.Add(new FamilyProductOverride + { + FamilyId = familyId, + ProductId = pid, + AllowedUnitCategories = next, + }); + } + else + { + var current = ovr.AllowedUnitCategories ?? baseCats; + var next = current | delta; + if (next != current) + { + ovr.AllowedUnitCategories = next; + ovr.UpdatedAt = DateTime.UtcNow; + } + } + } + } + } +} diff --git a/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs b/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs index 87c18b2..b721dea 100644 --- a/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs +++ b/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs @@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore; using YesChef.Api.Auth; using YesChef.Api.Data; using YesChef.Api.Entities; +using YesChef.Api.Features.Products; using YesChef.Api.Features.ShoppingLists; namespace YesChef.Api.Features.Recipes; @@ -64,6 +65,7 @@ public static class RecipeEndpoints }; db.Recipes.Add(recipe); + await AllowedUnitCategoryLearner.LearnAsync(db, familyId, request.Ingredients.Select(ToLearnerPair)); await db.SaveChangesAsync(); return Results.Created($"/api/recipes/{recipe.Id}", new { recipe.Id, recipe.Title }); }); @@ -127,6 +129,7 @@ public static class RecipeEndpoints db.RecipeIngredients.RemoveRange(recipe.Ingredients); recipe.Ingredients = request.Ingredients.Select(i => ToEntity(i, familyId)).ToList(); + await AllowedUnitCategoryLearner.LearnAsync(db, familyId, request.Ingredients.Select(ToLearnerPair)); await db.SaveChangesAsync(); return Results.Ok(new { recipe.Id, recipe.Title }); }); @@ -157,6 +160,9 @@ public static class RecipeEndpoints return null; } + private static AllowedUnitCategoryLearner.Pair ToLearnerPair(IngredientRequest i) => + new(i.ProductId, i.FamilyProductId, i.UnitOfMeasureId, i.FamilyUnitOfMeasureId); + private static RecipeIngredient ToEntity(IngredientRequest i, int familyId) => new() { FamilyId = familyId, diff --git a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs index e882c09..242e2d9 100644 --- a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs +++ b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using YesChef.Api.Auth; using YesChef.Api.Data; using YesChef.Api.Entities; +using YesChef.Api.Features.Products; namespace YesChef.Api.Features.ShoppingLists; @@ -231,6 +232,11 @@ public static class ShoppingListEndpoints 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), + }); + await db.SaveChangesAsync(); await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", ItemAddedPayload(item, recipeTitle: null));