Browse Source

CW-STORY-05.1: model retailers and store locations

Retailer/StoreLocation per AGENTS.md §5.5. DEC-011: store tracking is
optional everywhere (nullable FKs), so recording a purchase never
requires picking a store first.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
master
Daniel Covington 1 week ago
parent
commit
daef3e603e
12 changed files with 1262 additions and 6 deletions
  1. +2
    -1
      .claude/settings.json
  2. +8
    -0
      docs/decision-log.md
  3. +7
    -5
      docs/scrum-backlog.md
  4. +29
    -0
      src/CartWise.Domain/Entities/Retailer.cs
  5. +76
    -0
      src/CartWise.Domain/Entities/StoreLocation.cs
  6. +4
    -0
      src/CartWise.Infrastructure/Data/CartWiseDbContext.cs
  7. +24
    -0
      src/CartWise.Infrastructure/Data/Configurations/RetailerConfiguration.cs
  8. +32
    -0
      src/CartWise.Infrastructure/Data/Configurations/StoreLocationConfiguration.cs
  9. +850
    -0
      src/CartWise.Infrastructure/Data/Migrations/20260810191230_AddRetailersAndStores.Designer.cs
  10. +80
    -0
      src/CartWise.Infrastructure/Data/Migrations/20260810191230_AddRetailersAndStores.cs
  11. +97
    -0
      src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs
  12. +53
    -0
      tests/CartWise.Domain.Tests/RetailerAndStoreLocationTests.cs

+ 2
- 1
.claude/settings.json View File

@@ -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 '/<h1>/,/Add to my list/p' /tmp/product.html)"
"Bash(sed -n '/<h1>/,/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"


+ 8
- 0
docs/decision-log.md View File

@@ -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.

+ 7
- 5
docs/scrum-backlog.md View File

@@ -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


+ 29
- 0
src/CartWise.Domain/Entities/Retailer.cs View File

@@ -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;
}
}

+ 76
- 0
src/CartWise.Domain/Entities/StoreLocation.cs View File

@@ -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;
}
}

+ 4
- 0
src/CartWise.Infrastructure/Data/CartWiseDbContext.cs View File

@@ -33,6 +33,10 @@ public class CartWiseDbContext : IdentityDbContext<ApplicationUser>, IApplicatio

public DbSet<HouseholdProductPreference> HouseholdProductPreferences => Set<HouseholdProductPreference>();

public DbSet<Retailer> Retailers => Set<Retailer>();

public DbSet<StoreLocation> StoreLocations => Set<StoreLocation>();

protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);


+ 24
- 0
src/CartWise.Infrastructure/Data/Configurations/RetailerConfiguration.cs View File

@@ -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<Retailer>
{
public void Configure(EntityTypeBuilder<Retailer> 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();
}
}

+ 32
- 0
src/CartWise.Infrastructure/Data/Configurations/StoreLocationConfiguration.cs View File

@@ -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<StoreLocation>
{
public void Configure(EntityTypeBuilder<StoreLocation> 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<Retailer>()
.WithMany()
.HasForeignKey(s => s.RetailerId)
.OnDelete(DeleteBehavior.Cascade);

builder.HasIndex(s => s.RetailerId);
}
}

+ 850
- 0
src/CartWise.Infrastructure/Data/Migrations/20260810191230_AddRetailersAndStores.Designer.cs View File

@@ -0,0 +1,850 @@
// <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("20260810191230_AddRetailersAndStores")]
partial class AddRetailersAndStores
{
/// <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.Retailer", b =>
{
b.Property<Guid>("RetailerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");

b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("TEXT");

b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("TEXT");

b.Property<string>("WebsiteUrl")
.HasColumnType("TEXT");

b.HasKey("RetailerId");

b.HasIndex("NormalizedName")
.IsUnique();

b.ToTable("Retailers");
});

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.Domain.Entities.StoreLocation", b =>
{
b.Property<Guid>("StoreLocationId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");

b.Property<string>("AddressLine1")
.HasMaxLength(200)
.HasColumnType("TEXT");

b.Property<string>("AddressLine2")
.HasMaxLength(200)
.HasColumnType("TEXT");

b.Property<string>("City")
.HasMaxLength(120)
.HasColumnType("TEXT");

b.Property<string>("CountryCode")
.HasMaxLength(2)
.HasColumnType("TEXT");

b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");

b.Property<string>("ExternalStoreId")
.HasMaxLength(120)
.HasColumnType("TEXT");

b.Property<decimal?>("Latitude")
.HasColumnType("TEXT");

b.Property<decimal?>("Longitude")
.HasColumnType("TEXT");

b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");

b.Property<string>("PostalCode")
.HasMaxLength(24)
.HasColumnType("TEXT");

b.Property<string>("Region")
.HasMaxLength(80)
.HasColumnType("TEXT");

b.Property<Guid>("RetailerId")
.HasColumnType("TEXT");

b.Property<DateTime>("UpdatedUtc")
.HasColumnType("TEXT");

b.HasKey("StoreLocationId");

b.HasIndex("RetailerId");

b.ToTable("StoreLocations");
});

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("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<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
}
}
}

