Quellcode durchsuchen

CW-STORY-04.1: model product catalog entities

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
Daniel Covington vor 1 Woche
Ursprung
Commit
867fe0d09a
19 geänderte Dateien mit 1586 neuen und 9 gelöschten Zeilen
  1. +3
    -1
      .claude/settings.json
  2. +8
    -0
      docs/decision-log.md
  3. +10
    -8
      docs/scrum-backlog.md
  4. +26
    -0
      src/CartWise.Domain/Entities/Brand.cs
  5. +55
    -0
      src/CartWise.Domain/Entities/HouseholdProductPreference.cs
  6. +81
    -0
      src/CartWise.Domain/Entities/Product.cs
  7. +41
    -0
      src/CartWise.Domain/Entities/ProductIdentifier.cs
  8. +12
    -0
      src/CartWise.Domain/Enums/ProductIdentifierType.cs
  9. +9
    -0
      src/CartWise.Domain/Enums/ProductSourceType.cs
  10. +10
    -0
      src/CartWise.Domain/Enums/SubstitutionLevel.cs
  11. +8
    -0
      src/CartWise.Infrastructure/Data/CartWiseDbContext.cs
  12. +24
    -0
      src/CartWise.Infrastructure/Data/Configurations/BrandConfiguration.cs
  13. +34
    -0
      src/CartWise.Infrastructure/Data/Configurations/HouseholdProductPreferenceConfiguration.cs
  14. +42
    -0
      src/CartWise.Infrastructure/Data/Configurations/ProductConfiguration.cs
  15. +29
    -0
      src/CartWise.Infrastructure/Data/Configurations/ProductIdentifierConfiguration.cs
  16. +753
    -0
      src/CartWise.Infrastructure/Data/Migrations/20260810184326_AddProductCatalog.Designer.cs
  17. +183
    -0
      src/CartWise.Infrastructure/Data/Migrations/20260810184326_AddProductCatalog.cs
  18. +199
    -0
      src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs
  19. +59
    -0
      tests/CartWise.Domain.Tests/ProductTests.cs

+ 3
- 1
.claude/settings.json Datei anzeigen

