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>
This commit is contained in:
Josh Rogers
2026-05-07 23:05:23 -05:00
parent 7c1cfd62e6
commit 9b2db931ee
25 changed files with 1057 additions and 90 deletions
@@ -0,0 +1,13 @@
using YesChef.Api.Data;
namespace YesChef.Api.IntegrationTests.Builders;
internal static class BuilderDefaults
{
/// <summary>
/// When a builder doesn't specify a family, default to the bootstrap
/// family seeded by Program.cs. Test DBs always have at least one.
/// </summary>
public static async Task<int> DefaultFamilyIdAsync(YesChefDb db) =>
await db.Families.OrderBy(f => f.Id).Select(f => f.Id).FirstAsync();
}
@@ -11,6 +11,7 @@ public sealed class RecipeBuilder
private int? _servings;
private string? _sourceUrl;
private int? _createdByUserId;
private int? _familyId;
private readonly List<RecipeIngredient> _ingredients = [];
public RecipeBuilder Titled(string title) { _title = title; return this; }
@@ -20,6 +21,8 @@ public sealed class RecipeBuilder
public RecipeBuilder SourcedFrom(string url) { _sourceUrl = url; return this; }
public RecipeBuilder CreatedBy(User user) { _createdByUserId = user.Id; return this; }
public RecipeBuilder CreatedBy(int userId) { _createdByUserId = userId; return this; }
public RecipeBuilder ForFamily(int familyId) { _familyId = familyId; return this; }
public RecipeBuilder ForFamily(Family family) { _familyId = family.Id; return this; }
public RecipeBuilder WithIngredient(string name, string? quantity = null, int sortOrder = 0)
{
@@ -27,12 +30,16 @@ public sealed class RecipeBuilder
return this;
}
public Recipe Build()
public async Task<Recipe> PersistAsync(YesChefDb db)
{
if (_createdByUserId is null) throw new InvalidOperationException("Recipe requires a creator. Call CreatedBy().");
return new Recipe
var familyId = _familyId ?? await BuilderDefaults.DefaultFamilyIdAsync(db);
foreach (var ing in _ingredients) ing.FamilyId = familyId;
var recipe = new Recipe
{
FamilyId = familyId,
Title = _title,
Description = _description,
Instructions = _instructions,
@@ -41,11 +48,6 @@ public sealed class RecipeBuilder
CreatedByUserId = _createdByUserId.Value,
Ingredients = _ingredients
};
}
public async Task<Recipe> PersistAsync(YesChefDb db)
{
var recipe = Build();
db.Recipes.Add(recipe);
await db.SaveChangesAsync();
return recipe;
@@ -8,14 +8,17 @@ public sealed class ShoppingListBuilder
private string _name = $"List-{Guid.NewGuid():N}"[..20];
private int? _storeId;
private int? _createdByUserId;
private int? _familyId;
private bool _archived;
private readonly List<ShoppingListItem> _items = [];
public ShoppingListBuilder Named(string name) { _name = name; return this; }
public ShoppingListBuilder ForStore(Store store) { _storeId = store.Id; return this; }
public ShoppingListBuilder ForStore(Store store) { _storeId = store.Id; _familyId ??= store.FamilyId; return this; }
public ShoppingListBuilder ForStore(int storeId) { _storeId = storeId; return this; }
public ShoppingListBuilder CreatedBy(User user) { _createdByUserId = user.Id; return this; }
public ShoppingListBuilder CreatedBy(int userId) { _createdByUserId = userId; return this; }
public ShoppingListBuilder ForFamily(int familyId) { _familyId = familyId; return this; }
public ShoppingListBuilder ForFamily(Family family) { _familyId = family.Id; return this; }
public ShoppingListBuilder Archived() { _archived = true; return this; }
public ShoppingListBuilder WithItem(string name, bool isChecked = false, int sortOrder = 0)
@@ -24,24 +27,23 @@ public sealed class ShoppingListBuilder
return this;
}
public ShoppingList Build()
public async Task<ShoppingList> PersistAsync(YesChefDb db)
{
if (_storeId is null) throw new InvalidOperationException("ShoppingList requires a Store. Call ForStore().");
if (_createdByUserId is null) throw new InvalidOperationException("ShoppingList requires a creator. Call CreatedBy().");
return new ShoppingList
var familyId = _familyId ?? await BuilderDefaults.DefaultFamilyIdAsync(db);
foreach (var item in _items) item.FamilyId = familyId;
var list = new ShoppingList
{
FamilyId = familyId,
Name = _name,
StoreId = _storeId.Value,
CreatedByUserId = _createdByUserId.Value,
IsArchived = _archived,
Items = _items
};
}
public async Task<ShoppingList> PersistAsync(YesChefDb db)
{
var list = Build();
db.ShoppingLists.Add(list);
await db.SaveChangesAsync();
return list;
@@ -7,15 +7,17 @@ public sealed class StoreBuilder
{
private string _name = $"Store-{Guid.NewGuid():N}"[..20];
private int _sortOrder;
private int? _familyId;
public StoreBuilder Named(string name) { _name = name; return this; }
public StoreBuilder WithSortOrder(int sortOrder) { _sortOrder = sortOrder; return this; }
public Store Build() => new() { Name = _name, SortOrder = _sortOrder };
public StoreBuilder ForFamily(int familyId) { _familyId = familyId; return this; }
public StoreBuilder ForFamily(Family family) { _familyId = family.Id; return this; }
public async Task<Store> PersistAsync(YesChefDb db)
{
var store = Build();
var familyId = _familyId ?? await BuilderDefaults.DefaultFamilyIdAsync(db);
var store = new Store { FamilyId = familyId, Name = _name, SortOrder = _sortOrder };
db.Stores.Add(store);
await db.SaveChangesAsync();
return store;
@@ -28,4 +28,18 @@ public class ClaimsPrincipalExtensionsTests
var principal = Build();
await Assert.That(() => principal.GetUserId()).Throws<ArgumentNullException>();
}
[Test]
public async Task GetFamilyId_parses_family_id_claim()
{
var principal = Build(new Claim(JwtTokenService.FamilyIdClaim, "7"));
await Assert.That(principal.GetFamilyId()).IsEqualTo(7);
}
[Test]
public async Task GetFamilyId_throws_when_claim_missing()
{
var principal = Build();
await Assert.That(() => principal.GetFamilyId()).Throws<ArgumentNullException>();
}
}
@@ -24,15 +24,16 @@ public class JwtTokenServiceTests
new JwtSecurityTokenHandler().ReadJwtToken(token);
[Test]
public async Task GenerateToken_includes_user_id_and_name_claims()
public async Task GenerateToken_includes_user_id_name_and_family_claims()
{
var service = BuildService();
var user = new User { Id = 42, Name = "alice", PasswordHash = "x" };
var jwt = Decode(service.GenerateToken(user));
var jwt = Decode(service.GenerateToken(user, familyId: 7));
await Assert.That(jwt.Claims.First(c => c.Type == ClaimTypes.NameIdentifier).Value).IsEqualTo("42");
await Assert.That(jwt.Claims.First(c => c.Type == ClaimTypes.Name).Value).IsEqualTo("alice");
await Assert.That(jwt.Claims.First(c => c.Type == JwtTokenService.FamilyIdClaim).Value).IsEqualTo("7");
}
[Test]
@@ -41,7 +42,7 @@ public class JwtTokenServiceTests
var service = BuildService();
var user = new User { Id = 1, Name = "bob", PasswordHash = "x" };
var jwt = Decode(service.GenerateToken(user));
var jwt = Decode(service.GenerateToken(user, familyId: 1));
var expectedExpiry = DateTime.UtcNow.AddDays(30);
var delta = (jwt.ValidTo - expectedExpiry).Duration();
@@ -54,7 +55,7 @@ public class JwtTokenServiceTests
var service = BuildService();
var user = new User { Id = 7, Name = "carol", PasswordHash = "x" };
var token = service.GenerateToken(user);
var token = service.GenerateToken(user, familyId: 1);
var validator = new JwtSecurityTokenHandler();
var parameters = new TokenValidationParameters
@@ -77,7 +78,7 @@ public class JwtTokenServiceTests
public async Task GenerateToken_with_different_secret_fails_validation()
{
var service = BuildService();
var token = service.GenerateToken(new User { Id = 1, Name = "x", PasswordHash = "x" });
var token = service.GenerateToken(new User { Id = 1, Name = "x", PasswordHash = "x" }, familyId: 1);
var validator = new JwtSecurityTokenHandler();
var parameters = new TokenValidationParameters
+24 -6
View File
@@ -15,10 +15,10 @@ public static class AuthEndpoints
{
var hasher = new PasswordHasher<User>();
group.MapPost("/register", async (RegisterRequest request, YesChefDb db, JwtTokenService jwt, IConfiguration config) =>
group.MapPost("/register", async (RegisterRequest request, YesChefDb db, JwtTokenService jwt) =>
{
var familyCode = config["FamilyCode"];
if (string.IsNullOrEmpty(familyCode) || request.FamilyCode != familyCode)
var family = await db.Families.FirstOrDefaultAsync(f => f.InviteCode == request.FamilyCode);
if (family is null)
return Results.BadRequest(new { error = "Invalid family code." });
if (await db.Users.AnyAsync(u => u.Name == request.Name))
@@ -30,7 +30,15 @@ public static class AuthEndpoints
db.Users.Add(user);
await db.SaveChangesAsync();
var token = jwt.GenerateToken(user);
db.FamilyMemberships.Add(new FamilyMembership
{
UserId = user.Id,
FamilyId = family.Id,
Role = FamilyRole.Member,
});
await db.SaveChangesAsync();
var token = jwt.GenerateToken(user, family.Id);
return Results.Ok(new AuthResponse(token, user.Name));
});
@@ -44,7 +52,16 @@ public static class AuthEndpoints
if (result == PasswordVerificationResult.Failed)
return Results.Unauthorized();
var token = jwt.GenerateToken(user);
// Pick the user's first/only family. Multi-family membership becomes
// meaningful in a later step; for now a user belongs to exactly one.
var membership = await db.FamilyMemberships
.Where(m => m.UserId == user.Id)
.OrderBy(m => m.FamilyId)
.FirstOrDefaultAsync();
if (membership is null)
return Results.Unauthorized();
var token = jwt.GenerateToken(user, membership.FamilyId);
return Results.Ok(new AuthResponse(token, user.Name));
});
@@ -52,7 +69,8 @@ public static class AuthEndpoints
{
var userId = http.User.GetUserId();
var name = http.User.GetUserName();
return Results.Ok(new { id = userId, name });
var familyId = http.User.GetFamilyId();
return Results.Ok(new { id = userId, name, familyId });
}).RequireAuthorization();
return group;
@@ -9,4 +9,7 @@ public static class ClaimsPrincipalExtensions
public static string GetUserName(this ClaimsPrincipal principal) =>
principal.FindFirstValue(ClaimTypes.Name)!;
public static int GetFamilyId(this ClaimsPrincipal principal) =>
int.Parse(principal.FindFirstValue(JwtTokenService.FamilyIdClaim)!);
}
@@ -8,7 +8,9 @@ namespace YesChef.Api.Auth;
public class JwtTokenService(IConfiguration config)
{
public string GenerateToken(User user)
public const string FamilyIdClaim = "family_id";
public string GenerateToken(User user, int familyId)
{
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Secret"]!));
var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);
@@ -16,7 +18,8 @@ public class JwtTokenService(IConfiguration config)
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()),
new Claim(ClaimTypes.Name, user.Name)
new Claim(ClaimTypes.Name, user.Name),
new Claim(FamilyIdClaim, familyId.ToString()),
};
var token = new JwtSecurityToken(
+18 -1
View File
@@ -6,6 +6,7 @@ namespace YesChef.Api.Data;
public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
{
public DbSet<Family> Families => Set<Family>();
public DbSet<FamilyMembership> FamilyMemberships => Set<FamilyMembership>();
public DbSet<User> Users => Set<User>();
public DbSet<Store> Stores => Set<Store>();
public DbSet<ShoppingList> ShoppingLists => Set<ShoppingList>();
@@ -22,6 +23,14 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.Property(f => f.InviteCode).HasMaxLength(100);
});
modelBuilder.Entity<FamilyMembership>(e =>
{
e.HasKey(m => new { m.UserId, m.FamilyId });
e.HasOne(m => m.User).WithMany().HasForeignKey(m => m.UserId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(m => m.Family).WithMany().HasForeignKey(m => m.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.Property(m => m.Role).HasConversion<int>();
});
modelBuilder.Entity<User>(e =>
{
e.HasIndex(u => u.Name).IsUnique();
@@ -30,36 +39,44 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
modelBuilder.Entity<Store>(e =>
{
e.HasIndex(s => s.Name).IsUnique();
e.HasOne(s => s.Family).WithMany().HasForeignKey(s => s.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(s => new { s.FamilyId, s.Name }).IsUnique();
e.Property(s => s.Name).HasMaxLength(100);
});
modelBuilder.Entity<ShoppingList>(e =>
{
e.Property(l => l.Name).HasMaxLength(200);
e.HasOne(l => l.Family).WithMany().HasForeignKey(l => l.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(l => l.Store).WithMany().HasForeignKey(l => l.StoreId);
e.HasOne(l => l.CreatedByUser).WithMany().HasForeignKey(l => l.CreatedByUserId);
e.HasMany(l => l.Items).WithOne(i => i.ShoppingList).HasForeignKey(i => i.ShoppingListId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(l => l.FamilyId);
});
modelBuilder.Entity<ShoppingListItem>(e =>
{
e.Property(i => i.Name).HasMaxLength(300);
e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(i => i.CheckedByUser).WithMany().HasForeignKey(i => i.CheckedByUserId).OnDelete(DeleteBehavior.SetNull);
e.HasOne(i => i.Recipe).WithMany().HasForeignKey(i => i.RecipeId).OnDelete(DeleteBehavior.SetNull);
e.HasIndex(i => i.FamilyId);
});
modelBuilder.Entity<Recipe>(e =>
{
e.Property(r => r.Title).HasMaxLength(300);
e.HasOne(r => r.Family).WithMany().HasForeignKey(r => r.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(r => r.CreatedByUser).WithMany().HasForeignKey(r => r.CreatedByUserId);
e.HasMany(r => r.Ingredients).WithOne(i => i.Recipe).HasForeignKey(i => i.RecipeId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(r => r.FamilyId);
});
modelBuilder.Entity<RecipeIngredient>(e =>
{
e.Property(i => i.Name).HasMaxLength(200);
e.Property(i => i.Quantity).HasMaxLength(50);
e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade);
});
}
}
@@ -0,0 +1,11 @@
namespace YesChef.Api.Entities;
public class FamilyMembership
{
public int UserId { get; set; }
public User User { get; set; } = null!;
public int FamilyId { get; set; }
public Family Family { get; set; } = null!;
public FamilyRole Role { get; set; } = FamilyRole.Member;
public DateTime JoinedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,7 @@
namespace YesChef.Api.Entities;
public enum FamilyRole
{
Member = 0,
Admin = 1,
}
@@ -3,6 +3,8 @@ namespace YesChef.Api.Entities;
public class Recipe
{
public int Id { get; set; }
public int FamilyId { get; set; }
public Family Family { get; set; } = null!;
public required string Title { get; set; }
public string? Description { get; set; }
public string? Instructions { get; set; }
@@ -3,6 +3,8 @@ namespace YesChef.Api.Entities;
public class RecipeIngredient
{
public int Id { get; set; }
public int FamilyId { get; set; }
public Family Family { get; set; } = null!;
public int RecipeId { get; set; }
public Recipe Recipe { get; set; } = null!;
public required string Name { get; set; }
@@ -3,6 +3,8 @@ namespace YesChef.Api.Entities;
public class ShoppingList
{
public int Id { get; set; }
public int FamilyId { get; set; }
public Family Family { get; set; } = null!;
public required string Name { get; set; }
public int StoreId { get; set; }
public Store Store { get; set; } = null!;
@@ -3,6 +3,8 @@ namespace YesChef.Api.Entities;
public class ShoppingListItem
{
public int Id { get; set; }
public int FamilyId { get; set; }
public Family Family { get; set; } = null!;
public int ShoppingListId { get; set; }
public ShoppingList ShoppingList { get; set; } = null!;
public required string Name { get; set; }
@@ -3,6 +3,8 @@ namespace YesChef.Api.Entities;
public class Store
{
public int Id { get; set; }
public int FamilyId { get; set; }
public Family Family { get; set; } = null!;
public required string Name { get; set; }
public int SortOrder { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -13,9 +13,10 @@ public static class RecipeEndpoints
public static RouteGroupBuilder MapRecipeEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/", async (YesChefDb db, string? q) =>
group.MapGet("/", async (YesChefDb db, HttpContext http, string? q) =>
{
var query = db.Recipes.AsQueryable();
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));
@@ -34,8 +35,10 @@ public static class RecipeEndpoints
group.MapPost("/", async (CreateRecipeRequest request, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var recipe = new Recipe
{
FamilyId = familyId,
Title = request.Title,
Description = request.Description,
Instructions = request.Instructions,
@@ -44,6 +47,7 @@ public static class RecipeEndpoints
CreatedByUserId = http.User.GetUserId(),
Ingredients = request.Ingredients.Select(i => new RecipeIngredient
{
FamilyId = familyId,
Name = i.Name,
Quantity = i.Quantity,
SortOrder = i.SortOrder
@@ -55,12 +59,14 @@ public static class RecipeEndpoints
return Results.Created($"/api/recipes/{recipe.Id}", new { recipe.Id, recipe.Title });
});
group.MapGet("/{id:int}", async (int id, YesChefDb db) =>
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(r => r.Id == id);
.FirstOrDefaultAsync();
if (recipe is null) return Results.NotFound();
@@ -80,7 +86,11 @@ public static class RecipeEndpoints
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);
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();
recipe.Title = request.Title;
@@ -93,6 +103,7 @@ public static class RecipeEndpoints
db.RecipeIngredients.RemoveRange(recipe.Ingredients);
recipe.Ingredients = request.Ingredients.Select(i => new RecipeIngredient
{
FamilyId = familyId,
Name = i.Name,
Quantity = i.Quantity,
SortOrder = i.SortOrder
@@ -102,9 +113,10 @@ public static class RecipeEndpoints
return Results.Ok(new { recipe.Id, recipe.Title });
});
group.MapDelete("/{id:int}", async (int id, YesChefDb db) =>
group.MapDelete("/{id:int}", async (int id, YesChefDb db, HttpContext http) =>
{
var recipe = await db.Recipes.FindAsync(id);
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);
@@ -12,10 +12,12 @@ public static class ShoppingListEndpoints
public record UpdateListRequest(string Name, int StoreId);
public record AddItemRequest(string Name, int SortOrder = 0);
private static async Task BroadcastListSummary(IHubContext<ShoppingListHub> hub, YesChefDb db, int listId)
private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}";
private static async Task BroadcastListSummary(IHubContext<ShoppingListHub> hub, YesChefDb db, int listId, int familyId)
{
var summary = await db.ShoppingLists
.Where(l => l.Id == listId)
.Where(l => l.Id == listId && l.FamilyId == familyId)
.Select(l => new
{
l.Id,
@@ -27,15 +29,16 @@ public static class ShoppingListEndpoints
})
.FirstAsync();
await hub.Clients.Group("lists-overview").SendAsync("ListSummaryUpdated", summary);
await hub.Clients.Group(OverviewGroup(familyId)).SendAsync("ListSummaryUpdated", summary);
}
public static RouteGroupBuilder MapShoppingListEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/", async (YesChefDb db, int? storeId) =>
group.MapGet("/", async (YesChefDb db, HttpContext http, int? storeId) =>
{
var familyId = http.User.GetFamilyId();
var query = db.ShoppingLists
.Where(l => !l.IsArchived)
.Where(l => l.FamilyId == familyId && !l.IsArchived)
.Include(l => l.Store)
.Include(l => l.Items)
.AsQueryable();
@@ -58,8 +61,15 @@ public static class ShoppingListEndpoints
group.MapPost("/", async (CreateListRequest request, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
{
var familyId = http.User.GetFamilyId();
// Reject store IDs that don't belong to the caller's family.
var store = await db.Stores.FirstOrDefaultAsync(s => s.Id == request.StoreId && s.FamilyId == familyId);
if (store is null) return Results.BadRequest(new { error = "Unknown store." });
var list = new ShoppingList
{
FamilyId = familyId,
Name = request.Name,
StoreId = request.StoreId,
CreatedByUserId = http.User.GetUserId()
@@ -67,12 +77,11 @@ public static class ShoppingListEndpoints
db.ShoppingLists.Add(list);
await db.SaveChangesAsync();
var store = await db.Stores.FindAsync(list.StoreId);
await hub.Clients.Group("lists-overview").SendAsync("ListCreated", new
await hub.Clients.Group(OverviewGroup(familyId)).SendAsync("ListCreated", new
{
list.Id,
list.Name,
Store = new { store!.Id, store.Name },
Store = new { store.Id, store.Name },
ItemCount = 0,
CheckedCount = 0,
list.UpdatedAt
@@ -81,15 +90,17 @@ public static class ShoppingListEndpoints
return Results.Created($"/api/lists/{list.Id}", new { list.Id, list.Name, list.StoreId });
});
group.MapGet("/{id:int}", async (int id, YesChefDb db) =>
group.MapGet("/{id:int}", async (int id, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var list = await db.ShoppingLists
.Where(l => l.Id == id && l.FamilyId == familyId)
.Include(l => l.Store)
.Include(l => l.Items.OrderBy(i => i.SortOrder))
.ThenInclude(i => i.CheckedByUser)
.Include(l => l.Items)
.ThenInclude(i => i.Recipe)
.FirstOrDefaultAsync(l => l.Id == id);
.FirstOrDefaultAsync();
if (list is null) return Results.NotFound();
@@ -112,11 +123,17 @@ public static class ShoppingListEndpoints
});
});
group.MapPut("/{id:int}", async (int id, UpdateListRequest request, YesChefDb db, IHubContext<ShoppingListHub> hub) =>
group.MapPut("/{id:int}", async (int id, UpdateListRequest request, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
{
var list = await db.ShoppingLists.FindAsync(id);
var familyId = http.User.GetFamilyId();
var list = await db.ShoppingLists.FirstOrDefaultAsync(l => l.Id == id && l.FamilyId == familyId);
if (list is null) return Results.NotFound();
// Reject store IDs from another family.
if (request.StoreId != list.StoreId &&
!await db.Stores.AnyAsync(s => s.Id == request.StoreId && s.FamilyId == familyId))
return Results.BadRequest(new { error = "Unknown store." });
list.Name = request.Name;
list.StoreId = request.StoreId;
list.UpdatedAt = DateTime.UtcNow;
@@ -126,45 +143,48 @@ public static class ShoppingListEndpoints
return Results.Ok();
});
group.MapDelete("/{id:int}", async (int id, YesChefDb db, IHubContext<ShoppingListHub> hub) =>
group.MapDelete("/{id:int}", async (int id, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
{
var list = await db.ShoppingLists.FindAsync(id);
var familyId = http.User.GetFamilyId();
var list = await db.ShoppingLists.FirstOrDefaultAsync(l => l.Id == id && l.FamilyId == familyId);
if (list is null) return Results.NotFound();
list.IsArchived = true;
list.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await hub.Clients.Group("lists-overview").SendAsync("ListArchived", new { list.Id });
await hub.Clients.Group(OverviewGroup(familyId)).SendAsync("ListArchived", new { list.Id });
return Results.NoContent();
});
group.MapPost("/{listId:int}/items", async (int listId, AddItemRequest request, YesChefDb db, IHubContext<ShoppingListHub> hub) =>
group.MapPost("/{listId:int}/items", async (int listId, AddItemRequest request, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
{
if (!await db.ShoppingLists.AnyAsync(l => l.Id == listId)) return Results.NotFound();
var familyId = http.User.GetFamilyId();
var list = await db.ShoppingLists.FirstOrDefaultAsync(l => l.Id == listId && l.FamilyId == familyId);
if (list is null) return Results.NotFound();
var item = new ShoppingListItem
{
FamilyId = familyId,
ShoppingListId = listId,
Name = request.Name,
SortOrder = request.SortOrder
};
db.ShoppingListItems.Add(item);
var list = await db.ShoppingLists.FindAsync(listId);
list!.UpdatedAt = DateTime.UtcNow;
list.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder });
await BroadcastListSummary(hub, db, listId);
await BroadcastListSummary(hub, db, listId, familyId);
return Results.Created($"/api/lists/{listId}/items/{item.Id}", new { item.Id, item.Name, item.SortOrder });
});
group.MapPatch("/{listId:int}/items/{itemId:int}/check", async (int listId, int itemId, YesChefDb db, IHubContext<ShoppingListHub> hub, HttpContext http) =>
{
var item = await db.ShoppingListItems.FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId);
var familyId = http.User.GetFamilyId();
var item = await db.ShoppingListItems
.FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId && i.FamilyId == familyId);
if (item is null) return Results.NotFound();
var userId = http.User.GetUserId();
@@ -177,35 +197,42 @@ public static class ShoppingListEndpoints
checkedByName = await db.Users.Where(u => u.Id == userId).Select(u => u.Name).FirstAsync();
await hub.Clients.Group($"list-{listId}").SendAsync("ItemChecked", new { item.Id, item.IsChecked, CheckedByUserName = checkedByName });
await BroadcastListSummary(hub, db, listId);
await BroadcastListSummary(hub, db, listId, familyId);
return Results.Ok();
});
group.MapDelete("/{listId:int}/items/{itemId:int}", async (int listId, int itemId, YesChefDb db, IHubContext<ShoppingListHub> hub) =>
group.MapDelete("/{listId:int}/items/{itemId:int}", async (int listId, int itemId, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
{
var item = await db.ShoppingListItems.FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId);
var familyId = http.User.GetFamilyId();
var item = await db.ShoppingListItems
.FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId && i.FamilyId == familyId);
if (item is null) return Results.NotFound();
db.ShoppingListItems.Remove(item);
await db.SaveChangesAsync();
await hub.Clients.Group($"list-{listId}").SendAsync("ItemRemoved", new { item.Id });
await BroadcastListSummary(hub, db, listId);
await BroadcastListSummary(hub, db, listId, familyId);
return Results.NoContent();
});
group.MapPost("/{listId:int}/add-recipe/{recipeId:int}", async (int listId, int recipeId, YesChefDb db, IHubContext<ShoppingListHub> hub) =>
group.MapPost("/{listId:int}/add-recipe/{recipeId:int}", async (int listId, int recipeId, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
{
var list = await db.ShoppingLists.FindAsync(listId);
var familyId = http.User.GetFamilyId();
var list = await db.ShoppingLists.FirstOrDefaultAsync(l => l.Id == listId && l.FamilyId == familyId);
if (list is null) return Results.NotFound();
var recipe = await db.Recipes.Include(r => r.Ingredients).FirstOrDefaultAsync(r => r.Id == recipeId);
var recipe = await db.Recipes
.Where(r => r.Id == recipeId && r.FamilyId == familyId)
.Include(r => r.Ingredients)
.FirstOrDefaultAsync();
if (recipe is null) return Results.NotFound();
var maxSort = await db.ShoppingListItems.Where(i => i.ShoppingListId == listId).MaxAsync(i => (int?)i.SortOrder) ?? 0;
var newItems = recipe.Ingredients.Select((ing, idx) => new ShoppingListItem
{
FamilyId = familyId,
ShoppingListId = listId,
Name = string.IsNullOrEmpty(ing.Quantity) ? ing.Name : $"{ing.Quantity} {ing.Name}",
SortOrder = maxSort + idx + 1,
@@ -221,7 +248,7 @@ public static class ShoppingListEndpoints
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, RecipeTitle = recipe.Title });
}
await BroadcastListSummary(hub, db, listId);
await BroadcastListSummary(hub, db, listId, familyId);
return Results.Ok(new { added = newItems.Count });
});
@@ -1,20 +1,34 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using YesChef.Api.Auth;
using YesChef.Api.Data;
namespace YesChef.Api.Features.ShoppingLists;
[Authorize]
public class ShoppingListHub : Hub
public class ShoppingListHub(YesChefDb db) : Hub
{
public async Task JoinList(int listId) =>
public async Task JoinList(int listId)
{
var familyId = Context.User!.GetFamilyId();
var owns = await db.ShoppingLists.AnyAsync(l => l.Id == listId && l.FamilyId == familyId);
if (!owns) throw new HubException("List not found.");
await Groups.AddToGroupAsync(Context.ConnectionId, $"list-{listId}");
}
public async Task LeaveList(int listId) =>
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"list-{listId}");
public async Task JoinListsOverview() =>
await Groups.AddToGroupAsync(Context.ConnectionId, "lists-overview");
public async Task JoinListsOverview()
{
var familyId = Context.User!.GetFamilyId();
await Groups.AddToGroupAsync(Context.ConnectionId, $"lists-overview-{familyId}");
}
public async Task LeaveListsOverview() =>
await Groups.RemoveFromGroupAsync(Context.ConnectionId, "lists-overview");
public async Task LeaveListsOverview()
{
var familyId = Context.User!.GetFamilyId();
await Groups.RemoveFromGroupAsync(Context.ConnectionId, $"lists-overview-{familyId}");
}
}
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using YesChef.Api.Auth;
using YesChef.Api.Data;
using YesChef.Api.Entities;
@@ -11,20 +12,32 @@ public static class StoreEndpoints
public static RouteGroupBuilder MapStoreEndpoints(this RouteGroupBuilder group)
{
group.MapGet("/", async (YesChefDb db) =>
await db.Stores.OrderBy(s => s.SortOrder).ThenBy(s => s.Name).ToListAsync());
group.MapPost("/", async (CreateStoreRequest request, YesChefDb db) =>
group.MapGet("/", async (YesChefDb db, HttpContext http) =>
{
var store = new Store { Name = request.Name, SortOrder = request.SortOrder };
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) =>
group.MapPut("/{id:int}", async (int id, UpdateStoreRequest request, YesChefDb db, HttpContext http) =>
{
var store = await db.Stores.FindAsync(id);
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;
@@ -33,14 +46,15 @@ public static class StoreEndpoints
return Results.Ok(store);
});
group.MapDelete("/{id:int}", async (int id, YesChefDb db) =>
group.MapDelete("/{id:int}", async (int id, YesChefDb db, HttpContext http) =>
{
var hasLists = await db.ShoppingLists.AnyAsync(l => l.StoreId == id);
if (hasLists) return Results.BadRequest(new { error = "Store has shopping lists. Remove them first." });
var store = await db.Stores.FindAsync(id);
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();
@@ -0,0 +1,446 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using YesChef.Api.Data;
#nullable disable
namespace YesChef.Api.Migrations
{
[DbContext(typeof(YesChefDb))]
[Migration("20260508034856_AddFamilyScoping")]
partial class AddFamilyScoping
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("YesChef.Api.Entities.Family", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("InviteCode")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.HasKey("Id");
b.HasIndex("InviteCode")
.IsUnique();
b.ToTable("Families");
});
modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b =>
{
b.Property<int>("UserId")
.HasColumnType("integer");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Role")
.HasColumnType("integer");
b.HasKey("UserId", "FamilyId");
b.HasIndex("FamilyId");
b.ToTable("FamilyMemberships");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Instructions")
.HasColumnType("text");
b.Property<int?>("Servings")
.HasColumnType("integer");
b.Property<string>("SourceUrl")
.HasColumnType("text");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CreatedByUserId");
b.HasIndex("FamilyId");
b.ToTable("Recipes");
});
modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("Quantity")
.HasMaxLength(50)
.HasColumnType("character varying(50)");
b.Property<int>("RecipeId")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId");
b.HasIndex("RecipeId");
b.ToTable("RecipeIngredients");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<bool>("IsArchived")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<int>("StoreId")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("CreatedByUserId");
b.HasIndex("FamilyId");
b.HasIndex("StoreId");
b.ToTable("ShoppingLists");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int?>("CheckedByUserId")
.HasColumnType("integer");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<bool>("IsChecked")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<int?>("RecipeId")
.HasColumnType("integer");
b.Property<int>("ShoppingListId")
.HasColumnType("integer");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("CheckedByUserId");
b.HasIndex("FamilyId");
b.HasIndex("RecipeId");
b.HasIndex("ShoppingListId");
b.ToTable("ShoppingListItems");
});
modelBuilder.Entity("YesChef.Api.Entities.Store", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("FamilyId", "Name")
.IsUnique();
b.ToTable("Stores");
});
modelBuilder.Entity("YesChef.Api.Entities.User", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b =>
{
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Family");
b.Navigation("User");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.HasOne("YesChef.Api.Entities.User", "CreatedByUser")
.WithMany()
.HasForeignKey("CreatedByUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CreatedByUser");
b.Navigation("Family");
});
modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b =>
{
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Recipe", "Recipe")
.WithMany("Ingredients")
.HasForeignKey("RecipeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Family");
b.Navigation("Recipe");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b =>
{
b.HasOne("YesChef.Api.Entities.User", "CreatedByUser")
.WithMany()
.HasForeignKey("CreatedByUserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Store", "Store")
.WithMany()
.HasForeignKey("StoreId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CreatedByUser");
b.Navigation("Family");
b.Navigation("Store");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b =>
{
b.HasOne("YesChef.Api.Entities.User", "CheckedByUser")
.WithMany()
.HasForeignKey("CheckedByUserId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Recipe", "Recipe")
.WithMany()
.HasForeignKey("RecipeId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList")
.WithMany("Items")
.HasForeignKey("ShoppingListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CheckedByUser");
b.Navigation("Family");
b.Navigation("Recipe");
b.Navigation("ShoppingList");
});
modelBuilder.Entity("YesChef.Api.Entities.Store", b =>
{
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Family");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.Navigation("Ingredients");
});
modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b =>
{
b.Navigation("Items");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,224 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace YesChef.Api.Migrations
{
/// <inheritdoc />
public partial class AddFamilyScoping : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Stores_Name",
table: "Stores");
migrationBuilder.AddColumn<int>(
name: "FamilyId",
table: "Stores",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "FamilyId",
table: "ShoppingLists",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "FamilyId",
table: "ShoppingListItems",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "FamilyId",
table: "Recipes",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "FamilyId",
table: "RecipeIngredients",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.CreateTable(
name: "FamilyMemberships",
columns: table => new
{
UserId = table.Column<int>(type: "integer", nullable: false),
FamilyId = table.Column<int>(type: "integer", nullable: false),
Role = table.Column<int>(type: "integer", nullable: false),
JoinedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_FamilyMemberships", x => new { x.UserId, x.FamilyId });
table.ForeignKey(
name: "FK_FamilyMemberships_Families_FamilyId",
column: x => x.FamilyId,
principalTable: "Families",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_FamilyMemberships_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Stores_FamilyId_Name",
table: "Stores",
columns: new[] { "FamilyId", "Name" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ShoppingLists_FamilyId",
table: "ShoppingLists",
column: "FamilyId");
migrationBuilder.CreateIndex(
name: "IX_ShoppingListItems_FamilyId",
table: "ShoppingListItems",
column: "FamilyId");
migrationBuilder.CreateIndex(
name: "IX_Recipes_FamilyId",
table: "Recipes",
column: "FamilyId");
migrationBuilder.CreateIndex(
name: "IX_RecipeIngredients_FamilyId",
table: "RecipeIngredients",
column: "FamilyId");
migrationBuilder.CreateIndex(
name: "IX_FamilyMemberships_FamilyId",
table: "FamilyMemberships",
column: "FamilyId");
migrationBuilder.AddForeignKey(
name: "FK_RecipeIngredients_Families_FamilyId",
table: "RecipeIngredients",
column: "FamilyId",
principalTable: "Families",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Recipes_Families_FamilyId",
table: "Recipes",
column: "FamilyId",
principalTable: "Families",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_ShoppingListItems_Families_FamilyId",
table: "ShoppingListItems",
column: "FamilyId",
principalTable: "Families",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_ShoppingLists_Families_FamilyId",
table: "ShoppingLists",
column: "FamilyId",
principalTable: "Families",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Stores_Families_FamilyId",
table: "Stores",
column: "FamilyId",
principalTable: "Families",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_RecipeIngredients_Families_FamilyId",
table: "RecipeIngredients");
migrationBuilder.DropForeignKey(
name: "FK_Recipes_Families_FamilyId",
table: "Recipes");
migrationBuilder.DropForeignKey(
name: "FK_ShoppingListItems_Families_FamilyId",
table: "ShoppingListItems");
migrationBuilder.DropForeignKey(
name: "FK_ShoppingLists_Families_FamilyId",
table: "ShoppingLists");
migrationBuilder.DropForeignKey(
name: "FK_Stores_Families_FamilyId",
table: "Stores");
migrationBuilder.DropTable(
name: "FamilyMemberships");
migrationBuilder.DropIndex(
name: "IX_Stores_FamilyId_Name",
table: "Stores");
migrationBuilder.DropIndex(
name: "IX_ShoppingLists_FamilyId",
table: "ShoppingLists");
migrationBuilder.DropIndex(
name: "IX_ShoppingListItems_FamilyId",
table: "ShoppingListItems");
migrationBuilder.DropIndex(
name: "IX_Recipes_FamilyId",
table: "Recipes");
migrationBuilder.DropIndex(
name: "IX_RecipeIngredients_FamilyId",
table: "RecipeIngredients");
migrationBuilder.DropColumn(
name: "FamilyId",
table: "Stores");
migrationBuilder.DropColumn(
name: "FamilyId",
table: "ShoppingLists");
migrationBuilder.DropColumn(
name: "FamilyId",
table: "ShoppingListItems");
migrationBuilder.DropColumn(
name: "FamilyId",
table: "Recipes");
migrationBuilder.DropColumn(
name: "FamilyId",
table: "RecipeIngredients");
migrationBuilder.CreateIndex(
name: "IX_Stores_Name",
table: "Stores",
column: "Name",
unique: true);
}
}
}
@@ -51,6 +51,27 @@ namespace YesChef.Api.Migrations
b.ToTable("Families");
});
modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b =>
{
b.Property<int>("UserId")
.HasColumnType("integer");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("Role")
.HasColumnType("integer");
b.HasKey("UserId", "FamilyId");
b.HasIndex("FamilyId");
b.ToTable("FamilyMemberships");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.Property<int>("Id")
@@ -68,6 +89,9 @@ namespace YesChef.Api.Migrations
b.Property<string>("Description")
.HasColumnType("text");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Instructions")
.HasColumnType("text");
@@ -89,6 +113,8 @@ namespace YesChef.Api.Migrations
b.HasIndex("CreatedByUserId");
b.HasIndex("FamilyId");
b.ToTable("Recipes");
});
@@ -100,6 +126,9 @@ namespace YesChef.Api.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@@ -117,6 +146,8 @@ namespace YesChef.Api.Migrations
b.HasKey("Id");
b.HasIndex("FamilyId");
b.HasIndex("RecipeId");
b.ToTable("RecipeIngredients");
@@ -136,6 +167,9 @@ namespace YesChef.Api.Migrations
b.Property<int>("CreatedByUserId")
.HasColumnType("integer");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<bool>("IsArchived")
.HasColumnType("boolean");
@@ -154,6 +188,8 @@ namespace YesChef.Api.Migrations
b.HasIndex("CreatedByUserId");
b.HasIndex("FamilyId");
b.HasIndex("StoreId");
b.ToTable("ShoppingLists");
@@ -173,6 +209,9 @@ namespace YesChef.Api.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<bool>("IsChecked")
.HasColumnType("boolean");
@@ -194,6 +233,8 @@ namespace YesChef.Api.Migrations
b.HasIndex("CheckedByUserId");
b.HasIndex("FamilyId");
b.HasIndex("RecipeId");
b.HasIndex("ShoppingListId");
@@ -212,6 +253,9 @@ namespace YesChef.Api.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("FamilyId")
.HasColumnType("integer");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
@@ -222,7 +266,7 @@ namespace YesChef.Api.Migrations
b.HasKey("Id");
b.HasIndex("Name")
b.HasIndex("FamilyId", "Name")
.IsUnique();
b.ToTable("Stores");
@@ -256,6 +300,25 @@ namespace YesChef.Api.Migrations
b.ToTable("Users");
});
modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b =>
{
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Family");
b.Navigation("User");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.HasOne("YesChef.Api.Entities.User", "CreatedByUser")
@@ -264,17 +327,33 @@ namespace YesChef.Api.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("CreatedByUser");
b.Navigation("Family");
});
modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b =>
{
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Recipe", "Recipe")
.WithMany("Ingredients")
.HasForeignKey("RecipeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Family");
b.Navigation("Recipe");
});
@@ -286,6 +365,12 @@ namespace YesChef.Api.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Store", "Store")
.WithMany()
.HasForeignKey("StoreId")
@@ -294,6 +379,8 @@ namespace YesChef.Api.Migrations
b.Navigation("CreatedByUser");
b.Navigation("Family");
b.Navigation("Store");
});
@@ -304,6 +391,12 @@ namespace YesChef.Api.Migrations
.HasForeignKey("CheckedByUserId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("YesChef.Api.Entities.Recipe", "Recipe")
.WithMany()
.HasForeignKey("RecipeId")
@@ -317,11 +410,24 @@ namespace YesChef.Api.Migrations
b.Navigation("CheckedByUser");
b.Navigation("Family");
b.Navigation("Recipe");
b.Navigation("ShoppingList");
});
modelBuilder.Entity("YesChef.Api.Entities.Store", b =>
{
b.HasOne("YesChef.Api.Entities.Family", "Family")
.WithMany()
.HasForeignKey("FamilyId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Family");
});
modelBuilder.Entity("YesChef.Api.Entities.Recipe", b =>
{
b.Navigation("Ingredients");
+22 -1
View File
@@ -1,3 +1,4 @@
using System.Security.Claims;
using System.Text;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.EntityFrameworkCore;
@@ -37,7 +38,27 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
if (!string.IsNullOrEmpty(accessToken) && context.HttpContext.Request.Path.StartsWithSegments("/hubs"))
context.Token = accessToken;
return Task.CompletedTask;
}
},
OnTokenValidated = async context =>
{
// Reject tokens whose user is no longer a member of the claimed family.
// Catches admin-initiated removal between login and the next request.
var principal = context.Principal;
if (principal is null) { context.Fail("Missing principal."); return; }
var userIdClaim = principal.FindFirstValue(ClaimTypes.NameIdentifier);
var familyIdClaim = principal.FindFirstValue(JwtTokenService.FamilyIdClaim);
if (!int.TryParse(userIdClaim, out var userId) || !int.TryParse(familyIdClaim, out var familyId))
{
context.Fail("Invalid user/family claim.");
return;
}
var db = context.HttpContext.RequestServices.GetRequiredService<YesChefDb>();
var stillMember = await db.FamilyMemberships
.AnyAsync(m => m.UserId == userId && m.FamilyId == familyId);
if (!stillMember) context.Fail("Family membership revoked.");
},
};
});