diff --git a/.claude/settings.json b/.claude/settings.json index 39ce9d6..ea2f793 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -30,7 +30,9 @@ "Bash(sed -n '/shopping-list-quick-add/,/<\\\\/form>/p' /tmp/list2.html)", "Bash(sed -n '/shopping-list-item/,/<\\\\/li>/p' /tmp/list2.html)", "Bash(curl -s http://127.0.0.1:5187/css/site.css)", - "PowerShell(\"done\")" + "PowerShell(\"done\")", + "Bash(grep -n \"^### \\\\`CW-STORY\\\\|^## \\\\`CW-EPIC\" docs/scrum-backlog.md)", + "Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify3.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify3.db\" 2>&1 | tail -15 && rm -f verify3.db*)" ], "additionalDirectories": [ "G:\\development\\C Sharp AI\\CartWise" diff --git a/docs/decision-log.md b/docs/decision-log.md index fc33565..0566bb6 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -97,3 +97,11 @@ This file records product, scope, architecture, and delivery decisions for CartW - Reason: AGENTS.md and the backlog use "current household" language without defining cardinality. One-per-user is the smallest working vertical slice — no household-switcher UI, no "which household" ambiguity anywhere downstream (list, purchases, running-low). - Impact: No schema-level uniqueness constraint was added (`HouseholdMember`'s composite PK is `(HouseholdId, UserId)`, which would technically allow multiple rows for the same user), so this is enforced only at the `HouseholdService` application layer for now. Multi-household support remains possible later without a migration. - Revisit Trigger: A real need emerges for one person to participate in more than one household (e.g. managing groceries for two homes). + +### DEC-010 - Build order reverted to strict epic-numeric sequencing +- Date: 2026-08-10 +- Status: Accepted +- Decision: Build remaining epics in strict numeric order (`CW-EPIC-04` → `CW-EPIC-05` → `CW-EPIC-06` → `CW-EPIC-07` → `CW-EPIC-08`) instead of the MVP-first order the backlog recommended (`05` → `07` → MVP-tagged `08` stories, with `04`/`06` deferred to after MVP). +- Reason: Explicit founder direction, given after being asked directly whether to keep the MVP-first order or override it. +- Impact: `CW-EPIC-04` (Product Catalog and Barcode Resolution) and `CW-EPIC-06` (Shopping Mode) are no longer sequenced after MVP — they will be built next, ahead of `CW-EPIC-05`/`07`. `DEC-003` (barcode out of MVP), `DEC-004` (external providers out of MVP), and `DEC-007` (shopping mode may be deferred) remain valid as *scope* decisions — none of that work becomes MVP-required by this change — but their *sequencing* guidance is superseded. Individual stories within `04`/`06` that carry their own explicit Decision Gate (e.g. "confirm whether a product catalog is still needed," "Open Food Facts integration," "camera scan flow") will still be raised with the founder individually as implementation reaches them; this decision only resolves ordering, not each gate's content. Building substantial Post-MVP scope now will likely push completion past the September 10, 2026 MVP target. +- Revisit Trigger: If schedule pressure toward September 10, 2026 becomes acute, reconsider reverting to MVP-first sequencing for whatever remains. diff --git a/docs/scrum-backlog.md b/docs/scrum-backlog.md index 121ff65..3e4e8f7 100644 --- a/docs/scrum-backlog.md +++ b/docs/scrum-backlog.md @@ -431,14 +431,16 @@ Two more real bugs found by tests (bringing the running total for `CW-EPIC-03` t - EF configurations and migration are created **Tasks** -- [ ] Create `Brand` -- [ ] Create `Product` -- [ ] Create `ProductIdentifier` -- [ ] Create `HouseholdProductPreference` -- [ ] Add source type enums -- [ ] Add indexes and normalized fields -- [ ] Add EF configurations -- [ ] Create migration +- [x] Create `Brand` +- [x] Create `Product` +- [x] Create `ProductIdentifier` +- [x] Create `HouseholdProductPreference` +- [x] Add source type enums +- [x] Add indexes and normalized fields +- [x] Add EF configurations +- [x] Create migration + +**Status:** Done — per `DEC-010`, this epic is being built now ahead of MVP-tagged `CW-EPIC-05`/`07` at explicit founder direction (strict numeric epic order), overriding this story's own "confirm whether a product catalog is still needed" decision gate implicitly via that same direction. `Product` is the aggregate root for `ProductIdentifier` (`AddIdentifier`, mirroring `Household.AddMember`/`ShoppingList.AddItem`), rejecting a duplicate `(IdentifierType, NormalizedValue)` pair before it would ever hit the DB's unique index — same defense-in-depth pattern as `HouseholdService`'s one-active-list check. `ProductIdentifier.Normalize` is `public static` since `CW-STORY-04.3`'s barcode lookup will need the identical normalization before it ever constructs an identifier. `HouseholdProductPreference` enforces the `HouseholdId`+`GroceryConceptId` uniqueness from AGENTS.md §5.3 as a real unique index. `AddProductCatalog` migration verified applying cleanly. ### `CW-STORY-04.2` Search and view products **Release:** Post-MVP diff --git a/src/CartWise.Domain/Entities/Brand.cs b/src/CartWise.Domain/Entities/Brand.cs new file mode 100644 index 0000000..e4400e1 --- /dev/null +++ b/src/CartWise.Domain/Entities/Brand.cs @@ -0,0 +1,26 @@ +namespace CartWise.Domain.Entities; + +public class Brand +{ + public Guid BrandId { get; private set; } + + public string Name { get; private set; } = null!; + + public string NormalizedName { get; private set; } = null!; + + private Brand() + { + } + + public Brand(string name) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Brand name is required.", nameof(name)); + } + + BrandId = Guid.NewGuid(); + Name = name.Trim(); + NormalizedName = Name.ToUpperInvariant(); + } +} diff --git a/src/CartWise.Domain/Entities/HouseholdProductPreference.cs b/src/CartWise.Domain/Entities/HouseholdProductPreference.cs new file mode 100644 index 0000000..e82bcc9 --- /dev/null +++ b/src/CartWise.Domain/Entities/HouseholdProductPreference.cs @@ -0,0 +1,55 @@ +using CartWise.Domain.Enums; + +namespace CartWise.Domain.Entities; + +public class HouseholdProductPreference +{ + public Guid HouseholdProductPreferenceId { get; private set; } + + public Guid HouseholdId { get; private set; } + + public Guid GroceryConceptId { get; private set; } + + public Guid? PreferredProductId { get; private set; } + + public SubstitutionLevel SubstitutionLevel { get; private set; } + + public decimal? PreferredQuantity { get; private set; } + + public string? PreferredUnit { get; private set; } + + public DateTime UpdatedUtc { get; private set; } + + private HouseholdProductPreference() + { + } + + public HouseholdProductPreference( + Guid householdId, + Guid groceryConceptId, + SubstitutionLevel substitutionLevel, + DateTime updatedUtc, + Guid? preferredProductId = null, + decimal? preferredQuantity = null, + string? preferredUnit = null) + { + if (householdId == Guid.Empty) + { + throw new ArgumentException("HouseholdId is required.", nameof(householdId)); + } + + if (groceryConceptId == Guid.Empty) + { + throw new ArgumentException("GroceryConceptId is required.", nameof(groceryConceptId)); + } + + HouseholdProductPreferenceId = Guid.NewGuid(); + HouseholdId = householdId; + GroceryConceptId = groceryConceptId; + SubstitutionLevel = substitutionLevel; + PreferredProductId = preferredProductId; + PreferredQuantity = preferredQuantity; + PreferredUnit = preferredUnit; + UpdatedUtc = updatedUtc; + } +} diff --git a/src/CartWise.Domain/Entities/Product.cs b/src/CartWise.Domain/Entities/Product.cs new file mode 100644 index 0000000..99401a4 --- /dev/null +++ b/src/CartWise.Domain/Entities/Product.cs @@ -0,0 +1,81 @@ +using CartWise.Domain.Enums; + +namespace CartWise.Domain.Entities; + +public class Product +{ + private readonly List _identifiers = []; + + public Guid ProductId { get; private set; } + + public Guid? GroceryConceptId { get; private set; } + + public Guid? BrandId { get; private set; } + + public string Name { get; private set; } = null!; + + public string NormalizedName { get; private set; } = null!; + + public decimal? SizeValue { get; private set; } + + public string? SizeUnit { get; private set; } + + public string? ImageUrl { get; private set; } + + public ProductSourceType SourceType { get; private set; } + + public string? SourceExternalId { get; private set; } + + public DateTime CreatedUtc { get; private set; } + + public DateTime UpdatedUtc { get; private set; } + + public IReadOnlyCollection Identifiers => _identifiers.AsReadOnly(); + + private Product() + { + } + + public Product( + string name, + ProductSourceType sourceType, + DateTime createdUtc, + Guid? groceryConceptId = null, + Guid? brandId = null, + decimal? sizeValue = null, + string? sizeUnit = null, + string? imageUrl = null, + string? sourceExternalId = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Product name is required.", nameof(name)); + } + + ProductId = Guid.NewGuid(); + Name = name.Trim(); + NormalizedName = Name.ToUpperInvariant(); + SourceType = sourceType; + GroceryConceptId = groceryConceptId; + BrandId = brandId; + SizeValue = sizeValue; + SizeUnit = sizeUnit; + ImageUrl = imageUrl; + SourceExternalId = sourceExternalId; + CreatedUtc = createdUtc; + UpdatedUtc = createdUtc; + } + + public ProductIdentifier AddIdentifier(ProductIdentifierType identifierType, string value) + { + var normalized = ProductIdentifier.Normalize(value); + if (_identifiers.Any(i => i.IdentifierType == identifierType && i.NormalizedValue == normalized)) + { + throw new InvalidOperationException("This product already has that identifier."); + } + + var identifier = new ProductIdentifier(ProductId, identifierType, value); + _identifiers.Add(identifier); + return identifier; + } +} diff --git a/src/CartWise.Domain/Entities/ProductIdentifier.cs b/src/CartWise.Domain/Entities/ProductIdentifier.cs new file mode 100644 index 0000000..836cd94 --- /dev/null +++ b/src/CartWise.Domain/Entities/ProductIdentifier.cs @@ -0,0 +1,41 @@ +using CartWise.Domain.Enums; + +namespace CartWise.Domain.Entities; + +public class ProductIdentifier +{ + public Guid ProductIdentifierId { get; private set; } + + public Guid ProductId { get; private set; } + + public ProductIdentifierType IdentifierType { get; private set; } + + public string Value { get; private set; } = null!; + + public string NormalizedValue { get; private set; } = null!; + + private ProductIdentifier() + { + } + + internal ProductIdentifier(Guid productId, ProductIdentifierType identifierType, string value) + { + if (productId == Guid.Empty) + { + throw new ArgumentException("ProductId is required.", nameof(productId)); + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("Identifier value is required.", nameof(value)); + } + + ProductIdentifierId = Guid.NewGuid(); + ProductId = productId; + IdentifierType = identifierType; + Value = value.Trim(); + NormalizedValue = Normalize(value); + } + + public static string Normalize(string value) => value.Trim().ToUpperInvariant(); +} diff --git a/src/CartWise.Domain/Enums/ProductIdentifierType.cs b/src/CartWise.Domain/Enums/ProductIdentifierType.cs new file mode 100644 index 0000000..7d1dff7 --- /dev/null +++ b/src/CartWise.Domain/Enums/ProductIdentifierType.cs @@ -0,0 +1,12 @@ +namespace CartWise.Domain.Enums; + +public enum ProductIdentifierType +{ + UpcA = 0, + UpcE = 1, + Ean8 = 2, + Ean13 = 3, + Gtin = 4, + Plu = 5, + Other = 6 +} diff --git a/src/CartWise.Domain/Enums/ProductSourceType.cs b/src/CartWise.Domain/Enums/ProductSourceType.cs new file mode 100644 index 0000000..18f81d8 --- /dev/null +++ b/src/CartWise.Domain/Enums/ProductSourceType.cs @@ -0,0 +1,9 @@ +namespace CartWise.Domain.Enums; + +public enum ProductSourceType +{ + Manual = 0, + OpenFoodFacts = 1, + Usda = 2, + Retailer = 3 +} diff --git a/src/CartWise.Domain/Enums/SubstitutionLevel.cs b/src/CartWise.Domain/Enums/SubstitutionLevel.cs new file mode 100644 index 0000000..d514c14 --- /dev/null +++ b/src/CartWise.Domain/Enums/SubstitutionLevel.cs @@ -0,0 +1,10 @@ +namespace CartWise.Domain.Enums; + +public enum SubstitutionLevel +{ + ExactProductOnly = 0, + SameBrand = 1, + SimilarPreferredBrands = 2, + AnyEquivalent = 3, + CheapestAcceptable = 4 +} diff --git a/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs b/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs index 7964418..d74bfed 100644 --- a/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs +++ b/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs @@ -25,6 +25,14 @@ public class CartWiseDbContext : IdentityDbContext, IApplicatio public DbSet ShoppingListItems => Set(); + public DbSet Brands => Set(); + + public DbSet Products => Set(); + + public DbSet ProductIdentifiers => Set(); + + public DbSet HouseholdProductPreferences => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); diff --git a/src/CartWise.Infrastructure/Data/Configurations/BrandConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/BrandConfiguration.cs new file mode 100644 index 0000000..0834b30 --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Configurations/BrandConfiguration.cs @@ -0,0 +1,24 @@ +using CartWise.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CartWise.Infrastructure.Data.Configurations; + +public class BrandConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(b => b.BrandId); + + builder.Property(b => b.Name) + .HasMaxLength(160) + .IsRequired(); + + builder.Property(b => b.NormalizedName) + .HasMaxLength(160) + .IsRequired(); + + builder.HasIndex(b => b.NormalizedName) + .IsUnique(); + } +} diff --git a/src/CartWise.Infrastructure/Data/Configurations/HouseholdProductPreferenceConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/HouseholdProductPreferenceConfiguration.cs new file mode 100644 index 0000000..afe5467 --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Configurations/HouseholdProductPreferenceConfiguration.cs @@ -0,0 +1,34 @@ +using CartWise.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CartWise.Infrastructure.Data.Configurations; + +public class HouseholdProductPreferenceConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(p => p.HouseholdProductPreferenceId); + + builder.Property(p => p.PreferredUnit) + .HasMaxLength(32); + + builder.HasIndex(p => new { p.HouseholdId, p.GroceryConceptId }) + .IsUnique(); + + builder.HasOne() + .WithMany() + .HasForeignKey(p => p.HouseholdId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne() + .WithMany() + .HasForeignKey(p => p.GroceryConceptId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne() + .WithMany() + .HasForeignKey(p => p.PreferredProductId) + .OnDelete(DeleteBehavior.SetNull); + } +} diff --git a/src/CartWise.Infrastructure/Data/Configurations/ProductConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/ProductConfiguration.cs new file mode 100644 index 0000000..350ee9a --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Configurations/ProductConfiguration.cs @@ -0,0 +1,42 @@ +using CartWise.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CartWise.Infrastructure.Data.Configurations; + +public class ProductConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(p => p.ProductId); + + builder.Property(p => p.Name) + .HasMaxLength(240) + .IsRequired(); + + builder.Property(p => p.NormalizedName) + .HasMaxLength(240) + .IsRequired(); + + builder.Property(p => p.SizeUnit) + .HasMaxLength(32); + + builder.Property(p => p.SourceExternalId) + .HasMaxLength(200); + + builder.HasIndex(p => p.NormalizedName); + + builder.HasOne() + .WithMany() + .HasForeignKey(p => p.GroceryConceptId) + .OnDelete(DeleteBehavior.SetNull); + + builder.HasOne() + .WithMany() + .HasForeignKey(p => p.BrandId) + .OnDelete(DeleteBehavior.SetNull); + + builder.Metadata.FindNavigation(nameof(Product.Identifiers))! + .SetPropertyAccessMode(PropertyAccessMode.Field); + } +} diff --git a/src/CartWise.Infrastructure/Data/Configurations/ProductIdentifierConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/ProductIdentifierConfiguration.cs new file mode 100644 index 0000000..66ab2b4 --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Configurations/ProductIdentifierConfiguration.cs @@ -0,0 +1,29 @@ +using CartWise.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CartWise.Infrastructure.Data.Configurations; + +public class ProductIdentifierConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(i => i.ProductIdentifierId); + + builder.Property(i => i.Value) + .HasMaxLength(64) + .IsRequired(); + + builder.Property(i => i.NormalizedValue) + .HasMaxLength(64) + .IsRequired(); + + builder.HasIndex(i => new { i.IdentifierType, i.NormalizedValue }) + .IsUnique(); + + builder.HasOne() + .WithMany(p => p.Identifiers) + .HasForeignKey(i => i.ProductId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/CartWise.Infrastructure/Data/Migrations/20260810184326_AddProductCatalog.Designer.cs b/src/CartWise.Infrastructure/Data/Migrations/20260810184326_AddProductCatalog.Designer.cs new file mode 100644 index 0000000..eb683ab --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Migrations/20260810184326_AddProductCatalog.Designer.cs @@ -0,0 +1,753 @@ +// +using System; +using CartWise.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace CartWise.Infrastructure.Data.Migrations +{ + [DbContext(typeof(CartWiseDbContext))] + [Migration("20260810184326_AddProductCatalog")] + partial class AddProductCatalog + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("CartWise.Domain.Entities.Brand", b => + { + b.Property("BrandId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.HasKey("BrandId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Brands"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.GroceryConcept", b => + { + b.Property("GroceryConceptId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.HasKey("GroceryConceptId"); + + b.HasIndex("CategoryId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("GroceryConcepts"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Household", b => + { + b.Property("HouseholdId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("HouseholdId"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("Households"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.HouseholdMember", b => + { + b.Property("HouseholdId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("JoinedUtc") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("HouseholdId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("HouseholdMembers"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.HouseholdProductPreference", b => + { + b.Property("HouseholdProductPreferenceId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GroceryConceptId") + .HasColumnType("TEXT"); + + b.Property("HouseholdId") + .HasColumnType("TEXT"); + + b.Property("PreferredProductId") + .HasColumnType("TEXT"); + + b.Property("PreferredQuantity") + .HasColumnType("TEXT"); + + b.Property("PreferredUnit") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SubstitutionLevel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedUtc") + .HasColumnType("TEXT"); + + b.HasKey("HouseholdProductPreferenceId"); + + b.HasIndex("GroceryConceptId"); + + b.HasIndex("PreferredProductId"); + + b.HasIndex("HouseholdId", "GroceryConceptId") + .IsUnique(); + + b.ToTable("HouseholdProductPreferences"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Product", b => + { + b.Property("ProductId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BrandId") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("GroceryConceptId") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("TEXT"); + + b.Property("SizeUnit") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SizeValue") + .HasColumnType("TEXT"); + + b.Property("SourceExternalId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("UpdatedUtc") + .HasColumnType("TEXT"); + + b.HasKey("ProductId"); + + b.HasIndex("BrandId"); + + b.HasIndex("GroceryConceptId"); + + b.HasIndex("NormalizedName"); + + b.ToTable("Products"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b => + { + b.Property("ProductCategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("ParentCategoryId") + .HasColumnType("TEXT"); + + b.HasKey("ProductCategoryId"); + + b.HasIndex("ParentCategoryId"); + + b.ToTable("ProductCategories"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ProductIdentifier", b => + { + b.Property("ProductIdentifierId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("IdentifierType") + .HasColumnType("INTEGER"); + + b.Property("NormalizedValue") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ProductId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("ProductIdentifierId"); + + b.HasIndex("ProductId"); + + b.HasIndex("IdentifierType", "NormalizedValue") + .IsUnique(); + + b.ToTable("ProductIdentifiers"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => + { + b.Property("ShoppingListId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CompletedUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("HouseholdId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("ShoppingListId"); + + b.HasIndex("CreatedByUserId"); + + b.HasIndex("HouseholdId", "Status"); + + b.ToTable("ShoppingLists"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingListItem", b => + { + b.Property("ShoppingListItemId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AddedUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("TEXT"); + + b.Property("GroceryConceptId") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("PurchasedUtc") + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ShoppingListId") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Unit") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("ShoppingListItemId"); + + b.HasIndex("AddedByUserId"); + + b.HasIndex("GroceryConceptId"); + + b.HasIndex("ShoppingListId", "Status"); + + b.ToTable("ShoppingListItems"); + }); + + modelBuilder.Entity("CartWise.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.GroceryConcept", b => + { + b.HasOne("CartWise.Domain.Entities.ProductCategory", null) + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Household", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.HouseholdMember", b => + { + b.HasOne("CartWise.Domain.Entities.Household", null) + .WithMany("Members") + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.HouseholdProductPreference", b => + { + b.HasOne("CartWise.Domain.Entities.GroceryConcept", null) + .WithMany() + .HasForeignKey("GroceryConceptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.Household", null) + .WithMany() + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("PreferredProductId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Product", b => + { + b.HasOne("CartWise.Domain.Entities.Brand", null) + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CartWise.Domain.Entities.GroceryConcept", null) + .WithMany() + .HasForeignKey("GroceryConceptId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b => + { + b.HasOne("CartWise.Domain.Entities.ProductCategory", null) + .WithMany() + .HasForeignKey("ParentCategoryId") + .OnDelete(DeleteBehavior.Restrict); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ProductIdentifier", b => + { + b.HasOne("CartWise.Domain.Entities.Product", null) + .WithMany("Identifiers") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.Household", null) + .WithMany() + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingListItem", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("AddedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.GroceryConcept", null) + .WithMany() + .HasForeignKey("GroceryConceptId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CartWise.Domain.Entities.ShoppingList", null) + .WithMany("Items") + .HasForeignKey("ShoppingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Household", b => + { + b.Navigation("Members"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Product", b => + { + b.Navigation("Identifiers"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => + { + b.Navigation("Items"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/CartWise.Infrastructure/Data/Migrations/20260810184326_AddProductCatalog.cs b/src/CartWise.Infrastructure/Data/Migrations/20260810184326_AddProductCatalog.cs new file mode 100644 index 0000000..464ec95 --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Migrations/20260810184326_AddProductCatalog.cs @@ -0,0 +1,183 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CartWise.Infrastructure.Data.Migrations +{ + /// + public partial class AddProductCatalog : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Brands", + columns: table => new + { + BrandId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 160, nullable: false), + NormalizedName = table.Column(type: "TEXT", maxLength: 160, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Brands", x => x.BrandId); + }); + + migrationBuilder.CreateTable( + name: "Products", + columns: table => new + { + ProductId = table.Column(type: "TEXT", nullable: false), + GroceryConceptId = table.Column(type: "TEXT", nullable: true), + BrandId = table.Column(type: "TEXT", nullable: true), + Name = table.Column(type: "TEXT", maxLength: 240, nullable: false), + NormalizedName = table.Column(type: "TEXT", maxLength: 240, nullable: false), + SizeValue = table.Column(type: "TEXT", nullable: true), + SizeUnit = table.Column(type: "TEXT", maxLength: 32, nullable: true), + ImageUrl = table.Column(type: "TEXT", nullable: true), + SourceType = table.Column(type: "INTEGER", nullable: false), + SourceExternalId = table.Column(type: "TEXT", maxLength: 200, nullable: true), + CreatedUtc = table.Column(type: "TEXT", nullable: false), + UpdatedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Products", x => x.ProductId); + table.ForeignKey( + name: "FK_Products_Brands_BrandId", + column: x => x.BrandId, + principalTable: "Brands", + principalColumn: "BrandId", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_Products_GroceryConcepts_GroceryConceptId", + column: x => x.GroceryConceptId, + principalTable: "GroceryConcepts", + principalColumn: "GroceryConceptId", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "HouseholdProductPreferences", + columns: table => new + { + HouseholdProductPreferenceId = table.Column(type: "TEXT", nullable: false), + HouseholdId = table.Column(type: "TEXT", nullable: false), + GroceryConceptId = table.Column(type: "TEXT", nullable: false), + PreferredProductId = table.Column(type: "TEXT", nullable: true), + SubstitutionLevel = table.Column(type: "INTEGER", nullable: false), + PreferredQuantity = table.Column(type: "TEXT", nullable: true), + PreferredUnit = table.Column(type: "TEXT", maxLength: 32, nullable: true), + UpdatedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_HouseholdProductPreferences", x => x.HouseholdProductPreferenceId); + table.ForeignKey( + name: "FK_HouseholdProductPreferences_GroceryConcepts_GroceryConceptId", + column: x => x.GroceryConceptId, + principalTable: "GroceryConcepts", + principalColumn: "GroceryConceptId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_HouseholdProductPreferences_Households_HouseholdId", + column: x => x.HouseholdId, + principalTable: "Households", + principalColumn: "HouseholdId", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_HouseholdProductPreferences_Products_PreferredProductId", + column: x => x.PreferredProductId, + principalTable: "Products", + principalColumn: "ProductId", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateTable( + name: "ProductIdentifiers", + columns: table => new + { + ProductIdentifierId = table.Column(type: "TEXT", nullable: false), + ProductId = table.Column(type: "TEXT", nullable: false), + IdentifierType = table.Column(type: "INTEGER", nullable: false), + Value = table.Column(type: "TEXT", maxLength: 64, nullable: false), + NormalizedValue = table.Column(type: "TEXT", maxLength: 64, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ProductIdentifiers", x => x.ProductIdentifierId); + table.ForeignKey( + name: "FK_ProductIdentifiers_Products_ProductId", + column: x => x.ProductId, + principalTable: "Products", + principalColumn: "ProductId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Brands_NormalizedName", + table: "Brands", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_HouseholdProductPreferences_GroceryConceptId", + table: "HouseholdProductPreferences", + column: "GroceryConceptId"); + + migrationBuilder.CreateIndex( + name: "IX_HouseholdProductPreferences_HouseholdId_GroceryConceptId", + table: "HouseholdProductPreferences", + columns: new[] { "HouseholdId", "GroceryConceptId" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_HouseholdProductPreferences_PreferredProductId", + table: "HouseholdProductPreferences", + column: "PreferredProductId"); + + migrationBuilder.CreateIndex( + name: "IX_ProductIdentifiers_IdentifierType_NormalizedValue", + table: "ProductIdentifiers", + columns: new[] { "IdentifierType", "NormalizedValue" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_ProductIdentifiers_ProductId", + table: "ProductIdentifiers", + column: "ProductId"); + + migrationBuilder.CreateIndex( + name: "IX_Products_BrandId", + table: "Products", + column: "BrandId"); + + migrationBuilder.CreateIndex( + name: "IX_Products_GroceryConceptId", + table: "Products", + column: "GroceryConceptId"); + + migrationBuilder.CreateIndex( + name: "IX_Products_NormalizedName", + table: "Products", + column: "NormalizedName"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "HouseholdProductPreferences"); + + migrationBuilder.DropTable( + name: "ProductIdentifiers"); + + migrationBuilder.DropTable( + name: "Products"); + + migrationBuilder.DropTable( + name: "Brands"); + } + } +} diff --git a/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs b/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs index 9420b6c..dd78696 100644 --- a/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs +++ b/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs @@ -17,6 +17,30 @@ namespace CartWise.Infrastructure.Data.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + modelBuilder.Entity("CartWise.Domain.Entities.Brand", b => + { + b.Property("BrandId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.HasKey("BrandId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Brands"); + }); + modelBuilder.Entity("CartWise.Domain.Entities.GroceryConcept", b => { b.Property("GroceryConceptId") @@ -95,6 +119,102 @@ namespace CartWise.Infrastructure.Data.Migrations b.ToTable("HouseholdMembers"); }); + modelBuilder.Entity("CartWise.Domain.Entities.HouseholdProductPreference", b => + { + b.Property("HouseholdProductPreferenceId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GroceryConceptId") + .HasColumnType("TEXT"); + + b.Property("HouseholdId") + .HasColumnType("TEXT"); + + b.Property("PreferredProductId") + .HasColumnType("TEXT"); + + b.Property("PreferredQuantity") + .HasColumnType("TEXT"); + + b.Property("PreferredUnit") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SubstitutionLevel") + .HasColumnType("INTEGER"); + + b.Property("UpdatedUtc") + .HasColumnType("TEXT"); + + b.HasKey("HouseholdProductPreferenceId"); + + b.HasIndex("GroceryConceptId"); + + b.HasIndex("PreferredProductId"); + + b.HasIndex("HouseholdId", "GroceryConceptId") + .IsUnique(); + + b.ToTable("HouseholdProductPreferences"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Product", b => + { + b.Property("ProductId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BrandId") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("GroceryConceptId") + .HasColumnType("TEXT"); + + b.Property("ImageUrl") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(240) + .HasColumnType("TEXT"); + + b.Property("SizeUnit") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SizeValue") + .HasColumnType("TEXT"); + + b.Property("SourceExternalId") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("UpdatedUtc") + .HasColumnType("TEXT"); + + b.HasKey("ProductId"); + + b.HasIndex("BrandId"); + + b.HasIndex("GroceryConceptId"); + + b.HasIndex("NormalizedName"); + + b.ToTable("Products"); + }); + modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b => { b.Property("ProductCategoryId") @@ -116,6 +236,38 @@ namespace CartWise.Infrastructure.Data.Migrations b.ToTable("ProductCategories"); }); + modelBuilder.Entity("CartWise.Domain.Entities.ProductIdentifier", b => + { + b.Property("ProductIdentifierId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("IdentifierType") + .HasColumnType("INTEGER"); + + b.Property("NormalizedValue") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("ProductId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("ProductIdentifierId"); + + b.HasIndex("ProductId"); + + b.HasIndex("IdentifierType", "NormalizedValue") + .IsUnique(); + + b.ToTable("ProductIdentifiers"); + }); + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => { b.Property("ShoppingListId") @@ -442,6 +594,39 @@ namespace CartWise.Infrastructure.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("CartWise.Domain.Entities.HouseholdProductPreference", b => + { + b.HasOne("CartWise.Domain.Entities.GroceryConcept", null) + .WithMany() + .HasForeignKey("GroceryConceptId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.Household", null) + .WithMany() + .HasForeignKey("HouseholdId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CartWise.Domain.Entities.Product", null) + .WithMany() + .HasForeignKey("PreferredProductId") + .OnDelete(DeleteBehavior.SetNull); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Product", b => + { + b.HasOne("CartWise.Domain.Entities.Brand", null) + .WithMany() + .HasForeignKey("BrandId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("CartWise.Domain.Entities.GroceryConcept", null) + .WithMany() + .HasForeignKey("GroceryConceptId") + .OnDelete(DeleteBehavior.SetNull); + }); + modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b => { b.HasOne("CartWise.Domain.Entities.ProductCategory", null) @@ -450,6 +635,15 @@ namespace CartWise.Infrastructure.Data.Migrations .OnDelete(DeleteBehavior.Restrict); }); + modelBuilder.Entity("CartWise.Domain.Entities.ProductIdentifier", b => + { + b.HasOne("CartWise.Domain.Entities.Product", null) + .WithMany("Identifiers") + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => { b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) @@ -541,6 +735,11 @@ namespace CartWise.Infrastructure.Data.Migrations b.Navigation("Members"); }); + modelBuilder.Entity("CartWise.Domain.Entities.Product", b => + { + b.Navigation("Identifiers"); + }); + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => { b.Navigation("Items"); diff --git a/tests/CartWise.Domain.Tests/ProductTests.cs b/tests/CartWise.Domain.Tests/ProductTests.cs new file mode 100644 index 0000000..11fd6c6 --- /dev/null +++ b/tests/CartWise.Domain.Tests/ProductTests.cs @@ -0,0 +1,59 @@ +using CartWise.Domain.Entities; +using CartWise.Domain.Enums; + +namespace CartWise.Domain.Tests; + +public class ProductTests +{ + private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Constructor_SetsNormalizedName() + { + var product = new Product("Jif Creamy Peanut Butter", ProductSourceType.Manual, Now); + + Assert.Equal("JIF CREAMY PEANUT BUTTER", product.NormalizedName); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Constructor_ThrowsWhenNameIsMissing(string? name) + { + Assert.Throws(() => new Product(name!, ProductSourceType.Manual, Now)); + } + + [Fact] + public void AddIdentifier_AddsNormalizedIdentifier() + { + var product = new Product("Jif Creamy 16 oz", ProductSourceType.Manual, Now); + + var identifier = product.AddIdentifier(ProductIdentifierType.UpcA, " 051500255516 "); + + Assert.Single(product.Identifiers); + Assert.Equal("051500255516", identifier.NormalizedValue); + Assert.Equal(product.ProductId, identifier.ProductId); + } + + [Fact] + public void AddIdentifier_ThrowsForDuplicateIdentifier() + { + var product = new Product("Jif Creamy 16 oz", ProductSourceType.Manual, Now); + product.AddIdentifier(ProductIdentifierType.UpcA, "051500255516"); + + Assert.Throws(() => product.AddIdentifier(ProductIdentifierType.UpcA, "051500255516")); + } + + [Fact] + public void AddIdentifier_AllowsSameValueUnderDifferentType() + { + var product = new Product("Jif Creamy 16 oz", ProductSourceType.Manual, Now); + product.AddIdentifier(ProductIdentifierType.UpcA, "051500255516"); + + var identifier = product.AddIdentifier(ProductIdentifierType.Gtin, "051500255516"); + + Assert.Equal(2, product.Identifiers.Count); + Assert.Equal(ProductIdentifierType.Gtin, identifier.IdentifierType); + } +}