diff --git a/.claude/settings.json b/.claude/settings.json index c1dd4cb..7ee3e25 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -36,7 +36,8 @@ "Bash(curl -s -m 8 \"https://world.openfoodfacts.org/api/v2/product/3017620422003.json\")", "Bash(curl -s -b /tmp/cw5.txt -m 15 http://127.0.0.1:5187/api/products/barcode/3017620422003)", "Bash(curl -s -b /tmp/cw5.txt http://127.0.0.1:5187/products/0ce5df7a-6c8d-489d-ba9f-8d41699f0705 -o /tmp/product.html -w \"status=%{http_code}\\\\n\")", - "Bash(sed -n '/

/,/Add to my list/p' /tmp/product.html)" + "Bash(sed -n '/

/,/Add to my list/p' /tmp/product.html)", + "Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify4.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify4.db\" 2>&1 | tail -10 && rm -f verify4.db*)" ], "additionalDirectories": [ "G:\\development\\C Sharp AI\\CartWise" diff --git a/docs/decision-log.md b/docs/decision-log.md index 0566bb6..bdb3797 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -105,3 +105,11 @@ This file records product, scope, architecture, and delivery decisions for CartW - 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. + +### DEC-011 - Store tracking is optional on Purchase for MVP +- Date: 2026-08-10 +- Status: Accepted +- Decision: `Purchase.StoreLocationId` is a nullable FK. Recording a purchase never requires picking a store; a household can optionally attach one once `StoreLocation`s exist. +- Reason: `CW-STORY-05.1`'s decision gate asked whether store tracking should be minimal/optional or required. Founder chose optional — no "create a store" step should block recording the first purchase. +- Impact: `PriceObservation.StoreLocationId` is likewise nullable (already specified that way in AGENTS.md §5.6). Store-scoped price comparisons ("cheapest at Store X") remain possible later for whichever purchases did specify a store, without requiring it retroactively. +- Revisit Trigger: If store-level price comparison becomes a core MVP feature rather than a nice-to-have. diff --git a/docs/scrum-backlog.md b/docs/scrum-backlog.md index 0d42035..e87169e 100644 --- a/docs/scrum-backlog.md +++ b/docs/scrum-backlog.md @@ -553,11 +553,13 @@ Verified live end-to-end with a **real** barcode against the **real** Open Food - EF configurations and migration are created **Tasks** -- [ ] Create `Retailer` -- [ ] Create `StoreLocation` -- [ ] Add EF configurations -- [ ] Create migration -- [ ] Add tests for basic persistence rules +- [x] Create `Retailer` +- [x] Create `StoreLocation` +- [x] Add EF configurations +- [x] Create migration +- [x] Add tests for basic persistence rules + +**Status:** Done — per `DEC-011`, store tracking is optional everywhere it appears (`Purchase.StoreLocationId`/`PriceObservation.StoreLocationId` are nullable, as AGENTS.md §5.6 already specified for the latter). `Retailer`/`StoreLocation` are plain independent entities (no aggregate relationship needed — a store isn't composed/protected by its retailer the way `ShoppingListItem` is by `ShoppingList`). `AddRetailersAndStores` migration verified applying cleanly. 9 domain tests. ### `CW-STORY-05.2` Model purchases and purchase items **Release:** MVP diff --git a/src/CartWise.Domain/Entities/Retailer.cs b/src/CartWise.Domain/Entities/Retailer.cs new file mode 100644 index 0000000..224a9c8 --- /dev/null +++ b/src/CartWise.Domain/Entities/Retailer.cs @@ -0,0 +1,29 @@ +namespace CartWise.Domain.Entities; + +public class Retailer +{ + public Guid RetailerId { get; private set; } + + public string Name { get; private set; } = null!; + + public string NormalizedName { get; private set; } = null!; + + public string? WebsiteUrl { get; private set; } + + private Retailer() + { + } + + public Retailer(string name, string? websiteUrl = null) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Retailer name is required.", nameof(name)); + } + + RetailerId = Guid.NewGuid(); + Name = name.Trim(); + NormalizedName = Name.ToUpperInvariant(); + WebsiteUrl = websiteUrl; + } +} diff --git a/src/CartWise.Domain/Entities/StoreLocation.cs b/src/CartWise.Domain/Entities/StoreLocation.cs new file mode 100644 index 0000000..4e6f82a --- /dev/null +++ b/src/CartWise.Domain/Entities/StoreLocation.cs @@ -0,0 +1,76 @@ +namespace CartWise.Domain.Entities; + +public class StoreLocation +{ + public Guid StoreLocationId { get; private set; } + + public Guid RetailerId { get; private set; } + + public string? ExternalStoreId { get; private set; } + + public string Name { get; private set; } = null!; + + public string? AddressLine1 { get; private set; } + + public string? AddressLine2 { get; private set; } + + public string? City { get; private set; } + + public string? Region { get; private set; } + + public string? PostalCode { get; private set; } + + public string? CountryCode { get; private set; } + + public decimal? Latitude { get; private set; } + + public decimal? Longitude { get; private set; } + + public DateTime CreatedUtc { get; private set; } + + public DateTime UpdatedUtc { get; private set; } + + private StoreLocation() + { + } + + public StoreLocation( + Guid retailerId, + string name, + DateTime createdUtc, + string? externalStoreId = null, + string? addressLine1 = null, + string? addressLine2 = null, + string? city = null, + string? region = null, + string? postalCode = null, + string? countryCode = null, + decimal? latitude = null, + decimal? longitude = null) + { + if (retailerId == Guid.Empty) + { + throw new ArgumentException("RetailerId is required.", nameof(retailerId)); + } + + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Store name is required.", nameof(name)); + } + + StoreLocationId = Guid.NewGuid(); + RetailerId = retailerId; + Name = name.Trim(); + ExternalStoreId = externalStoreId; + AddressLine1 = addressLine1; + AddressLine2 = addressLine2; + City = city; + Region = region; + PostalCode = postalCode; + CountryCode = countryCode; + Latitude = latitude; + Longitude = longitude; + CreatedUtc = createdUtc; + UpdatedUtc = createdUtc; + } +} diff --git a/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs b/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs index d74bfed..5359bcc 100644 --- a/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs +++ b/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs @@ -33,6 +33,10 @@ public class CartWiseDbContext : IdentityDbContext, IApplicatio public DbSet HouseholdProductPreferences => Set(); + public DbSet Retailers => Set(); + + public DbSet StoreLocations => Set(); + protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); diff --git a/src/CartWise.Infrastructure/Data/Configurations/RetailerConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/RetailerConfiguration.cs new file mode 100644 index 0000000..40605d6 --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Configurations/RetailerConfiguration.cs @@ -0,0 +1,24 @@ +using CartWise.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CartWise.Infrastructure.Data.Configurations; + +public class RetailerConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(r => r.RetailerId); + + builder.Property(r => r.Name) + .HasMaxLength(160) + .IsRequired(); + + builder.Property(r => r.NormalizedName) + .HasMaxLength(160) + .IsRequired(); + + builder.HasIndex(r => r.NormalizedName) + .IsUnique(); + } +} diff --git a/src/CartWise.Infrastructure/Data/Configurations/StoreLocationConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/StoreLocationConfiguration.cs new file mode 100644 index 0000000..ec995cb --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Configurations/StoreLocationConfiguration.cs @@ -0,0 +1,32 @@ +using CartWise.Domain.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CartWise.Infrastructure.Data.Configurations; + +public class StoreLocationConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(s => s.StoreLocationId); + + builder.Property(s => s.Name) + .HasMaxLength(200) + .IsRequired(); + + builder.Property(s => s.ExternalStoreId).HasMaxLength(120); + builder.Property(s => s.AddressLine1).HasMaxLength(200); + builder.Property(s => s.AddressLine2).HasMaxLength(200); + builder.Property(s => s.City).HasMaxLength(120); + builder.Property(s => s.Region).HasMaxLength(80); + builder.Property(s => s.PostalCode).HasMaxLength(24); + builder.Property(s => s.CountryCode).HasMaxLength(2); + + builder.HasOne() + .WithMany() + .HasForeignKey(s => s.RetailerId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(s => s.RetailerId); + } +} diff --git a/src/CartWise.Infrastructure/Data/Migrations/20260810191230_AddRetailersAndStores.Designer.cs b/src/CartWise.Infrastructure/Data/Migrations/20260810191230_AddRetailersAndStores.Designer.cs new file mode 100644 index 0000000..381d2c3 --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Migrations/20260810191230_AddRetailersAndStores.Designer.cs @@ -0,0 +1,850 @@ +// +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("20260810191230_AddRetailersAndStores")] + partial class AddRetailersAndStores + { + /// + 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.Retailer", b => + { + b.Property("RetailerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("WebsiteUrl") + .HasColumnType("TEXT"); + + b.HasKey("RetailerId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Retailers"); + }); + + 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.Domain.Entities.StoreLocation", b => + { + b.Property("StoreLocationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddressLine1") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AddressLine2") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("City") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ExternalStoreId") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("TEXT"); + + b.Property("Longitude") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("Region") + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("RetailerId") + .HasColumnType("TEXT"); + + b.Property("UpdatedUtc") + .HasColumnType("TEXT"); + + b.HasKey("StoreLocationId"); + + b.HasIndex("RetailerId"); + + b.ToTable("StoreLocations"); + }); + + 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("CartWise.Domain.Entities.StoreLocation", b => + { + b.HasOne("CartWise.Domain.Entities.Retailer", null) + .WithMany() + .HasForeignKey("RetailerId") + .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/20260810191230_AddRetailersAndStores.cs b/src/CartWise.Infrastructure/Data/Migrations/20260810191230_AddRetailersAndStores.cs new file mode 100644 index 0000000..6e62e4b --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Migrations/20260810191230_AddRetailersAndStores.cs @@ -0,0 +1,80 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CartWise.Infrastructure.Data.Migrations +{ + /// + public partial class AddRetailersAndStores : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Retailers", + columns: table => new + { + RetailerId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 160, nullable: false), + NormalizedName = table.Column(type: "TEXT", maxLength: 160, nullable: false), + WebsiteUrl = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_Retailers", x => x.RetailerId); + }); + + migrationBuilder.CreateTable( + name: "StoreLocations", + columns: table => new + { + StoreLocationId = table.Column(type: "TEXT", nullable: false), + RetailerId = table.Column(type: "TEXT", nullable: false), + ExternalStoreId = table.Column(type: "TEXT", maxLength: 120, nullable: true), + Name = table.Column(type: "TEXT", maxLength: 200, nullable: false), + AddressLine1 = table.Column(type: "TEXT", maxLength: 200, nullable: true), + AddressLine2 = table.Column(type: "TEXT", maxLength: 200, nullable: true), + City = table.Column(type: "TEXT", maxLength: 120, nullable: true), + Region = table.Column(type: "TEXT", maxLength: 80, nullable: true), + PostalCode = table.Column(type: "TEXT", maxLength: 24, nullable: true), + CountryCode = table.Column(type: "TEXT", maxLength: 2, nullable: true), + Latitude = table.Column(type: "TEXT", nullable: true), + Longitude = table.Column(type: "TEXT", nullable: true), + CreatedUtc = table.Column(type: "TEXT", nullable: false), + UpdatedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StoreLocations", x => x.StoreLocationId); + table.ForeignKey( + name: "FK_StoreLocations_Retailers_RetailerId", + column: x => x.RetailerId, + principalTable: "Retailers", + principalColumn: "RetailerId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Retailers_NormalizedName", + table: "Retailers", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_StoreLocations_RetailerId", + table: "StoreLocations", + column: "RetailerId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "StoreLocations"); + + migrationBuilder.DropTable( + name: "Retailers"); + } + } +} diff --git a/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs b/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs index dd78696..feb5c60 100644 --- a/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs +++ b/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs @@ -268,6 +268,33 @@ namespace CartWise.Infrastructure.Data.Migrations b.ToTable("ProductIdentifiers"); }); + modelBuilder.Entity("CartWise.Domain.Entities.Retailer", b => + { + b.Property("RetailerId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.Property("WebsiteUrl") + .HasColumnType("TEXT"); + + b.HasKey("RetailerId"); + + b.HasIndex("NormalizedName") + .IsUnique(); + + b.ToTable("Retailers"); + }); + modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => { b.Property("ShoppingListId") @@ -363,6 +390,67 @@ namespace CartWise.Infrastructure.Data.Migrations b.ToTable("ShoppingListItems"); }); + modelBuilder.Entity("CartWise.Domain.Entities.StoreLocation", b => + { + b.Property("StoreLocationId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddressLine1") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("AddressLine2") + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("City") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("CountryCode") + .HasMaxLength(2) + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ExternalStoreId") + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("Latitude") + .HasColumnType("TEXT"); + + b.Property("Longitude") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("PostalCode") + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("Region") + .HasMaxLength(80) + .HasColumnType("TEXT"); + + b.Property("RetailerId") + .HasColumnType("TEXT"); + + b.Property("UpdatedUtc") + .HasColumnType("TEXT"); + + b.HasKey("StoreLocationId"); + + b.HasIndex("RetailerId"); + + b.ToTable("StoreLocations"); + }); + modelBuilder.Entity("CartWise.Infrastructure.Identity.ApplicationUser", b => { b.Property("Id") @@ -679,6 +767,15 @@ namespace CartWise.Infrastructure.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("CartWise.Domain.Entities.StoreLocation", b => + { + b.HasOne("CartWise.Domain.Entities.Retailer", null) + .WithMany() + .HasForeignKey("RetailerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) diff --git a/tests/CartWise.Domain.Tests/RetailerAndStoreLocationTests.cs b/tests/CartWise.Domain.Tests/RetailerAndStoreLocationTests.cs new file mode 100644 index 0000000..099ff96 --- /dev/null +++ b/tests/CartWise.Domain.Tests/RetailerAndStoreLocationTests.cs @@ -0,0 +1,53 @@ +using CartWise.Domain.Entities; + +namespace CartWise.Domain.Tests; + +public class RetailerAndStoreLocationTests +{ + private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Retailer_Constructor_SetsNormalizedName() + { + var retailer = new Retailer("Trader Joe's"); + + Assert.Equal("TRADER JOE'S", retailer.NormalizedName); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Retailer_Constructor_ThrowsWhenNameIsMissing(string? name) + { + Assert.Throws(() => new Retailer(name!)); + } + + [Fact] + public void StoreLocation_Constructor_SetsRequiredFields() + { + var retailer = new Retailer("Trader Joe's"); + + var store = new StoreLocation(retailer.RetailerId, "Downtown", Now, city: "Springfield"); + + Assert.Equal(retailer.RetailerId, store.RetailerId); + Assert.Equal("Downtown", store.Name); + Assert.Equal("Springfield", store.City); + Assert.Equal(Now, store.CreatedUtc); + } + + [Fact] + public void StoreLocation_Constructor_ThrowsWhenRetailerIdIsEmpty() + { + Assert.Throws(() => new StoreLocation(Guid.Empty, "Downtown", Now)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void StoreLocation_Constructor_ThrowsWhenNameIsMissing(string? name) + { + Assert.Throws(() => new StoreLocation(Guid.NewGuid(), name!, Now)); + } +}