Files
YesChef/src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs
T
Josh Rogers 9b2db931ee Scope all data access by FamilyId for multi-tenant isolation
Adds FamilyMembership join (UserId, FamilyId, Role) and a non-null
FamilyId FK on Store, ShoppingList, ShoppingListItem, Recipe, and
RecipeIngredient. FamilyId is denormalized on items/ingredients so the
tenant filter is a single column predicate without joins. Store name
uniqueness is now scoped per family.

JWT issuance stamps a family_id claim; ClaimsPrincipalExtensions exposes
GetFamilyId(). Register validates the supplied invite code against
Family.InviteCode (replacing the env-var equality check) and writes a
FamilyMembership row. OnTokenValidated rejects requests whose user has
been removed from the claimed family since login.

Every endpoint filters by FamilyId on read and stamps it on write.
Cross-family storeId references on list create/update return 400. The
SignalR hub verifies list ownership on JoinList and uses a per-family
overview group, so cross-tenant fan-out is structurally impossible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 23:05:23 -05:00

66 lines
2.4 KiB
C#

using Microsoft.EntityFrameworkCore;
using YesChef.Api.Auth;
using YesChef.Api.Data;
using YesChef.Api.Entities;
namespace YesChef.Api.Features.Stores;
public static class StoreEndpoints
{
public record CreateStoreRequest(string Name, int SortOrder = 0);
public record UpdateStoreRequest(string Name, int SortOrder);
public static RouteGroupBuilder MapStoreEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/", async (YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
return await db.Stores
.Where(s => s.FamilyId == familyId)
.OrderBy(s => s.SortOrder).ThenBy(s => s.Name)
.ToListAsync();
});
group.MapPost("/", async (CreateStoreRequest request, YesChefDb db, HttpContext http) =>
{
var store = new Store
{
FamilyId = http.User.GetFamilyId(),
Name = request.Name,
SortOrder = request.SortOrder,
};
db.Stores.Add(store);
await db.SaveChangesAsync();
return Results.Created($"/api/stores/{store.Id}", store);
});
group.MapPut("/{id:int}", async (int id, UpdateStoreRequest request, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var store = await db.Stores.FirstOrDefaultAsync(s => s.Id == id && s.FamilyId == familyId);
if (store is null) return Results.NotFound();
store.Name = request.Name;
store.SortOrder = request.SortOrder;
await db.SaveChangesAsync();
return Results.Ok(store);
});
group.MapDelete("/{id:int}", async (int id, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var store = await db.Stores.FirstOrDefaultAsync(s => s.Id == id && s.FamilyId == familyId);
if (store is null) return Results.NotFound();
var hasLists = await db.ShoppingLists.AnyAsync(l => l.StoreId == id && l.FamilyId == familyId);
if (hasLists) return Results.BadRequest(new { error = "Store has shopping lists. Remove them first." });
db.Stores.Remove(store);
await db.SaveChangesAsync();
return Results.NoContent();
});
return group;
}
}