Files
YesChef/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs
T
Josh Rogers 6c8f0167e5 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.
2026-05-09 21:29:51 -05:00

148 lines
5.7 KiB
C#

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, 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);
public static RouteGroupBuilder MapRecipeEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/", async (YesChefDb db, HttpContext http, string? q) =>
{
var familyId = http.User.GetFamilyId();
var query = db.Recipes.Where(r => r.FamilyId == familyId);
if (!string.IsNullOrWhiteSpace(q))
query = query.Where(r => r.Title.Contains(q));
return await query.OrderByDescending(r => r.UpdatedAt)
.Select(r => new
{
r.Id,
r.Title,
r.Description,
r.Servings,
IngredientCount = r.Ingredients.Count,
r.UpdatedAt
})
.ToListAsync();
});
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,
Title = request.Title,
Description = request.Description,
Instructions = request.Instructions,
Servings = request.Servings,
SourceUrl = request.SourceUrl,
CreatedByUserId = http.User.GetUserId(),
Ingredients = request.Ingredients.Select(i => new RecipeIngredient
{
FamilyId = familyId,
Name = i.Name,
Quantity = i.Quantity,
SortOrder = i.SortOrder,
ProductId = i.ProductId,
FamilyProductId = i.FamilyProductId
}).ToList()
};
db.Recipes.Add(recipe);
await db.SaveChangesAsync();
return Results.Created($"/api/recipes/{recipe.Id}", new { recipe.Id, recipe.Title });
});
group.MapGet("/{id:int}", async (int id, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var recipe = await db.Recipes
.Where(r => r.Id == id && r.FamilyId == familyId)
.Include(r => r.Ingredients.OrderBy(i => i.SortOrder))
.Include(r => r.CreatedByUser)
.FirstOrDefaultAsync();
if (recipe is null) return Results.NotFound();
return Results.Ok(new
{
recipe.Id,
recipe.Title,
recipe.Description,
recipe.Instructions,
recipe.Servings,
recipe.SourceUrl,
CreatedBy = recipe.CreatedByUser.Name,
recipe.UpdatedAt,
Ingredients = recipe.Ingredients.Select(i => new { i.Id, i.Name, i.Quantity, i.SortOrder, i.ProductId, i.FamilyProductId })
});
});
group.MapPut("/{id:int}", async (int id, UpdateRecipeRequest request, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var recipe = await db.Recipes
.Where(r => r.Id == id && r.FamilyId == familyId)
.Include(r => r.Ingredients)
.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;
recipe.Servings = request.Servings;
recipe.SourceUrl = request.SourceUrl;
recipe.UpdatedAt = DateTime.UtcNow;
db.RecipeIngredients.RemoveRange(recipe.Ingredients);
recipe.Ingredients = request.Ingredients.Select(i => new RecipeIngredient
{
FamilyId = familyId,
Name = i.Name,
Quantity = i.Quantity,
SortOrder = i.SortOrder,
ProductId = i.ProductId,
FamilyProductId = i.FamilyProductId
}).ToList();
await db.SaveChangesAsync();
return Results.Ok(new { recipe.Id, recipe.Title });
});
group.MapDelete("/{id:int}", async (int id, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var recipe = await db.Recipes.FirstOrDefaultAsync(r => r.Id == id && r.FamilyId == familyId);
if (recipe is null) return Results.NotFound();
db.Recipes.Remove(recipe);
await db.SaveChangesAsync();
return Results.NoContent();
});
return group;
}
}