diff --git a/src/backend/YesChef.Api.IntegrationTests/Builders/ShoppingListBuilder.cs b/src/backend/YesChef.Api.IntegrationTests/Builders/ShoppingListBuilder.cs index b4ed3ce..14cead1 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Builders/ShoppingListBuilder.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Builders/ShoppingListBuilder.cs @@ -21,9 +21,35 @@ public sealed class ShoppingListBuilder 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) + public ShoppingListBuilder WithItem( + string name, + bool isChecked = false, + int sortOrder = 0, + decimal? quantity = null, + int? unitOfMeasureId = null, + int? familyUnitOfMeasureId = null) { - _items.Add(new ShoppingListItem { Name = name, IsChecked = isChecked, SortOrder = sortOrder }); + _items.Add(new ShoppingListItem + { + Name = name, + IsChecked = isChecked, + SortOrder = sortOrder, + Quantity = quantity, + UnitOfMeasureId = unitOfMeasureId, + FamilyUnitOfMeasureId = familyUnitOfMeasureId, + }); + return this; + } + + public ShoppingListBuilder WithApproximateItem(string name, string quantityNote, int sortOrder = 0) + { + _items.Add(new ShoppingListItem + { + Name = name, + SortOrder = sortOrder, + IsApproximate = true, + QuantityNote = quantityNote, + }); return this; } diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs index 6a79278..7f3b782 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/ShoppingListEndpointsTests.cs @@ -441,7 +441,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest } [Test] - public async Task Add_recipe_appends_all_ingredients_with_quantity_prefix() + public async Task Add_recipe_copies_structured_quantity_to_items() { var cupId = await UseDbAsync(async db => { @@ -471,10 +471,134 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest await Assert.That(items.Count).IsEqualTo(4); await Assert.That(items.Select(i => i.Name)).IsEquivalentTo(new[] { - "existing", "2 cup flour", "2 eggs", "salt (to taste)" + "existing", "flour", "eggs", "salt" }); await Assert.That(items.Where(i => i.RecipeId == recipe.Id).Count()).IsEqualTo(3); - await Assert.That(items[3].SortOrder).IsGreaterThan(5); + + var flour = items.Single(i => i.Name == "flour"); + await Assert.That(flour.Quantity).IsEqualTo(2m); + await Assert.That(flour.UnitOfMeasureId).IsEqualTo(cupId); + await Assert.That(flour.IsApproximate).IsFalse(); + + var eggs = items.Single(i => i.Name == "eggs"); + await Assert.That(eggs.Quantity).IsEqualTo(2m); + await Assert.That(eggs.UnitOfMeasureId).IsNull(); + + var salt = items.Single(i => i.Name == "salt"); + await Assert.That(salt.IsApproximate).IsTrue(); + await Assert.That(salt.QuantityNote).IsEqualTo("to taste"); + await Assert.That(salt.Quantity).IsNull(); + } + + [Test] + public async Task Add_item_persists_structured_quantity() + { + var cupId = await UseDbAsync(async db => + { + var u = new UnitOfMeasure + { + Code = "cup", SingularName = "cup", PluralName = "cups", + Abbreviation = "cup", Category = UnitCategory.Volume, + }; + db.UnitsOfMeasure.Add(u); + await db.SaveChangesAsync(); + return u.Id; + }); + var list = await CreateListAsync(); + + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("flour", Quantity: 2.5m, UnitOfMeasureId: cupId)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync()); + await Assert.That(item.Quantity).IsEqualTo(2.5m); + await Assert.That(item.UnitOfMeasureId).IsEqualTo(cupId); + await Assert.That(item.IsApproximate).IsFalse(); + } + + [Test] + public async Task Add_item_persists_approximate_note_and_clears_structured_fields() + { + var list = await CreateListAsync(); + + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("salt", Quantity: 1m, IsApproximate: true, QuantityNote: "to taste")); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created); + var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync()); + await Assert.That(item.IsApproximate).IsTrue(); + await Assert.That(item.QuantityNote).IsEqualTo("to taste"); + await Assert.That(item.Quantity).IsNull(); + await Assert.That(item.UnitOfMeasureId).IsNull(); + } + + [Test] + public async Task Add_item_rejects_setting_both_unit_ids() + { + var list = await CreateListAsync(); + + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("flour", UnitOfMeasureId: 1, FamilyUnitOfMeasureId: 1)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Add_item_rejects_other_familys_family_unit() + { + var list = await CreateListAsync(); + var foreignUnitId = await UseDbAsync(async db => + { + var otherFamily = new Family { Name = "Other-Unit", InviteCode = "other-unit-code" }; + db.Families.Add(otherFamily); + await db.SaveChangesAsync(); + var u = new FamilyUnitOfMeasure + { + FamilyId = otherFamily.Id, + SingularName = "scoop", PluralName = "scoops", + Abbreviation = "scp", Category = UnitCategory.Count, + }; + db.FamilyUnitsOfMeasure.Add(u); + await db.SaveChangesAsync(); + return u.Id; + }); + + var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items", + new ShoppingListEndpoints.AddItemRequest("flour", FamilyUnitOfMeasureId: foreignUnitId)); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest); + } + + [Test] + public async Task Get_list_projects_structured_quantity_fields() + { + var cupId = await UseDbAsync(async db => + { + var u = new UnitOfMeasure + { + Code = "cup-proj", SingularName = "cup", PluralName = "cups", + Abbreviation = "cup-proj", Category = UnitCategory.Volume, + }; + db.UnitsOfMeasure.Add(u); + await db.SaveChangesAsync(); + return u.Id; + }); + var list = await CreateListAsync(b => b + .WithItem("flour", quantity: 3m, unitOfMeasureId: cupId) + .WithApproximateItem("salt", "to taste", sortOrder: 2)); + + var body = await Client.GetFromJsonAsync($"/api/lists/{list.Id}"); + var items = body.GetProperty("items").EnumerateArray().ToArray(); + + var flour = items.Single(i => i.GetProperty("name").GetString() == "flour"); + await Assert.That(flour.GetProperty("quantity").GetDecimal()).IsEqualTo(3m); + await Assert.That(flour.GetProperty("unitOfMeasureId").GetInt32()).IsEqualTo(cupId); + await Assert.That(flour.GetProperty("isApproximate").GetBoolean()).IsFalse(); + + var salt = items.Single(i => i.GetProperty("name").GetString() == "salt"); + await Assert.That(salt.GetProperty("isApproximate").GetBoolean()).IsTrue(); + await Assert.That(salt.GetProperty("quantityNote").GetString()).IsEqualTo("to taste"); + await Assert.That(salt.GetProperty("quantity").ValueKind).IsEqualTo(JsonValueKind.Null); } [Test] diff --git a/src/backend/YesChef.Api.IntegrationTests/Features/UnitEndpointsTests.cs b/src/backend/YesChef.Api.IntegrationTests/Features/UnitEndpointsTests.cs index 991031c..d4a4024 100644 --- a/src/backend/YesChef.Api.IntegrationTests/Features/UnitEndpointsTests.cs +++ b/src/backend/YesChef.Api.IntegrationTests/Features/UnitEndpointsTests.cs @@ -234,6 +234,43 @@ public class UnitEndpointsTests : AuthenticatedIntegrationTest await Assert.That(remaining).IsEqualTo(1); } + [Test] + public async Task Delete_family_unit_returns_409_when_referenced_by_shopping_list_item() + { + var familyId = await GetFamilyIdAsync(); + var store = await Data.CreateStoreAsync(); + var unit = await UseDbAsync(async db => + { + var u = new FamilyUnitOfMeasure + { + FamilyId = familyId, + SingularName = "scoop", PluralName = "scoops", + Abbreviation = "scp", Category = UnitCategory.Count, + }; + db.FamilyUnitsOfMeasure.Add(u); + await db.SaveChangesAsync(); + + var list = new ShoppingList + { + FamilyId = familyId, + Name = "List with unit", + StoreId = store.Id, + CreatedByUserId = User.Id, + Items = new List + { + new() { FamilyId = familyId, Name = "ice cream", Quantity = 2m, FamilyUnitOfMeasureId = u.Id }, + }, + }; + db.ShoppingLists.Add(list); + await db.SaveChangesAsync(); + return u; + }); + + var response = await Client.DeleteAsync($"/api/units/family/{unit.Id}"); + + await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict); + } + [Test] public async Task Endpoints_require_authentication() { diff --git a/src/backend/YesChef.Api/Data/YesChefDb.cs b/src/backend/YesChef.Api/Data/YesChefDb.cs index 3bf6fc5..8f6b4c3 100644 --- a/src/backend/YesChef.Api/Data/YesChefDb.cs +++ b/src/backend/YesChef.Api/Data/YesChefDb.cs @@ -95,6 +95,8 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) modelBuilder.Entity(e => { e.Property(i => i.Name).HasMaxLength(300); + e.Property(i => i.Quantity).HasPrecision(12, 4); + e.Property(i => i.QuantityNote).HasMaxLength(200); 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.RemovedByUser).WithMany().HasForeignKey(i => i.RemovedByUserId).OnDelete(DeleteBehavior.SetNull); @@ -102,6 +104,10 @@ public class YesChefDb(DbContextOptions options) : DbContext(options) e.HasOne(i => i.Section).WithMany().HasForeignKey(i => i.SectionId).OnDelete(DeleteBehavior.SetNull); e.HasOne(i => i.Product).WithMany().HasForeignKey(i => i.ProductId).OnDelete(DeleteBehavior.SetNull); e.HasOne(i => i.FamilyProduct).WithMany().HasForeignKey(i => i.FamilyProductId).OnDelete(DeleteBehavior.SetNull); + // Restrict on delete: the unit-delete endpoint already blocks + // deletion when an item references the unit; this is defense in depth. + e.HasOne(i => i.UnitOfMeasure).WithMany().HasForeignKey(i => i.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(i => i.FamilyUnitOfMeasure).WithMany().HasForeignKey(i => i.FamilyUnitOfMeasureId).OnDelete(DeleteBehavior.Restrict); e.HasIndex(i => i.FamilyId); }); diff --git a/src/backend/YesChef.Api/Entities/ShoppingListItem.cs b/src/backend/YesChef.Api/Entities/ShoppingListItem.cs index 1663087..3d8def9 100644 --- a/src/backend/YesChef.Api/Entities/ShoppingListItem.cs +++ b/src/backend/YesChef.Api/Entities/ShoppingListItem.cs @@ -22,6 +22,17 @@ public class ShoppingListItem public Product? Product { get; set; } public int? FamilyProductId { get; set; } public FamilyProduct? FamilyProduct { get; set; } + // Structured quantity. Null = unspecified (free-form Name carries the meaning). + public decimal? Quantity { get; set; } + // At most one of UnitOfMeasureId / FamilyUnitOfMeasureId is set on a row. + public int? UnitOfMeasureId { get; set; } + public UnitOfMeasure? UnitOfMeasure { get; set; } + public int? FamilyUnitOfMeasureId { get; set; } + public FamilyUnitOfMeasure? FamilyUnitOfMeasure { get; set; } + // Approximations like "to taste" / "a pinch" — render QuantityNote instead + // of the structured Quantity/Unit pair when this flag is set. + public bool IsApproximate { get; set; } + public string? QuantityNote { get; set; } public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime? RemovedAt { get; set; } public int? RemovedByUserId { get; set; } diff --git a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs index 1cb1d3b..e882c09 100644 --- a/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs +++ b/src/backend/YesChef.Api/Features/ShoppingLists/ShoppingListEndpoints.cs @@ -10,7 +10,17 @@ public static class ShoppingListEndpoints { public record CreateListRequest(string Name, int StoreId); public record UpdateListRequest(string Name, int StoreId); - public record AddItemRequest(string Name, int SortOrder = 0, int? SectionId = null, int? ProductId = null, int? FamilyProductId = null); + public record AddItemRequest( + string Name, + int SortOrder = 0, + int? SectionId = null, + int? ProductId = null, + int? FamilyProductId = null, + decimal? Quantity = null, + int? UnitOfMeasureId = null, + int? FamilyUnitOfMeasureId = null, + bool IsApproximate = false, + string? QuantityNote = null); public record SetItemSectionRequest(int? SectionId); private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}"; @@ -129,7 +139,12 @@ public static class ShoppingListEndpoints i.SectionId, RecipeTitle = i.Recipe?.Title, i.ProductId, - i.FamilyProductId + i.FamilyProductId, + i.Quantity, + i.UnitOfMeasureId, + i.FamilyUnitOfMeasureId, + i.IsApproximate, + i.QuantityNote }) }); }); @@ -183,6 +198,9 @@ public static class ShoppingListEndpoints if (await ValidateProductLink(db, familyId, request.ProductId, request.FamilyProductId) is { } productError) return productError; + if (await ValidateUnitLink(db, familyId, request.UnitOfMeasureId, request.FamilyUnitOfMeasureId) is { } unitError) + return unitError; + // Auto-assign a section from memory when caller didn't pick one // but supplied a product link — "we put bananas in Produce last // time we shopped here, do it again." @@ -198,6 +216,11 @@ public static class ShoppingListEndpoints SectionId = resolvedSectionId, ProductId = request.ProductId, FamilyProductId = request.FamilyProductId, + Quantity = request.IsApproximate ? null : request.Quantity, + UnitOfMeasureId = request.IsApproximate ? null : request.UnitOfMeasureId, + FamilyUnitOfMeasureId = request.IsApproximate ? null : request.FamilyUnitOfMeasureId, + IsApproximate = request.IsApproximate, + QuantityNote = request.IsApproximate ? request.QuantityNote : null, }; db.ShoppingListItems.Add(item); list.UpdatedAt = DateTime.UtcNow; @@ -210,9 +233,9 @@ public static class ShoppingListEndpoints await db.SaveChangesAsync(); - await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId }); + await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", ItemAddedPayload(item, recipeTitle: null)); await BroadcastListSummary(hub, db, listId, familyId); - return Results.Created($"/api/lists/{listId}/items/{item.Id}", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId }); + return Results.Created($"/api/lists/{listId}/items/{item.Id}", ItemAddedPayload(item, recipeTitle: null)); }); group.MapPatch("/{listId:int}/items/{itemId:int}/section", async (int listId, int itemId, SetItemSectionRequest request, YesChefDb db, HttpContext http, IHubContext hub) => @@ -297,7 +320,12 @@ public static class ShoppingListEndpoints item.SectionId, RecipeTitle = item.Recipe?.Title, item.ProductId, - item.FamilyProductId + item.FamilyProductId, + item.Quantity, + item.UnitOfMeasureId, + item.FamilyUnitOfMeasureId, + item.IsApproximate, + item.QuantityNote }); await BroadcastListSummary(hub, db, listId, familyId); return Results.NoContent(); @@ -330,16 +358,17 @@ public static class ShoppingListEndpoints { FamilyId = familyId, ShoppingListId = listId, - // Phase 3 will give ShoppingListItem its own structured - // quantity/unit fields. Until then, fold the recipe's - // structured quantity into the free-form Name so the - // list still reads "2 cup flour" / "salt to taste". - Name = FormatIngredientForList(ing), + Name = ing.Name, SortOrder = maxSort + idx + 1, RecipeId = recipeId, ProductId = ing.ProductId, FamilyProductId = ing.FamilyProductId, SectionId = rememberedSectionId, + Quantity = ing.Quantity, + UnitOfMeasureId = ing.UnitOfMeasureId, + FamilyUnitOfMeasureId = ing.FamilyUnitOfMeasureId, + IsApproximate = ing.IsApproximate, + QuantityNote = ing.QuantityNote, }); idx++; } @@ -350,7 +379,7 @@ public static class ShoppingListEndpoints foreach (var item in newItems) { - await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId, RecipeTitle = recipe.Title }); + await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", ItemAddedPayload(item, recipe.Title)); } await BroadcastListSummary(hub, db, listId, familyId); @@ -380,24 +409,25 @@ public static class ShoppingListEndpoints } /// - /// Render an ingredient's structured quantity into a free-form display - /// string for use as a shopping list item Name in Phase 2. Phase 3 will - /// remove this once ShoppingListItem grows its own structured fields. + /// Shape the wire payload for ItemAdded / Created responses so clients + /// can render the structured quantity. Single source of truth so the + /// list endpoint and recipe-add fan-out stay aligned. /// - private static string FormatIngredientForList(RecipeIngredient ing) + private static object ItemAddedPayload(ShoppingListItem item, string? recipeTitle) => new { - if (ing.IsApproximate && !string.IsNullOrWhiteSpace(ing.QuantityNote)) - return $"{ing.Name} ({ing.QuantityNote})"; - - var abbrev = ing.UnitOfMeasure?.Abbreviation ?? ing.FamilyUnitOfMeasure?.Abbreviation; - if (ing.Quantity is { } q) - { - // Trim trailing zeros so "2.0000" renders as "2". - var qty = q.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture); - return string.IsNullOrEmpty(abbrev) ? $"{qty} {ing.Name}" : $"{qty} {abbrev} {ing.Name}"; - } - return ing.Name; - } + item.Id, + item.Name, + item.SortOrder, + item.SectionId, + item.ProductId, + item.FamilyProductId, + item.Quantity, + item.UnitOfMeasureId, + item.FamilyUnitOfMeasureId, + item.IsApproximate, + item.QuantityNote, + RecipeTitle = recipeTitle, + }; /// /// Validates the unit-of-measure FK pair on an ingredient/item payload. diff --git a/src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs b/src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs index 633ffd6..1014c5f 100644 --- a/src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs +++ b/src/backend/YesChef.Api/Features/Units/UnitEndpoints.cs @@ -119,10 +119,12 @@ public static class UnitEndpoints var unit = await db.FamilyUnitsOfMeasure.FirstOrDefaultAsync(u => u.Id == id && u.FamilyId == familyId); if (unit is null) return Results.NotFound(); - // Block deletion when any recipe ingredient still references the - // unit. (ShoppingListItem will gain the same check in Phase 3.) - var inUse = await db.RecipeIngredients.AnyAsync(i => i.FamilyUnitOfMeasureId == id); - if (inUse) return Results.Conflict(new { error = "Unit is in use by one or more recipes." }); + // Block deletion when any recipe ingredient or shopping list item + // still references the unit. + var inUseByRecipe = await db.RecipeIngredients.AnyAsync(i => i.FamilyUnitOfMeasureId == id); + if (inUseByRecipe) return Results.Conflict(new { error = "Unit is in use by one or more recipes." }); + var inUseByList = await db.ShoppingListItems.AnyAsync(i => i.FamilyUnitOfMeasureId == id); + if (inUseByList) return Results.Conflict(new { error = "Unit is in use by one or more shopping lists." }); db.FamilyUnitsOfMeasure.Remove(unit); await db.SaveChangesAsync(); diff --git a/src/backend/YesChef.Api/Migrations/20260513233141_AddStructuredShoppingListItemQuantities.Designer.cs b/src/backend/YesChef.Api/Migrations/20260513233141_AddStructuredShoppingListItemQuantities.Designer.cs new file mode 100644 index 0000000..6212f03 --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260513233141_AddStructuredShoppingListItemQuantities.Designer.cs @@ -0,0 +1,1091 @@ +// +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("20260513233141_AddStructuredShoppingListItemQuantities")] + partial class AddStructuredShoppingListItemQuantities + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("YesChef.Api.Entities.Family", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InviteCode") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("InviteCode") + .IsUnique(); + + b.ToTable("Families"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyMembership", b => + { + b.Property("UserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .HasColumnType("integer"); + + b.HasKey("UserId", "FamilyId"); + + b.HasIndex("FamilyId"); + + b.ToTable("FamilyMemberships"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyProduct", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Name") + .IsUnique(); + + b.ToTable("FamilyProducts"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyProductOverride", b => + { + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("FamilyId", "ProductId"); + + b.HasIndex("ProductId"); + + b.ToTable("FamilyProductOverrides"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyUnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Abbreviation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("PluralName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SingularName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Abbreviation") + .IsUnique(); + + b.ToTable("FamilyUnitsOfMeasure"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Invite", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumedByUserId") + .HasColumnType("integer"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedByUserId") + .HasColumnType("integer"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("ConsumedByUserId"); + + b.HasIndex("IssuedByUserId"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("FamilyId", "ConsumedAt"); + + b.ToTable("Invites"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IssuedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId", "ConsumedAt"); + + b.ToTable("PasswordResetTokens"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Product", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Brand") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Notes") + .HasMaxLength(1000) + .HasColumnType("character varying(1000)"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ProductStoreSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("FamilyProductId") + .HasColumnType("integer"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("StoreSectionId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("FamilyProductId"); + + b.HasIndex("ProductId"); + + b.HasIndex("StoreId"); + + b.HasIndex("StoreSectionId"); + + b.HasIndex("FamilyId", "StoreId", "FamilyProductId") + .IsUnique() + .HasFilter("\"FamilyProductId\" IS NOT NULL"); + + b.HasIndex("FamilyId", "StoreId", "ProductId") + .IsUnique() + .HasFilter("\"ProductId\" IS NOT NULL"); + + b.ToTable("ProductStoreSections"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Instructions") + .HasColumnType("text"); + + b.Property("Servings") + .HasColumnType("integer"); + + b.Property("SourceUrl") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.ToTable("Recipes"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.RecipeIngredient", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("FamilyProductId") + .HasColumnType("integer"); + + b.Property("FamilyUnitOfMeasureId") + .HasColumnType("integer"); + + b.Property("IsApproximate") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasPrecision(12, 4) + .HasColumnType("numeric(12,4)"); + + b.Property("QuantityNote") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UnitOfMeasureId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("FamilyProductId"); + + b.HasIndex("FamilyUnitOfMeasureId"); + + b.HasIndex("ProductId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("UnitOfMeasureId"); + + b.ToTable("RecipeIngredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("integer"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("IsArchived") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId"); + + b.ToTable("ShoppingLists"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CheckedByUserId") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("FamilyProductId") + .HasColumnType("integer"); + + b.Property("FamilyUnitOfMeasureId") + .HasColumnType("integer"); + + b.Property("IsApproximate") + .HasColumnType("boolean"); + + b.Property("IsChecked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("ProductId") + .HasColumnType("integer"); + + b.Property("Quantity") + .HasPrecision(12, 4) + .HasColumnType("numeric(12,4)"); + + b.Property("QuantityNote") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RecipeId") + .HasColumnType("integer"); + + b.Property("RemovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RemovedByUserId") + .HasColumnType("integer"); + + b.Property("SectionId") + .HasColumnType("integer"); + + b.Property("ShoppingListId") + .HasColumnType("integer"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UnitOfMeasureId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CheckedByUserId"); + + b.HasIndex("FamilyId"); + + b.HasIndex("FamilyProductId"); + + b.HasIndex("FamilyUnitOfMeasureId"); + + b.HasIndex("ProductId"); + + b.HasIndex("RecipeId"); + + b.HasIndex("RemovedByUserId"); + + b.HasIndex("SectionId"); + + b.HasIndex("ShoppingListId"); + + b.HasIndex("UnitOfMeasureId"); + + b.ToTable("ShoppingListItems"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Store", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId", "Name") + .IsUnique(); + + b.ToTable("Stores"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.StoreSection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FamilyId") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("StoreId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("FamilyId"); + + b.HasIndex("StoreId", "Name") + .IsUnique(); + + b.ToTable("StoreSections"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.UnitOfMeasure", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Abbreviation") + .IsRequired() + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsBase") + .HasColumnType("boolean"); + + b.Property("PluralName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SingularName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("Abbreviation") + .IsUnique(); + + b.HasIndex("Code") + .IsUnique(); + + b.ToTable("UnitsOfMeasure"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(254) + .HasColumnType("character varying(254)"); + + b.Property("EmailConfirmedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + 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.FamilyProduct", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyProductOverride", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.FamilyUnitOfMeasure", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Invite", b => + { + b.HasOne("YesChef.Api.Entities.User", "ConsumedByUser") + .WithMany() + .HasForeignKey("ConsumedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.User", "IssuedByUser") + .WithMany() + .HasForeignKey("IssuedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("ConsumedByUser"); + + b.Navigation("Family"); + + b.Navigation("IssuedByUser"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.PasswordResetToken", b => + { + b.HasOne("YesChef.Api.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ProductStoreSection", b => + { + b.HasOne("YesChef.Api.Entities.Family", "Family") + .WithMany() + .HasForeignKey("FamilyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.FamilyProduct", "FamilyProduct") + .WithMany() + .HasForeignKey("FamilyProductId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("YesChef.Api.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("YesChef.Api.Entities.Store", "Store") + .WithMany() + .HasForeignKey("StoreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.StoreSection", "StoreSection") + .WithMany() + .HasForeignKey("StoreSectionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Family"); + + b.Navigation("FamilyProduct"); + + b.Navigation("Product"); + + b.Navigation("Store"); + + b.Navigation("StoreSection"); + }); + + 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.FamilyProduct", "FamilyProduct") + .WithMany() + .HasForeignKey("FamilyProductId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.FamilyUnitOfMeasure", "FamilyUnitOfMeasure") + .WithMany() + .HasForeignKey("FamilyUnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("YesChef.Api.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany("Ingredients") + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.UnitOfMeasure", "UnitOfMeasure") + .WithMany() + .HasForeignKey("UnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Family"); + + b.Navigation("FamilyProduct"); + + b.Navigation("FamilyUnitOfMeasure"); + + b.Navigation("Product"); + + b.Navigation("Recipe"); + + b.Navigation("UnitOfMeasure"); + }); + + 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.FamilyProduct", "FamilyProduct") + .WithMany() + .HasForeignKey("FamilyProductId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.FamilyUnitOfMeasure", "FamilyUnitOfMeasure") + .WithMany() + .HasForeignKey("FamilyUnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("YesChef.Api.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.Recipe", "Recipe") + .WithMany() + .HasForeignKey("RecipeId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.User", "RemovedByUser") + .WithMany() + .HasForeignKey("RemovedByUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.StoreSection", "Section") + .WithMany() + .HasForeignKey("SectionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("YesChef.Api.Entities.ShoppingList", "ShoppingList") + .WithMany("Items") + .HasForeignKey("ShoppingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("YesChef.Api.Entities.UnitOfMeasure", "UnitOfMeasure") + .WithMany() + .HasForeignKey("UnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("CheckedByUser"); + + b.Navigation("Family"); + + b.Navigation("FamilyProduct"); + + b.Navigation("FamilyUnitOfMeasure"); + + b.Navigation("Product"); + + b.Navigation("Recipe"); + + b.Navigation("RemovedByUser"); + + b.Navigation("Section"); + + b.Navigation("ShoppingList"); + + b.Navigation("UnitOfMeasure"); + }); + + 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.StoreSection", b => + { + 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("Family"); + + b.Navigation("Store"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.Recipe", b => + { + b.Navigation("Ingredients"); + }); + + modelBuilder.Entity("YesChef.Api.Entities.ShoppingList", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/20260513233141_AddStructuredShoppingListItemQuantities.cs b/src/backend/YesChef.Api/Migrations/20260513233141_AddStructuredShoppingListItemQuantities.cs new file mode 100644 index 0000000..288ec4b --- /dev/null +++ b/src/backend/YesChef.Api/Migrations/20260513233141_AddStructuredShoppingListItemQuantities.cs @@ -0,0 +1,114 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace YesChef.Api.Migrations +{ + /// + public partial class AddStructuredShoppingListItemQuantities : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "FamilyUnitOfMeasureId", + table: "ShoppingListItems", + type: "integer", + nullable: true); + + migrationBuilder.AddColumn( + name: "IsApproximate", + table: "ShoppingListItems", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "Quantity", + table: "ShoppingListItems", + type: "numeric(12,4)", + precision: 12, + scale: 4, + nullable: true); + + migrationBuilder.AddColumn( + name: "QuantityNote", + table: "ShoppingListItems", + type: "character varying(200)", + maxLength: 200, + nullable: true); + + migrationBuilder.AddColumn( + name: "UnitOfMeasureId", + table: "ShoppingListItems", + type: "integer", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_ShoppingListItems_FamilyUnitOfMeasureId", + table: "ShoppingListItems", + column: "FamilyUnitOfMeasureId"); + + migrationBuilder.CreateIndex( + name: "IX_ShoppingListItems_UnitOfMeasureId", + table: "ShoppingListItems", + column: "UnitOfMeasureId"); + + migrationBuilder.AddForeignKey( + name: "FK_ShoppingListItems_FamilyUnitsOfMeasure_FamilyUnitOfMeasureId", + table: "ShoppingListItems", + column: "FamilyUnitOfMeasureId", + principalTable: "FamilyUnitsOfMeasure", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + + migrationBuilder.AddForeignKey( + name: "FK_ShoppingListItems_UnitsOfMeasure_UnitOfMeasureId", + table: "ShoppingListItems", + column: "UnitOfMeasureId", + principalTable: "UnitsOfMeasure", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_ShoppingListItems_FamilyUnitsOfMeasure_FamilyUnitOfMeasureId", + table: "ShoppingListItems"); + + migrationBuilder.DropForeignKey( + name: "FK_ShoppingListItems_UnitsOfMeasure_UnitOfMeasureId", + table: "ShoppingListItems"); + + migrationBuilder.DropIndex( + name: "IX_ShoppingListItems_FamilyUnitOfMeasureId", + table: "ShoppingListItems"); + + migrationBuilder.DropIndex( + name: "IX_ShoppingListItems_UnitOfMeasureId", + table: "ShoppingListItems"); + + migrationBuilder.DropColumn( + name: "FamilyUnitOfMeasureId", + table: "ShoppingListItems"); + + migrationBuilder.DropColumn( + name: "IsApproximate", + table: "ShoppingListItems"); + + migrationBuilder.DropColumn( + name: "Quantity", + table: "ShoppingListItems"); + + migrationBuilder.DropColumn( + name: "QuantityNote", + table: "ShoppingListItems"); + + migrationBuilder.DropColumn( + name: "UnitOfMeasureId", + table: "ShoppingListItems"); + } + } +} diff --git a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs index f3860a7..9b7b263 100644 --- a/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs +++ b/src/backend/YesChef.Api/Migrations/YesChefDbModelSnapshot.cs @@ -514,6 +514,12 @@ namespace YesChef.Api.Migrations b.Property("FamilyProductId") .HasColumnType("integer"); + b.Property("FamilyUnitOfMeasureId") + .HasColumnType("integer"); + + b.Property("IsApproximate") + .HasColumnType("boolean"); + b.Property("IsChecked") .HasColumnType("boolean"); @@ -525,6 +531,14 @@ namespace YesChef.Api.Migrations b.Property("ProductId") .HasColumnType("integer"); + b.Property("Quantity") + .HasPrecision(12, 4) + .HasColumnType("numeric(12,4)"); + + b.Property("QuantityNote") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + b.Property("RecipeId") .HasColumnType("integer"); @@ -543,6 +557,9 @@ namespace YesChef.Api.Migrations b.Property("SortOrder") .HasColumnType("integer"); + b.Property("UnitOfMeasureId") + .HasColumnType("integer"); + b.HasKey("Id"); b.HasIndex("CheckedByUserId"); @@ -551,6 +568,8 @@ namespace YesChef.Api.Migrations b.HasIndex("FamilyProductId"); + b.HasIndex("FamilyUnitOfMeasureId"); + b.HasIndex("ProductId"); b.HasIndex("RecipeId"); @@ -561,6 +580,8 @@ namespace YesChef.Api.Migrations b.HasIndex("ShoppingListId"); + b.HasIndex("UnitOfMeasureId"); + b.ToTable("ShoppingListItems"); }); @@ -965,6 +986,11 @@ namespace YesChef.Api.Migrations .HasForeignKey("FamilyProductId") .OnDelete(DeleteBehavior.SetNull); + b.HasOne("YesChef.Api.Entities.FamilyUnitOfMeasure", "FamilyUnitOfMeasure") + .WithMany() + .HasForeignKey("FamilyUnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict); + b.HasOne("YesChef.Api.Entities.Product", "Product") .WithMany() .HasForeignKey("ProductId") @@ -991,12 +1017,19 @@ namespace YesChef.Api.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); + b.HasOne("YesChef.Api.Entities.UnitOfMeasure", "UnitOfMeasure") + .WithMany() + .HasForeignKey("UnitOfMeasureId") + .OnDelete(DeleteBehavior.Restrict); + b.Navigation("CheckedByUser"); b.Navigation("Family"); b.Navigation("FamilyProduct"); + b.Navigation("FamilyUnitOfMeasure"); + b.Navigation("Product"); b.Navigation("Recipe"); @@ -1006,6 +1039,8 @@ namespace YesChef.Api.Migrations b.Navigation("Section"); b.Navigation("ShoppingList"); + + b.Navigation("UnitOfMeasure"); }); modelBuilder.Entity("YesChef.Api.Entities.Store", b => diff --git a/src/frontend/src/routes/lists/[id]/+page.svelte b/src/frontend/src/routes/lists/[id]/+page.svelte index 5928684..6657a33 100644 --- a/src/frontend/src/routes/lists/[id]/+page.svelte +++ b/src/frontend/src/routes/lists/[id]/+page.svelte @@ -6,6 +6,9 @@ import { startConnection, stopConnection } from '$lib/signalr'; import { toast } from '$lib/toast.svelte'; import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte'; + import QuantityInput, { type QuantityValue } from '$lib/QuantityInput.svelte'; + import { units } from '$lib/units.svelte'; + import { formatQuantity } from '$lib/formatQuantity'; import type { HubConnection } from '@microsoft/signalr'; interface ListItem { @@ -16,6 +19,11 @@ sortOrder: number; sectionId: number | null; recipeTitle: string | null; + quantity: number | null; + unitOfMeasureId: number | null; + familyUnitOfMeasureId: number | null; + isApproximate: boolean; + quantityNote: string | null; } interface Section { @@ -42,6 +50,9 @@ let newItemSectionId = $state(null); let newItemProductId = $state(null); let newItemFamilyProductId = $state(null); + let newItemQuantity = $state({ quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null }); + let newItemIsApproximate = $state(false); + let newItemQuantityNote = $state(''); let loading = $state(true); let connection: HubConnection | null = null; @@ -83,12 +94,18 @@ list = data; items = data.items; sections = data.sections; + // Touch the unit catalog so abbreviations are available for display. + void units.all; loading = false; connection = await startConnection(); await connection.invoke('JoinList', listId); - connection.on('ItemAdded', (data: { id: number; name: string; sortOrder: number; sectionId: number | null; recipeTitle?: string }) => { + connection.on('ItemAdded', (data: { + id: number; name: string; sortOrder: number; sectionId: number | null; recipeTitle?: string; + quantity: number | null; unitOfMeasureId: number | null; familyUnitOfMeasureId: number | null; + isApproximate: boolean; quantityNote: string | null; + }) => { if (!items.find((i) => i.id === data.id)) { items = [ ...items, @@ -99,7 +116,12 @@ checkedByUserName: null, sortOrder: data.sortOrder, sectionId: data.sectionId ?? null, - recipeTitle: data.recipeTitle ?? null + recipeTitle: data.recipeTitle ?? null, + quantity: data.quantity, + unitOfMeasureId: data.unitOfMeasureId, + familyUnitOfMeasureId: data.familyUnitOfMeasureId, + isApproximate: data.isApproximate, + quantityNote: data.quantityNote } ]; } @@ -154,12 +176,29 @@ sortOrder: maxSort + 1, sectionId: newItemSectionId, productId: newItemProductId, - familyProductId: newItemFamilyProductId + familyProductId: newItemFamilyProductId, + quantity: newItemIsApproximate ? null : newItemQuantity.quantity, + unitOfMeasureId: newItemIsApproximate ? null : newItemQuantity.unitOfMeasureId, + familyUnitOfMeasureId: newItemIsApproximate ? null : newItemQuantity.familyUnitOfMeasureId, + isApproximate: newItemIsApproximate, + quantityNote: newItemIsApproximate ? (newItemQuantityNote || null) : null }) }); newItemName = ''; newItemProductId = null; newItemFamilyProductId = null; + newItemQuantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null }; + newItemIsApproximate = false; + newItemQuantityNote = ''; + } + + function toggleApproximateNewItem() { + newItemIsApproximate = !newItemIsApproximate; + if (newItemIsApproximate) { + newItemQuantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null }; + } else { + newItemQuantityNote = ''; + } } function onItemProductChange(product: ProductSuggestion | null) { @@ -229,34 +268,53 @@ -
{ e.preventDefault(); addItem(); }} class="mb-4 flex flex-wrap gap-2"> -
- -
- {#if sections.length > 0} - + {:else} + + {/if} +
+ +
+ {#if sections.length > 0} + + {/if} + + @@ -269,6 +327,7 @@
    {#each group.items as item (item.id)} + {@const qty = formatQuantity(item, units.all)}
  • + {#if qty} + {qty} + {/if} {item.name} {#if item.recipeTitle} from {item.recipeTitle} @@ -324,6 +386,7 @@
      {#each group.items as item (item.id)} + {@const qty = formatQuantity(item, units.all)}
    • + {#if qty} + {qty} + {/if} {item.name} {#if item.checkedByUserName} {item.checkedByUserName}