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));