Scope all data access by FamilyId for multi-tenant isolation

Adds FamilyMembership join (UserId, FamilyId, Role) and a non-null
FamilyId FK on Store, ShoppingList, ShoppingListItem, Recipe, and
RecipeIngredient. FamilyId is denormalized on items/ingredients so the
tenant filter is a single column predicate without joins. Store name
uniqueness is now scoped per family.

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

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

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