Initial commit: YesChef family shopping list and recipe app
Backend (.NET 10 minimal API): - Vertical slice architecture with feature folders - Postgres via EF Core with initial migration - JWT auth with family invite code registration - REST endpoints for stores, shopping lists, items, recipes - SignalR hub for real-time list collaboration (per-list groups and lists-overview group for live list creation/archival/progress) - Multi-stage Dockerfile Frontend (SvelteKit + Svelte 5 runes, Tailwind v4): - Mobile-first PWA with web manifest and service worker - Bottom-nav layout, login/register, lists overview, list detail, stores management, recipes (list/create/detail with add-to-list) - SignalR client with reference-counted connection - Real-time updates on both lists overview and list detail pages Infrastructure: - docker-compose.yml with postgres, backend, frontend services and Traefik labels for path-based routing (/api, /hubs to backend) - .env.example with required config End-to-end tests (Playwright): - test-e2e.mjs: single-user flow (auth, stores, lists, items, recipes) - test-e2e-multiuser.mjs: two-user real-time sync coverage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using YesChef.Api.Auth;
|
||||
using YesChef.Api.Data;
|
||||
using YesChef.Api.Entities;
|
||||
|
||||
namespace YesChef.Api.Features.Recipes;
|
||||
|
||||
public static class RecipeEndpoints
|
||||
{
|
||||
public record IngredientRequest(string Name, string? Quantity, int SortOrder);
|
||||
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, string? q) =>
|
||||
{
|
||||
var query = db.Recipes.AsQueryable();
|
||||
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 recipe = new Recipe
|
||||
{
|
||||
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
|
||||
{
|
||||
Name = i.Name,
|
||||
Quantity = i.Quantity,
|
||||
SortOrder = i.SortOrder
|
||||
}).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) =>
|
||||
{
|
||||
var recipe = await db.Recipes
|
||||
.Include(r => r.Ingredients.OrderBy(i => i.SortOrder))
|
||||
.Include(r => r.CreatedByUser)
|
||||
.FirstOrDefaultAsync(r => r.Id == id);
|
||||
|
||||
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 })
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPut("/{id:int}", async (int id, UpdateRecipeRequest request, YesChefDb db, HttpContext http) =>
|
||||
{
|
||||
var recipe = await db.Recipes.Include(r => r.Ingredients).FirstOrDefaultAsync(r => r.Id == id);
|
||||
if (recipe is null) return Results.NotFound();
|
||||
|
||||
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
|
||||
{
|
||||
Name = i.Name,
|
||||
Quantity = i.Quantity,
|
||||
SortOrder = i.SortOrder
|
||||
}).ToList();
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new { recipe.Id, recipe.Title });
|
||||
});
|
||||
|
||||
group.MapDelete("/{id:int}", async (int id, YesChefDb db) =>
|
||||
{
|
||||
var recipe = await db.Recipes.FindAsync(id);
|
||||
if (recipe is null) return Results.NotFound();
|
||||
|
||||
db.Recipes.Remove(recipe);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
return group;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user