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:
Josh Rogers
2026-05-06 19:32:39 -05:00
commit 48d30df07b
64 changed files with 5873 additions and 0 deletions
@@ -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;
}
}