+ 80
- 0
src/CartWise.Infrastructure/Data/Migrations/20260810191230_AddRetailersAndStores.cs View File

@@ -0,0 +1,80 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;

#nullable disable

namespace CartWise.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddRetailersAndStores : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Retailers",
columns: table => new
{
RetailerId = 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),
WebsiteUrl = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Retailers", x => x.RetailerId);
});

migrationBuilder.CreateTable(
name: "StoreLocations",
columns: table => new
{
StoreLocationId = table.Column<Guid>(type: "TEXT", nullable: false),
RetailerId = table.Column<Guid>(type: "TEXT", nullable: false),
ExternalStoreId = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
Name = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
AddressLine1 = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
AddressLine2 = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
City = table.Column<string>(type: "TEXT", maxLength: 120, nullable: true),
Region = table.Column<string>(type: "TEXT", maxLength: 80, nullable: true),
PostalCode = table.Column<string>(type: "TEXT", maxLength: 24, nullable: true),
CountryCode = table.Column<string>(type: "TEXT", maxLength: 2, nullable: true),
Latitude = table.Column<decimal>(type: "TEXT", nullable: true),
Longitude = table.Column<decimal>(type: "TEXT", nullable: true),
CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedUtc = table.Column<DateTime>(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");
}

/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "StoreLocations");

migrationBuilder.DropTable(
name: "Retailers");
}
}
}

+ 97
- 0
src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs View File

@@ -268,6 +268,33 @@ namespace CartWise.Infrastructure.Data.Migrations
b.ToTable("ProductIdentifiers");
});

modelBuilder.Entity("CartWise.Domain.Entities.Retailer", b =>
{
b.Property<Guid>("RetailerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");

b.Property<string>("Name")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("TEXT");

b.Property<string>("NormalizedName")
.IsRequired()
.HasMaxLength(160)
.HasColumnType("TEXT");

b.Property<string>("WebsiteUrl")
.HasColumnType("TEXT");

b.HasKey("RetailerId");

b.HasIndex("NormalizedName")
.IsUnique();

b.ToTable("Retailers");
});

modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b =>
{
b.Property<Guid>("ShoppingListId")
@@ -363,6 +390,67 @@ namespace CartWise.Infrastructure.Data.Migrations
b.ToTable("ShoppingListItems");
});

modelBuilder.Entity("CartWise.Domain.Entities.StoreLocation", b =>
{
b.Property<Guid>("StoreLocationId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");

b.Property<string>("AddressLine1")
.HasMaxLength(200)
.HasColumnType("TEXT");

b.Property<string>("AddressLine2")
.HasMaxLength(200)
.HasColumnType("TEXT");

b.Property<string>("City")
.HasMaxLength(120)
.HasColumnType("TEXT");

b.Property<string>("CountryCode")
.HasMaxLength(2)
.HasColumnType("TEXT");

b.Property<DateTime>("CreatedUtc")
.HasColumnType("TEXT");

b.Property<string>("ExternalStoreId")
.HasMaxLength(120)
.HasColumnType("TEXT");

b.Property<decimal?>("Latitude")
.HasColumnType("TEXT");

b.Property<decimal?>("Longitude")
.HasColumnType("TEXT");

b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");

b.Property<string>("PostalCode")
.HasMaxLength(24)
.HasColumnType("TEXT");

b.Property<string>("Region")
.HasMaxLength(80)
.HasColumnType("TEXT");

b.Property<Guid>("RetailerId")
.HasColumnType("TEXT");

b.Property<DateTime>("UpdatedUtc")
.HasColumnType("TEXT");

b.HasKey("StoreLocationId");

b.HasIndex("RetailerId");

b.ToTable("StoreLocations");
});

modelBuilder.Entity("CartWise.Infrastructure.Identity.ApplicationUser", b =>
{
b.Property<string>("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<string>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)


+ 53
- 0
tests/CartWise.Domain.Tests/RetailerAndStoreLocationTests.cs View File

@@ -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<ArgumentException>(() => 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<ArgumentException>(() => new StoreLocation(Guid.Empty, "Downtown", Now));
}

[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData(null)]
public void StoreLocation_Constructor_ThrowsWhenNameIsMissing(string? name)
{
Assert.Throws<ArgumentException>(() => new StoreLocation(Guid.NewGuid(), name!, Now));
}
}

Loading…
Cancel
Save

Powered by TurnKey Linux.