diff --git a/src/backend/YesChef.Api.IntegrationTests/Builders/BuilderDefaults.cs b/src/backend/YesChef.Api.IntegrationTests/Builders/BuilderDefaults.cs new file mode 100644 index 0000000..67564a9 --- /dev/null +++ b/src/backend/YesChef.Api.IntegrationTests/Builders/BuilderDefaults.cs @@ -0,0 +1,13 @@ +using YesChef.Api.Data; + +namespace YesChef.Api.IntegrationTests.Builders; + +internal static class BuilderDefaults +{ + /// + /// When a builder doesn't specify a family, default to the bootstrap + /// family seeded by Program.cs. Test DBs always have at least one. + /// + public static async Task DefaultFamilyIdAsync(YesChefDb db) => + await db.Families.OrderBy(f => f.Id).Select(f => f.Id).FirstAsync(); +} diff --git a/src/backend/YesChef.Api.IntegrationTests/Builders/RecipeBuilder.cs b/src/backend/YesChef.Api.IntegrationTests/Builders/RecipeBuilder.cs index 2fb5e92..3b779d7 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Builders/RecipeBuilder.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Builders/RecipeBuilder.cs @@ -11,6 +11,7 @@ public sealed class RecipeBuilder private int? _servings; private string? _sourceUrl; private int? _createdByUserId; + private int? _familyId; private readonly List _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 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 PersistAsync(YesChefDb db) - { - var recipe = Build(); db.Recipes.Add(recipe); await db.SaveChangesAsync(); return recipe; diff --git a/src/backend/YesChef.Api.IntegrationTests/Builders/ShoppingListBuilder.cs b/src/backend/YesChef.Api.IntegrationTests/Builders/ShoppingListBuilder.cs index 4d56982..b4ed3ce 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Builders/ShoppingListBuilder.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Builders/ShoppingListBuilder.cs @@ -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 _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 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 PersistAsync(YesChefDb db) - { - var list = Build(); db.ShoppingLists.Add(list); await db.SaveChangesAsync(); return list; diff --git a/src/backend/YesChef.Api.IntegrationTests/Builders/StoreBuilder.cs b/src/backend/YesChef.Api.IntegrationTests/Builders/StoreBuilder.cs index 9d03903..12eb304 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Builders/StoreBuilder.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Builders/StoreBuilder.cs @@ -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 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; diff --git a/src/backend/YesChef.Api.UnitTests/Auth/ClaimsPrincipalExtensionsTests.cs b/src/backend/YesChef.Api.UnitTests/Auth/ClaimsPrincipalExtensionsTests.cs index 0383619..dd5d6c9 100644 --- a/src/backend/YesChef.Api.UnitTests/Auth/ClaimsPrincipalExtensionsTests.cs +++ b/src/backend/YesChef.Api.UnitTests/Auth/ClaimsPrincipalExtensionsTests.cs @@ -28,4 +28,18 @@ public class ClaimsPrincipalExtensionsTests var principal = Build(); await Assert.That(() => principal.GetUserId()).Throws(); } + + [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(); + } } diff --git a/src/backend/YesChef.Api.UnitTests/Auth/JwtTokenServiceTests.cs b/src/backend/YesChef.Api.UnitTests/Auth/JwtTokenServiceTests.cs index fb018b7..bc64452 100644 --- a/src/backend/YesChef.Api.UnitTests/Auth/JwtTokenServiceTests.cs +++ b/src/backend/YesChef.Api.UnitTests/Auth/JwtTokenServiceTests.cs @@ -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 diff --git a/src/backend/YesChef.Api/Auth/AuthEndpoints.cs b/src/backend/YesChef.Api/Auth/AuthEndpoints.cs index 30ddde9..98a099d 100644 --- a/src/backend/YesChef.Api/Auth/AuthEndpoints.cs +++ b/src/backend/YesChef.Api/Auth/AuthEndpoints.cs @@ -15,10 +15,10 @@ public static class AuthEndpoints { var hasher = new PasswordHasher(); - 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; diff --git a/src/backend/YesChef.Api/Auth/ClaimsPrincipalExtensions.cs b/src/backend/YesChef.Api/Auth/ClaimsPrincipalExtensions.cs index 0fbfdf7..ee7f96a 100644 --- a/src/backend/YesChef.Api/Auth/ClaimsPrincipalExtensions.cs +++ b/src/backend/YesChef.Api/Auth/ClaimsPrincipalExtensions.cs @@ -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)!); } diff --git a/src/backend/YesChef.Api/Auth/JwtTokenService.cs b/src/backend/YesChef.Api/Auth/JwtTokenService.cs index 17b0f32..c8693ff 100644 --- a/src/backend/YesChef.Api/Auth/JwtTokenService.cs +++ b/src/backend/YesChef.Api/Auth/JwtTokenService.cs @@ -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( diff --git a/src/backend/YesChef.Api/Data/YesChefDb.cs b/src/backend/YesChef.Api/Data/YesChefDb.cs index b6cdf4c..438a176 100644 --- a/src/backend/YesChef.Api/Data/YesChefDb.cs +++ b/src/backend/YesChef.Api/Data/YesChefDb.cs @@ -6,6 +6,7 @@ namespace YesChef.Api.Data; public class YesChefDb(DbContextOptions options) : DbContext(options) { public DbSet Families => Set(); + public DbSet FamilyMemberships => Set(); public DbSet Users => Set(); public DbSet Stores => Set(); public DbSet ShoppingLists => Set(); @@ -22,6 +23,14 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) e.Property(f => f.InviteCode).HasMaxLength(100); }); + modelBuilder.Entity(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(); + }); + modelBuilder.Entity(e => { e.HasIndex(u => u.Name).IsUnique(); @@ -30,36 +39,44 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) modelBuilder.Entity(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(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(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(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(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); }); } } diff --git a/src/backend/YesChef.Api/Entities/FamilyMembership.cs b/src/backend/YesChef.Api/Entities/FamilyMembership.cs new file mode 100644 index 0000000..4141c35 --- /dev/null +++ b/src/backend/YesChef.Api/Entities/FamilyMembership.cs @@ -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; +} diff --git a/src/backend/YesChef.Api/Entities/FamilyRole.cs b/src/backend/YesChef.Api/Entities/FamilyRole.cs new file mode 100644 index 0000000..90e9ac0 --- /dev/null +++ b/src/backend/YesChef.Api/Entities/FamilyRole.cs @@ -0,0 +1,7 @@ +namespace YesChef.Api.Entities; + +public enum FamilyRole +{ + Member = 0, + Admin = 1, +} diff --git a/src/backend/YesChef.Api/Entities/Recipe.cs b/src/backend/YesChef.Api/Entities/Recipe.cs index 03091a9..ec26fff 100644 --- a/src/backend/YesChef.Api/Entities/Recipe.cs +++ b/src/backend/YesChef.Api/Entities/Recipe.cs @@ -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; } diff --git a/src/backend/YesChef.Api/Entities/RecipeIngredient.cs b/src/backend/YesChef.Api/Entities/RecipeIngredient.cs index b523bd9..379724e 100644 --- a/src/backend/YesChef.Api/Entities/RecipeIngredient.cs +++ b/src/backend/YesChef.Api/Entities/RecipeIngredient.cs @@ -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; } diff --git a/src/backend/YesChef.Api/Entities/ShoppingList.cs b/src/backend/YesChef.Api/Entities/ShoppingList.cs index cbe9242..7219c62 100644 --- a/src/backend/YesChef.Api/Entities/ShoppingList.cs +++ b/src/backend/YesChef.Api/Entities/ShoppingList.cs @@ -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!; diff --git a/src/backend/YesChef.Api/Entities/ShoppingListItem.cs b/src/backend/YesChef.Api/Entities/ShoppingListItem.cs index bd0e69a..a6f6e01 100644 --- a/src/backend/YesChef.Api/Entities/ShoppingListItem.cs +++ b/src/backend/YesChef.Api/Entities/ShoppingListItem.cs @@ -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; } diff --git a/src/backend/YesChef.Api/Entities/Store.cs b/src/backend/YesChef.Api/Entities/Store.cs index cd8e14e..b6a6db4 100644 --- a/src/backend/YesChef.Api/Entities/Store.cs +++ b/src/backend/YesChef.Api/Entities/Store.cs @@ -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; diff --git a/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs b/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs index 8ab4b80..c25a367 100644 --- a/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs +++ b/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs @@ -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); diff --git a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs index e964375..ea8e209 100644 --- a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs +++ b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs @@ -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 hub, YesChefDb db, int listId) + private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}"; + + private static async Task BroadcastListSummary(IHubContext 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 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 hub) => + group.MapPut("/{id:int}", async (int id, UpdateListRequest request, YesChefDb db, HttpContext http, IHubContext 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 hub) => + group.MapDelete("/{id:int}", async (int id, YesChefDb db, HttpContext http, IHubContext 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 hub) => + group.MapPost("/{listId:int}/items", async (int listId, AddItemRequest request, YesChefDb db, HttpContext http, IHubContext 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 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 hub) => + group.MapDelete("/{listId:int}/items/{itemId:int}", async (int listId, int itemId, YesChefDb db, HttpContext http, IHubContext 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 hub) => + group.MapPost("/{listId:int}/add-recipe/{recipeId:int}", async (int listId, int recipeId, YesChefDb db, HttpContext http, IHubContext 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 }); }); diff --git a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListHub.cs b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListHub.cs index e3b2c58..2019368 100644 --- a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListHub.cs +++ b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListHub.cs @@ -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}"); + } } diff --git a/src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs b/src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs index 75b81eb..7013ff8 100644 --- a/src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs +++ b/src/backend/YesChef.Api/Features/Stores/StoreEndpoints.cs @@ -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(); diff --git a/src/backend/YesChef.Api/Migrations/20260508034856_AddFamilyScoping.Designer.cs b/src/backend/YesChef.Api/Migrations/20260508034856_AddFamilyScoping.Designer.cs new file mode 100644 index 0000000..4d937d9 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260508034856_AddFamilyScoping.Designer.cs @@ -0,0 +1,446 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InviteCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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("UserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("UserId", "FamilyId"); + + b.HasIndex("FamilyId"); + + b.ToTable("FamilyMemberships"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Instructions") + .HasColumnType("text"); + + b.Property("Servings") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Quantity") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("RecipeId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckedByUserId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("ShoppingListId") + .HasColumnType("integer"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Name") + .IsUnique(); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("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 + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260508034856_AddFamilyScoping.cs b/src/backend/YesChef.Api/Migrations/20260508034856_AddFamilyScoping.cs new file mode 100644 index 0000000..e268366 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260508034856_AddFamilyScoping.cs @@ -0,0 +1,224 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + /// + public partial class AddFamilyScoping : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Stores_Name", + table: "Stores"); + + migrationBuilder.AddColumn( + name: "FamilyId", + table: "Stores", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "FamilyId", + table: "ShoppingLists", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "FamilyId", + table: "ShoppingListItems", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "FamilyId", + table: "Recipes", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "FamilyId", + table: "RecipeIngredients", + type: "integer", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "FamilyMemberships", + columns: table => new + { + UserId = table.Column(type: "integer", nullable: false), + FamilyId = table.Column(type: "integer", nullable: false), + Role = table.Column(type: "integer", nullable: false), + JoinedAt = table.Column(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); + } + + /// + 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); + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs index cbb9396..0119fd6 100644 --- a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs +++ b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs @@ -51,6 +51,27 @@ namespace YesChef.Api.Migrations b.ToTable("Families"); }); + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("UserId", "FamilyId"); + + b.HasIndex("FamilyId"); + + b.ToTable("FamilyMemberships"); + }); + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => { b.Property("Id") @@ -68,6 +89,9 @@ namespace YesChef.Api.Migrations b.Property("Description") .HasColumnType("text"); + b.Property("FamilyId") + .HasColumnType("integer"); + b.Property("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("Id")); + b.Property("FamilyId") + .HasColumnType("integer"); + b.Property("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("CreatedByUserId") .HasColumnType("integer"); + b.Property("FamilyId") + .HasColumnType("integer"); + b.Property("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("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("FamilyId") + .HasColumnType("integer"); + b.Property("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("CreatedAt") .HasColumnType("timestamp with time zone"); + b.Property("FamilyId") + .HasColumnType("integer"); + b.Property("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"); diff --git a/src/backend/YesChef.Api/Program.cs b/src/backend/YesChef.Api/Program.cs index a195503..68b3009 100644 --- a/src/backend/YesChef.Api/Program.cs +++ b/src/backend/YesChef.Api/Program.cs @@ -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(); + var stillMember = await db.FamilyMemberships + .AnyAsync(m => m.UserId == userId && m.FamilyId == familyId); + if (!stillMember) context.Fail("Family membership revoked."); + }, }; });