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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user