Auto-learn product allowed-unit categories from recipe / list writes

When a recipe ingredient or list item is saved with both a product link and
a unit link, the unit's category is OR'd into the product's AllowedUnitCategories
so the dropdown filter populates itself from real usage instead of needing
explicit configuration. Family products are mutated directly; global products
gain (or extend) a per-family FamilyProductOverride so the global catalog
stays read-only.

The learner only widens flag sets — it never narrows or clears them — so
repeated writes are idempotent. No-ops when the global product already
covers the category (no spurious override rows).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Josh Rogers
2026-05-13 22:32:23 -05:00
parent fd6b0accc8
commit ee98fc8134
5 changed files with 270 additions and 0 deletions
@@ -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);
}
}
@@ -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()
{
@@ -0,0 +1,114 @@
using Microsoft.EntityFrameworkCore;
using YesChef.Api.Data;
using YesChef.Api.Entities;
namespace YesChef.Api.Features.Products;
/// <summary>
/// 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.
/// </summary>
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<Pair> 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<int, UnitCategory>()
: await db.UnitsOfMeasure.Where(u => globalUnitIds.Contains(u.Id))
.ToDictionaryAsync(u => u.Id, u => u.Category);
var familyCats = familyUnitIds.Count == 0
? new Dictionary<int, UnitCategory>()
: await db.FamilyUnitsOfMeasure.Where(u => familyUnitIds.Contains(u.Id) && u.FamilyId == familyId)
.ToDictionaryAsync(u => u.Id, u => u.Category);
var familyProductDeltas = new Dictionary<int, UnitCategoryFlags>();
var globalProductDeltas = new Dictionary<int, UnitCategoryFlags>();
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;
}
}
}
}
}
}
@@ -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,
@@ -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));