@@ -30,7 +30,9 @@
"Bash(sed -n '/shopping-list-quick-add/,/<\\\\/form>/p' /tmp/list2.html)",
"Bash(sed -n '/shopping-list-item/,/<\\\\/li>/p' /tmp/list2.html)",
"Bash(curl -s http://127.0.0.1:5187/css/site.css)",
"PowerShell(\"done\")"
"PowerShell(\"done\")",
"Bash(grep -n \"^### \\\\`CW-STORY\\\\|^## \\\\`CW-EPIC\" docs/scrum-backlog.md)",
"Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify3.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify3.db\" 2>&1 | tail -15 && rm -f verify3.db*)"
],
"additionalDirectories": [
"G:\\development\\C Sharp AI\\CartWise"


+ 8
- 0
docs/decision-log.md Datei anzeigen

@@ -97,3 +97,11 @@ This file records product, scope, architecture, and delivery decisions for CartW
- Reason: AGENTS.md and the backlog use "current household" language without defining cardinality. One-per-user is the smallest working vertical slice — no household-switcher UI, no "which household" ambiguity anywhere downstream (list, purchases, running-low).
- Impact: No schema-level uniqueness constraint was added (`HouseholdMember`'s composite PK is `(HouseholdId, UserId)`, which would technically allow multiple rows for the same user), so this is enforced only at the `HouseholdService` application layer for now. Multi-household support remains possible later without a migration.
- Revisit Trigger: A real need emerges for one person to participate in more than one household (e.g. managing groceries for two homes).

### DEC-010 - Build order reverted to strict epic-numeric sequencing
- Date: 2026-08-10
- Status: Accepted
- Decision: Build remaining epics in strict numeric order (`CW-EPIC-04` → `CW-EPIC-05` → `CW-EPIC-06` → `CW-EPIC-07` → `CW-EPIC-08`) instead of the MVP-first order the backlog recommended (`05` → `07` → MVP-tagged `08` stories, with `04`/`06` deferred to after MVP).
- Reason: Explicit founder direction, given after being asked directly whether to keep the MVP-first order or override it.
- Impact: `CW-EPIC-04` (Product Catalog and Barcode Resolution) and `CW-EPIC-06` (Shopping Mode) are no longer sequenced after MVP — they will be built next, ahead of `CW-EPIC-05`/`07`. `DEC-003` (barcode out of MVP), `DEC-004` (external providers out of MVP), and `DEC-007` (shopping mode may be deferred) remain valid as *scope* decisions — none of that work becomes MVP-required by this change — but their *sequencing* guidance is superseded. Individual stories within `04`/`06` that carry their own explicit Decision Gate (e.g. "confirm whether a product catalog is still needed," "Open Food Facts integration," "camera scan flow") will still be raised with the founder individually as implementation reaches them; this decision only resolves ordering, not each gate's content. Building substantial Post-MVP scope now will likely push completion past the September 10, 2026 MVP target.
- Revisit Trigger: If schedule pressure toward September 10, 2026 becomes acute, reconsider reverting to MVP-first sequencing for whatever remains.

+ 10
- 8
docs/scrum-backlog.md Datei anzeigen

@@ -431,14 +431,16 @@ Two more real bugs found by tests (bringing the running total for `CW-EPIC-03` t
- EF configurations and migration are created

**Tasks**
- [ ] Create `Brand`
- [ ] Create `Product`
- [ ] Create `ProductIdentifier`
- [ ] Create `HouseholdProductPreference`
- [ ] Add source type enums
- [ ] Add indexes and normalized fields
- [ ] Add EF configurations
- [ ] Create migration
- [x] Create `Brand`
- [x] Create `Product`
- [x] Create `ProductIdentifier`
- [x] Create `HouseholdProductPreference`
- [x] Add source type enums
- [x] Add indexes and normalized fields
- [x] Add EF configurations
- [x] Create migration

**Status:** Done — per `DEC-010`, this epic is being built now ahead of MVP-tagged `CW-EPIC-05`/`07` at explicit founder direction (strict numeric epic order), overriding this story's own "confirm whether a product catalog is still needed" decision gate implicitly via that same direction. `Product` is the aggregate root for `ProductIdentifier` (`AddIdentifier`, mirroring `Household.AddMember`/`ShoppingList.AddItem`), rejecting a duplicate `(IdentifierType, NormalizedValue)` pair before it would ever hit the DB's unique index — same defense-in-depth pattern as `HouseholdService`'s one-active-list check. `ProductIdentifier.Normalize` is `public static` since `CW-STORY-04.3`'s barcode lookup will need the identical normalization before it ever constructs an identifier. `HouseholdProductPreference` enforces the `HouseholdId`+`GroceryConceptId` uniqueness from AGENTS.md §5.3 as a real unique index. `AddProductCatalog` migration verified applying cleanly.

### `CW-STORY-04.2` Search and view products
**Release:** Post-MVP


+ 26
- 0
src/CartWise.Domain/Entities/Brand.cs Datei anzeigen

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

+ 55
- 0
src/CartWise.Domain/Entities/HouseholdProductPreference.cs Datei anzeigen

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

+ 81
- 0
src/CartWise.Domain/Entities/Product.cs Datei anzeigen

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

+ 41
- 0
src/CartWise.Domain/Entities/ProductIdentifier.cs Datei anzeigen

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

+ 12
- 0
src/CartWise.Domain/Enums/ProductIdentifierType.cs Datei anzeigen

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

+ 9
- 0
src/CartWise.Domain/Enums/ProductSourceType.cs Datei anzeigen

@@ -0,0 +1,9 @@
namespace CartWise.Domain.Enums;

public enum ProductSourceType
{
Manual = 0,
OpenFoodFacts = 1,
Usda = 2,
Retailer = 3
}

+ 10
- 0
src/CartWise.Domain/Enums/SubstitutionLevel.cs Datei anzeigen

@@ -0,0 +1,10 @@
namespace CartWise.Domain.Enums;

public enum SubstitutionLevel
{
ExactProductOnly = 0,
SameBrand = 1,
SimilarPreferredBrands = 2,
AnyEquivalent = 3,
CheapestAcceptable = 4
}

+ 8
- 0
src/CartWise.Infrastructure/Data/CartWiseDbContext.cs Datei anzeigen

@@ -25,6 +25,14 @@ public class CartWiseDbContext : IdentityDbContext<ApplicationUser>, IApplicatio

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)
{
base.OnModelCreating(builder);


+ 24
- 0
src/CartWise.Infrastructure/Data/Configurations/BrandConfiguration.cs Datei anzeigen

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

+ 34
- 0
src/CartWise.Infrastructure/Data/Configurations/HouseholdProductPreferenceConfiguration.cs Datei anzeigen

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

+ 42
- 0
src/CartWise.Infrastructure/Data/Configurations/ProductConfiguration.cs Datei anzeigen

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

+ 29
- 0
src/CartWise.Infrastructure/Data/Configurations/ProductIdentifierConfiguration.cs Datei anzeigen

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

+ 753
- 0
src/CartWise.Infrastructure/Data/Migrations/20260810184326_AddProductCatalog.Designer.cs Datei anzeigen

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

+ 183
- 0
src/CartWise.Infrastructure/Data/Migrations/20260810184326_AddProductCatalog.cs Datei anzeigen

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

+ 199
- 0
src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs Datei anzeigen

@@ -17,6 +17,30 @@ namespace CartWise.Infrastructure.Data.Migrations
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");

modelBuilder.Entity("CartWise.Domain.Entities.Brand", b =>
{
b.Property<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")
@@ -95,6 +119,102 @@ namespace CartWise.Infrastructure.Data.Migrations
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")
@@ -116,6 +236,38 @@ namespace CartWise.Infrastructure.Data.Migrations
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")
@@ -442,6 +594,39 @@ namespace CartWise.Infrastructure.Data.Migrations
.IsRequired();
});

modelBuilder.Entity("CartWise.Domain.Entities.HouseholdProductPreference", b =>
{
b.HasOne("CartWise.Domain.Entities.GroceryConcept", null)
.WithMany()
.HasForeignKey("GroceryConceptId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();

b.HasOne("CartWise.Domain.Entities.Household", null)
.WithMany()
.HasForeignKey("HouseholdId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();

b.HasOne("CartWise.Domain.Entities.Product", null)
.WithMany()
.HasForeignKey("PreferredProductId")
.OnDelete(DeleteBehavior.SetNull);
});

modelBuilder.Entity("CartWise.Domain.Entities.Product", b =>
{
b.HasOne("CartWise.Domain.Entities.Brand", null)
.WithMany()
.HasForeignKey("BrandId")
.OnDelete(DeleteBehavior.SetNull);

b.HasOne("CartWise.Domain.Entities.GroceryConcept", null)
.WithMany()
.HasForeignKey("GroceryConceptId")
.OnDelete(DeleteBehavior.SetNull);
});

modelBuilder.Entity("CartWise.Domain.Entities.ProductCategory", b =>
{
b.HasOne("CartWise.Domain.Entities.ProductCategory", null)
@@ -450,6 +635,15 @@ namespace CartWise.Infrastructure.Data.Migrations
.OnDelete(DeleteBehavior.Restrict);
});

modelBuilder.Entity("CartWise.Domain.Entities.ProductIdentifier", b =>
{
b.HasOne("CartWise.Domain.Entities.Product", null)
.WithMany("Identifiers")
.HasForeignKey("ProductId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});

modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b =>
{
b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null)
@@ -541,6 +735,11 @@ namespace CartWise.Infrastructure.Data.Migrations
b.Navigation("Members");
});

modelBuilder.Entity("CartWise.Domain.Entities.Product", b =>
{
b.Navigation("Identifiers");
});

modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b =>
{
b.Navigation("Items");


+ 59
- 0
tests/CartWise.Domain.Tests/ProductTests.cs Datei anzeigen

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

Laden…
Abbrechen
Speichern

Powered by TurnKey Linux.