6c8f0167e5
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.
148 lines
5.7 KiB
C#
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;
|
|
}
|
|
}
|