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.");
+ },
};
});