Brand, Product (aggregate root for ProductIdentifier), ProductIdentifier, and HouseholdProductPreference, per AGENTS.md §5.2/5.3. Built ahead of MVP-tagged epics per DEC-010 (explicit founder direction to work epics in strict numeric order). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>master
| @@ -30,7 +30,9 @@ | |||||
| "Bash(sed -n '/shopping-list-quick-add/,/<\\\\/form>/p' /tmp/list2.html)", | "Bash(sed -n '/shopping-list-quick-add/,/<\\\\/form>/p' /tmp/list2.html)", | ||||
| "Bash(sed -n '/shopping-list-item/,/<\\\\/li>/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)", | "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": [ | "additionalDirectories": [ | ||||
| "G:\\development\\C Sharp AI\\CartWise" | "G:\\development\\C Sharp AI\\CartWise" | ||||
| @@ -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). | - 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. | - 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). | - 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. | |||||
| @@ -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 | - EF configurations and migration are created | ||||
| **Tasks** | **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 | ### `CW-STORY-04.2` Search and view products | ||||
| **Release:** Post-MVP | **Release:** Post-MVP | ||||
| @@ -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(); | |||||
| } | |||||
| } | |||||
| @@ -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; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,81 @@ | |||||
| using CartWise.Domain.Enums; | |||||
| namespace CartWise.Domain.Entities; | |||||
| public class Product | |||||
| { | |||||
| private readonly List<ProductIdentifier> _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<ProductIdentifier> 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; | |||||
| } | |||||
| } | |||||
| @@ -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(); | |||||
| } | |||||
| @@ -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 | |||||
| } | |||||
| @@ -0,0 +1,9 @@ | |||||
| namespace CartWise.Domain.Enums; | |||||
| public enum ProductSourceType | |||||
| { | |||||
| Manual = 0, | |||||
| OpenFoodFacts = 1, | |||||
| Usda = 2, | |||||
| Retailer = 3 | |||||
| } | |||||
| @@ -0,0 +1,10 @@ | |||||
| namespace CartWise.Domain.Enums; | |||||
| public enum SubstitutionLevel | |||||
| { | |||||
| ExactProductOnly = 0, | |||||
| SameBrand = 1, | |||||
| SimilarPreferredBrands = 2, | |||||
| AnyEquivalent = 3, | |||||
| CheapestAcceptable = 4 | |||||
| } | |||||
| @@ -25,6 +25,14 @@ public class CartWiseDbContext : IdentityDbContext<ApplicationUser>, IApplicatio | |||||
| public DbSet<ShoppingListItem> ShoppingListItems => Set<ShoppingListItem>(); | public DbSet<ShoppingListItem> ShoppingListItems => Set<ShoppingListItem>(); | ||||
| public DbSet<Brand> Brands => Set<Brand>(); | |||||
| public DbSet<Product> Products => Set<Product>(); | |||||
| public DbSet<ProductIdentifier> ProductIdentifiers => Set<ProductIdentifier>(); | |||||
| public DbSet<HouseholdProductPreference> HouseholdProductPreferences => Set<HouseholdProductPreference>(); | |||||
| protected override void OnModelCreating(ModelBuilder builder) | protected override void OnModelCreating(ModelBuilder builder) | ||||
| { | { | ||||
| base.OnModelCreating(builder); | base.OnModelCreating(builder); | ||||
| @@ -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<Brand> | |||||
| { | |||||
| public void Configure(EntityTypeBuilder<Brand> 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(); | |||||
| } | |||||
| } | |||||
| @@ -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<HouseholdProductPreference> | |||||
| { | |||||
| public void Configure(EntityTypeBuilder<HouseholdProductPreference> builder) | |||||
| { | |||||
| builder.HasKey(p => p.HouseholdProductPreferenceId); | |||||
| builder.Property(p => p.PreferredUnit) | |||||
| .HasMaxLength(32); | |||||
| builder.HasIndex(p => new { p.HouseholdId, p.GroceryConceptId }) | |||||
| .IsUnique(); | |||||
| builder.HasOne<Household>() | |||||
| .WithMany() | |||||
| .HasForeignKey(p => p.HouseholdId) | |||||
| .OnDelete(DeleteBehavior.Cascade); | |||||
| builder.HasOne<GroceryConcept>() | |||||
| .WithMany() | |||||
| .HasForeignKey(p => p.GroceryConceptId) | |||||
| .OnDelete(DeleteBehavior.Cascade); | |||||
| builder.HasOne<Product>() | |||||
| .WithMany() | |||||
| .HasForeignKey(p => p.PreferredProductId) | |||||
| .OnDelete(DeleteBehavior.SetNull); | |||||
| } | |||||
| } | |||||
| @@ -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<Product> | |||||
| { | |||||
| public void Configure(EntityTypeBuilder<Product> 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<GroceryConcept>() | |||||
| .WithMany() | |||||
| .HasForeignKey(p => p.GroceryConceptId) | |||||
| .OnDelete(DeleteBehavior.SetNull); | |||||
| builder.HasOne<Brand>() | |||||
| .WithMany() | |||||
| .HasForeignKey(p => p.BrandId) | |||||
| .OnDelete(DeleteBehavior.SetNull); | |||||
| builder.Metadata.FindNavigation(nameof(Product.Identifiers))! | |||||
| .SetPropertyAccessMode(PropertyAccessMode.Field); | |||||
| } | |||||
| } | |||||
| @@ -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<ProductIdentifier> | |||||
| { | |||||
| public void Configure(EntityTypeBuilder<ProductIdentifier> 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<Product>() | |||||
| .WithMany(p => p.Identifiers) | |||||
| .HasForeignKey(i => i.ProductId) | |||||
| .OnDelete(DeleteBehavior.Cascade); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,753 @@ | |||||
| // <auto-generated /> | |||||
| 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 | |||||
| { | |||||
| /// <inheritdoc /> | |||||
| 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<Guid>("BrandId") | |||||
| .ValueGeneratedOnAdd() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("Name") | |||||
| .IsRequired() | |||||
| .HasMaxLength(160) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("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<Guid>("GroceryConceptId") | |||||
| .ValueGeneratedOnAdd() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid?>("CategoryId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<DateTime>("CreatedUtc") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("Name") | |||||
| .IsRequired() | |||||
| .HasMaxLength(160) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("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<Guid>("HouseholdId") | |||||
| .ValueGeneratedOnAdd() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("CreatedByUserId") | |||||
| .IsRequired() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<DateTime>("CreatedUtc") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("Name") | |||||
| .IsRequired() | |||||
| .HasMaxLength(120) | |||||
| .HasColumnType("TEXT"); | |||||
| b.HasKey("HouseholdId"); | |||||
| b.HasIndex("CreatedByUserId"); | |||||
| b.ToTable("Households"); | |||||
| }); | |||||
| modelBuilder.Entity("CartWise.Domain.Entities.HouseholdMember", b => | |||||
| { | |||||
| b.Property<Guid>("HouseholdId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("UserId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<DateTime>("JoinedUtc") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<int>("Role") | |||||
| .HasColumnType("INTEGER"); | |||||
| b.HasKey("HouseholdId", "UserId"); | |||||
| b.HasIndex("UserId"); | |||||
| b.ToTable("HouseholdMembers"); | |||||
| }); | |||||
| modelBuilder.Entity("CartWise.Domain.Entities.HouseholdProductPreference", b => | |||||
| { | |||||
| b.Property<Guid>("HouseholdProductPreferenceId") | |||||
| .ValueGeneratedOnAdd() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid>("GroceryConceptId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid>("HouseholdId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid?>("PreferredProductId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<decimal?>("PreferredQuantity") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("PreferredUnit") | |||||
| .HasMaxLength(32) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<int>("SubstitutionLevel") | |||||
| .HasColumnType("INTEGER"); | |||||
| b.Property<DateTime>("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<Guid>("ProductId") | |||||
| .ValueGeneratedOnAdd() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid?>("BrandId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<DateTime>("CreatedUtc") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid?>("GroceryConceptId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("ImageUrl") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("Name") | |||||
| .IsRequired() | |||||
| .HasMaxLength(240) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("NormalizedName") | |||||
| .IsRequired() | |||||
| .HasMaxLength(240) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("SizeUnit") | |||||
| .HasMaxLength(32) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<decimal?>("SizeValue") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("SourceExternalId") | |||||
| .HasMaxLength(200) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<int>("SourceType") | |||||
| .HasColumnType("INTEGER"); | |||||
| b.Property<DateTime>("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<Guid>("ProductCategoryId") | |||||
| .ValueGeneratedOnAdd() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("Name") | |||||
| .IsRequired() | |||||
| .HasMaxLength(160) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid?>("ParentCategoryId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.HasKey("ProductCategoryId"); | |||||
| b.HasIndex("ParentCategoryId"); | |||||
| b.ToTable("ProductCategories"); | |||||
| }); | |||||
| modelBuilder.Entity("CartWise.Domain.Entities.ProductIdentifier", b => | |||||
| { | |||||
| b.Property<Guid>("ProductIdentifierId") | |||||
| .ValueGeneratedOnAdd() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<int>("IdentifierType") | |||||
| .HasColumnType("INTEGER"); | |||||
| b.Property<string>("NormalizedValue") | |||||
| .IsRequired() | |||||
| .HasMaxLength(64) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid>("ProductId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("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<Guid>("ShoppingListId") | |||||
| .ValueGeneratedOnAdd() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<DateTime?>("CompletedUtc") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("CreatedByUserId") | |||||
| .IsRequired() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<DateTime>("CreatedUtc") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid>("HouseholdId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("Name") | |||||
| .IsRequired() | |||||
| .HasMaxLength(160) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<int>("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<Guid>("ShoppingListItemId") | |||||
| .ValueGeneratedOnAdd() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("AddedByUserId") | |||||
| .IsRequired() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<DateTime>("AddedUtc") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("DisplayName") | |||||
| .IsRequired() | |||||
| .HasMaxLength(240) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid?>("GroceryConceptId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("Notes") | |||||
| .HasMaxLength(500) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<DateTime?>("PurchasedUtc") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<decimal?>("Quantity") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid>("RowVersion") | |||||
| .IsConcurrencyToken() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid>("ShoppingListId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<int>("SortOrder") | |||||
| .HasColumnType("INTEGER"); | |||||
| b.Property<int>("Status") | |||||
| .HasColumnType("INTEGER"); | |||||
| b.Property<string>("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<string>("Id") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<int>("AccessFailedCount") | |||||
| .HasColumnType("INTEGER"); | |||||
| b.Property<string>("ConcurrencyStamp") | |||||
| .IsConcurrencyToken() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<DateTime>("CreatedUtc") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("DisplayName") | |||||
| .IsRequired() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("Email") | |||||
| .HasMaxLength(256) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<bool>("EmailConfirmed") | |||||
| .HasColumnType("INTEGER"); | |||||
| b.Property<bool>("LockoutEnabled") | |||||
| .HasColumnType("INTEGER"); | |||||
| b.Property<DateTimeOffset?>("LockoutEnd") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("NormalizedEmail") | |||||
| .HasMaxLength(256) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("NormalizedUserName") | |||||
| .HasMaxLength(256) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("PasswordHash") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("PhoneNumber") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<bool>("PhoneNumberConfirmed") | |||||
| .HasColumnType("INTEGER"); | |||||
| b.Property<string>("SecurityStamp") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<bool>("TwoFactorEnabled") | |||||
| .HasColumnType("INTEGER"); | |||||
| b.Property<string>("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<string>("Id") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("ConcurrencyStamp") | |||||
| .IsConcurrencyToken() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("Name") | |||||
| .HasMaxLength(256) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("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<string>", b => | |||||
| { | |||||
| b.Property<int>("Id") | |||||
| .ValueGeneratedOnAdd() | |||||
| .HasColumnType("INTEGER"); | |||||
| b.Property<string>("ClaimType") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("ClaimValue") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("RoleId") | |||||
| .IsRequired() | |||||
| .HasColumnType("TEXT"); | |||||
| b.HasKey("Id"); | |||||
| b.HasIndex("RoleId"); | |||||
| b.ToTable("AspNetRoleClaims", (string)null); | |||||
| }); | |||||
| modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => | |||||
| { | |||||
| b.Property<int>("Id") | |||||
| .ValueGeneratedOnAdd() | |||||
| .HasColumnType("INTEGER"); | |||||
| b.Property<string>("ClaimType") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("ClaimValue") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("UserId") | |||||
| .IsRequired() | |||||
| .HasColumnType("TEXT"); | |||||
| b.HasKey("Id"); | |||||
| b.HasIndex("UserId"); | |||||
| b.ToTable("AspNetUserClaims", (string)null); | |||||
| }); | |||||
| modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => | |||||
| { | |||||
| b.Property<string>("LoginProvider") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("ProviderKey") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("ProviderDisplayName") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("UserId") | |||||
| .IsRequired() | |||||
| .HasColumnType("TEXT"); | |||||
| b.HasKey("LoginProvider", "ProviderKey"); | |||||
| b.HasIndex("UserId"); | |||||
| b.ToTable("AspNetUserLogins", (string)null); | |||||
| }); | |||||
| modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => | |||||
| { | |||||
| b.Property<string>("UserId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("RoleId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.HasKey("UserId", "RoleId"); | |||||
| b.HasIndex("RoleId"); | |||||
| b.ToTable("AspNetUserRoles", (string)null); | |||||
| }); | |||||
| modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => | |||||
| { | |||||
| b.Property<string>("UserId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("LoginProvider") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("Name") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("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<string>", b => | |||||
| { | |||||
| b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) | |||||
| .WithMany() | |||||
| .HasForeignKey("RoleId") | |||||
| .OnDelete(DeleteBehavior.Cascade) | |||||
| .IsRequired(); | |||||
| }); | |||||
| modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => | |||||
| { | |||||
| b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) | |||||
| .WithMany() | |||||
| .HasForeignKey("UserId") | |||||
| .OnDelete(DeleteBehavior.Cascade) | |||||
| .IsRequired(); | |||||
| }); | |||||
| modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => | |||||
| { | |||||
| b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) | |||||
| .WithMany() | |||||
| .HasForeignKey("UserId") | |||||
| .OnDelete(DeleteBehavior.Cascade) | |||||
| .IsRequired(); | |||||
| }); | |||||
| modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", 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<string>", 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 | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,183 @@ | |||||
| using System; | |||||
| using Microsoft.EntityFrameworkCore.Migrations; | |||||
| #nullable disable | |||||
| namespace CartWise.Infrastructure.Data.Migrations | |||||
| { | |||||
| /// <inheritdoc /> | |||||
| public partial class AddProductCatalog : Migration | |||||
| { | |||||
| /// <inheritdoc /> | |||||
| protected override void Up(MigrationBuilder migrationBuilder) | |||||
| { | |||||
| migrationBuilder.CreateTable( | |||||
| name: "Brands", | |||||
| columns: table => new | |||||
| { | |||||
| BrandId = table.Column<Guid>(type: "TEXT", nullable: false), | |||||
| Name = table.Column<string>(type: "TEXT", maxLength: 160, nullable: false), | |||||
| NormalizedName = table.Column<string>(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<Guid>(type: "TEXT", nullable: false), | |||||
| GroceryConceptId = table.Column<Guid>(type: "TEXT", nullable: true), | |||||
| BrandId = table.Column<Guid>(type: "TEXT", nullable: true), | |||||
| Name = table.Column<string>(type: "TEXT", maxLength: 240, nullable: false), | |||||
| NormalizedName = table.Column<string>(type: "TEXT", maxLength: 240, nullable: false), | |||||
| SizeValue = table.Column<decimal>(type: "TEXT", nullable: true), | |||||
| SizeUnit = table.Column<string>(type: "TEXT", maxLength: 32, nullable: true), | |||||
| ImageUrl = table.Column<string>(type: "TEXT", nullable: true), | |||||
| SourceType = table.Column<int>(type: "INTEGER", nullable: false), | |||||
| SourceExternalId = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true), | |||||
| CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false), | |||||
| UpdatedUtc = table.Column<DateTime>(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<Guid>(type: "TEXT", nullable: false), | |||||
| HouseholdId = table.Column<Guid>(type: "TEXT", nullable: false), | |||||
| GroceryConceptId = table.Column<Guid>(type: "TEXT", nullable: false), | |||||
| PreferredProductId = table.Column<Guid>(type: "TEXT", nullable: true), | |||||
| SubstitutionLevel = table.Column<int>(type: "INTEGER", nullable: false), | |||||
| PreferredQuantity = table.Column<decimal>(type: "TEXT", nullable: true), | |||||
| PreferredUnit = table.Column<string>(type: "TEXT", maxLength: 32, nullable: true), | |||||
| UpdatedUtc = table.Column<DateTime>(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<Guid>(type: "TEXT", nullable: false), | |||||
| ProductId = table.Column<Guid>(type: "TEXT", nullable: false), | |||||
| IdentifierType = table.Column<int>(type: "INTEGER", nullable: false), | |||||
| Value = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false), | |||||
| NormalizedValue = table.Column<string>(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"); | |||||
| } | |||||
| /// <inheritdoc /> | |||||
| protected override void Down(MigrationBuilder migrationBuilder) | |||||
| { | |||||
| migrationBuilder.DropTable( | |||||
| name: "HouseholdProductPreferences"); | |||||
| migrationBuilder.DropTable( | |||||
| name: "ProductIdentifiers"); | |||||
| migrationBuilder.DropTable( | |||||
| name: "Products"); | |||||
| migrationBuilder.DropTable( | |||||
| name: "Brands"); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -17,6 +17,30 @@ namespace CartWise.Infrastructure.Data.Migrations | |||||
| #pragma warning disable 612, 618 | #pragma warning disable 612, 618 | ||||
| modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); | modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); | ||||
| modelBuilder.Entity("CartWise.Domain.Entities.Brand", b => | |||||
| { | |||||
| b.Property<Guid>("BrandId") | |||||
| .ValueGeneratedOnAdd() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("Name") | |||||
| .IsRequired() | |||||
| .HasMaxLength(160) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("NormalizedName") | |||||
| .IsRequired() | |||||
| .HasMaxLength(160) | |||||
| .HasColumnType("TEXT"); | |||||
| b.HasKey("BrandId"); | |||||
| b.HasIndex("NormalizedName") | |||||
| .IsUnique(); | |||||
| b.ToTable("Brands"); | |||||
| }); | |||||
| modelBuilder.Entity("CartWise.Domain.Entities.GroceryConcept", b => | modelBuilder.Entity("CartWise.Domain.Entities.GroceryConcept", b => | ||||
| { | { | ||||
| b.Property<Guid>("GroceryConceptId") | b.Property<Guid>("GroceryConceptId") | ||||
| @@ -95,6 +119,102 @@ namespace CartWise.Infrastructure.Data.Migrations | |||||
| b.ToTable("HouseholdMembers"); | b.ToTable("HouseholdMembers"); | ||||
| }); | }); | ||||
| modelBuilder.Entity("CartWise.Domain.Entities.HouseholdProductPreference", b => | |||||
| { | |||||
| b.Property<Guid>("HouseholdProductPreferenceId") | |||||
| .ValueGeneratedOnAdd() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid>("GroceryConceptId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid>("HouseholdId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid?>("PreferredProductId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<decimal?>("PreferredQuantity") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("PreferredUnit") | |||||
| .HasMaxLength(32) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<int>("SubstitutionLevel") | |||||
| .HasColumnType("INTEGER"); | |||||
| b.Property<DateTime>("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<Guid>("ProductId") | |||||
| .ValueGeneratedOnAdd() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid?>("BrandId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<DateTime>("CreatedUtc") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid?>("GroceryConceptId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("ImageUrl") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("Name") | |||||
| .IsRequired() | |||||
| .HasMaxLength(240) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("NormalizedName") | |||||
| .IsRequired() | |||||
| .HasMaxLength(240) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("SizeUnit") | |||||
| .HasMaxLength(32) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<decimal?>("SizeValue") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("SourceExternalId") | |||||
| .HasMaxLength(200) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<int>("SourceType") | |||||
| .HasColumnType("INTEGER"); | |||||
| b.Property<DateTime>("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 => | modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b => | ||||
| { | { | ||||
| b.Property<Guid>("ProductCategoryId") | b.Property<Guid>("ProductCategoryId") | ||||
| @@ -116,6 +236,38 @@ namespace CartWise.Infrastructure.Data.Migrations | |||||
| b.ToTable("ProductCategories"); | b.ToTable("ProductCategories"); | ||||
| }); | }); | ||||
| modelBuilder.Entity("CartWise.Domain.Entities.ProductIdentifier", b => | |||||
| { | |||||
| b.Property<Guid>("ProductIdentifierId") | |||||
| .ValueGeneratedOnAdd() | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<int>("IdentifierType") | |||||
| .HasColumnType("INTEGER"); | |||||
| b.Property<string>("NormalizedValue") | |||||
| .IsRequired() | |||||
| .HasMaxLength(64) | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<Guid>("ProductId") | |||||
| .HasColumnType("TEXT"); | |||||
| b.Property<string>("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 => | modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => | ||||
| { | { | ||||
| b.Property<Guid>("ShoppingListId") | b.Property<Guid>("ShoppingListId") | ||||
| @@ -442,6 +594,39 @@ namespace CartWise.Infrastructure.Data.Migrations | |||||
| .IsRequired(); | .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 => | modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b => | ||||
| { | { | ||||
| b.HasOne("CartWise.Domain.Entities.ProductCategory", null) | b.HasOne("CartWise.Domain.Entities.ProductCategory", null) | ||||
| @@ -450,6 +635,15 @@ namespace CartWise.Infrastructure.Data.Migrations | |||||
| .OnDelete(DeleteBehavior.Restrict); | .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 => | modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => | ||||
| { | { | ||||
| b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) | b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) | ||||
| @@ -541,6 +735,11 @@ namespace CartWise.Infrastructure.Data.Migrations | |||||
| b.Navigation("Members"); | b.Navigation("Members"); | ||||
| }); | }); | ||||
| modelBuilder.Entity("CartWise.Domain.Entities.Product", b => | |||||
| { | |||||
| b.Navigation("Identifiers"); | |||||
| }); | |||||
| modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => | modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => | ||||
| { | { | ||||
| b.Navigation("Items"); | b.Navigation("Items"); | ||||
| @@ -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<ArgumentException>(() => 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<InvalidOperationException>(() => 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); | |||||
| } | |||||
| } | |||||
Powered by TurnKey Linux.