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