Add product catalog with per-store section memory

Introduces a global Products catalog plus per-family overrides and
private FamilyProducts, exposed via /api/products with a merged
search. Shopping list items and recipe ingredients gain optional
ProductId/FamilyProductId links, and a new ProductStoreSection
table remembers which section a product was last placed in at a
given store so future adds auto-assign the right section.

Frontend gets a reusable ProductTypeahead component, wired into
list-item add and recipe ingredient entry with free-form fallback.

A startup CatalogSeeder loads ~115 curated staples from an embedded
JSON resource via INSERT ... ON CONFLICT DO NOTHING; skipped under
the Testing environment so integration tests keep a clean slate.
This commit is contained in:
Josh Rogers
2026-05-09 21:29:51 -05:00
parent 5c6abc1e43
commit 6c8f0167e5
27 changed files with 4621 additions and 36 deletions
@@ -2,12 +2,13 @@ using Microsoft.EntityFrameworkCore;
using YesChef.Api.Auth;
using YesChef.Api.Data;
using YesChef.Api.Entities;
using YesChef.Api.Features.ShoppingLists;
namespace YesChef.Api.Features.Recipes;
public static class RecipeEndpoints
{
public record IngredientRequest(string Name, string? Quantity, int SortOrder);
public record IngredientRequest(string Name, string? Quantity, int SortOrder, int? ProductId = null, int? FamilyProductId = null);
public record CreateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List<IngredientRequest> Ingredients);
public record UpdateRecipeRequest(string Title, string? Description, string? Instructions, int? Servings, string? SourceUrl, List<IngredientRequest> Ingredients);
@@ -36,6 +37,13 @@ public static class RecipeEndpoints
group.MapPost("/", async (CreateRecipeRequest request, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
foreach (var ing in request.Ingredients)
{
if (await ShoppingListEndpoints.ValidateProductLink(db, familyId, ing.ProductId, ing.FamilyProductId) is { } error)
return error;
}
var recipe = new Recipe
{
FamilyId = familyId,
@@ -50,7 +58,9 @@ public static class RecipeEndpoints
FamilyId = familyId,
Name = i.Name,
Quantity = i.Quantity,
SortOrder = i.SortOrder
SortOrder = i.SortOrder,
ProductId = i.ProductId,
FamilyProductId = i.FamilyProductId
}).ToList()
};
@@ -80,7 +90,7 @@ public static class RecipeEndpoints
recipe.SourceUrl,
CreatedBy = recipe.CreatedByUser.Name,
recipe.UpdatedAt,
Ingredients = recipe.Ingredients.Select(i => new { i.Id, i.Name, i.Quantity, i.SortOrder })
Ingredients = recipe.Ingredients.Select(i => new { i.Id, i.Name, i.Quantity, i.SortOrder, i.ProductId, i.FamilyProductId })
});
});
@@ -93,6 +103,12 @@ public static class RecipeEndpoints
.FirstOrDefaultAsync();
if (recipe is null) return Results.NotFound();
foreach (var ing in request.Ingredients)
{
if (await ShoppingListEndpoints.ValidateProductLink(db, familyId, ing.ProductId, ing.FamilyProductId) is { } error)
return error;
}
recipe.Title = request.Title;
recipe.Description = request.Description;
recipe.Instructions = request.Instructions;
@@ -106,7 +122,9 @@ public static class RecipeEndpoints
FamilyId = familyId,
Name = i.Name,
Quantity = i.Quantity,
SortOrder = i.SortOrder
SortOrder = i.SortOrder,
ProductId = i.ProductId,
FamilyProductId = i.FamilyProductId
}).ToList();
await db.SaveChangesAsync();