| @@ -19,7 +19,11 @@ | |||||
| "Bash(xargs -I{} sh -c 'echo \"=== {} ===\"; cat {}')", | "Bash(xargs -I{} sh -c 'echo \"=== {} ===\"; cat {}')", | ||||
| "Bash(rm -f \"g:/development/C Sharp AI/CartWise/src/CartWise.Web/App_Data/cartwise.db\"*)", | "Bash(rm -f \"g:/development/C Sharp AI/CartWise/src/CartWise.Web/App_Data/cartwise.db\"*)", | ||||
| "Bash(powershell -Command 'Get-Process | Where-Object { $_.Id -eq 55048 } | Select-Object Id, ProcessName, Path')", | "Bash(powershell -Command 'Get-Process | Where-Object { $_.Id -eq 55048 } | Select-Object Id, ProcessName, Path')", | ||||
| "Bash(powershell -Command 'Get-Process | Where-Object { $_.ProcessName -like '\\\\''*CartWise*'\\\\'' } | Select-Object Id, ProcessName, Path')" | |||||
| "Bash(powershell -Command 'Get-Process | Where-Object { $_.ProcessName -like '\\\\''*CartWise*'\\\\'' } | Select-Object Id, ProcessName, Path')", | |||||
| "Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify2.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify2.db\" 2>&1 | tail -15 && rm -f verify2.db*)" | |||||
| ], | |||||
| "additionalDirectories": [ | |||||
| "G:\\development\\C Sharp AI\\CartWise" | |||||
| ] | ] | ||||
| } | } | ||||
| } | } | ||||
| @@ -397,3 +397,7 @@ dotnet_diagnostic.CA1707.severity = none | |||||
| # generic types" guidance. | # generic types" guidance. | ||||
| [src/CartWise.Application/Results/**.cs] | [src/CartWise.Application/Results/**.cs] | ||||
| dotnet_diagnostic.CA1000.severity = none | dotnet_diagnostic.CA1000.severity = none | ||||
| # EF Core generates migrations; don't hand-edit generated code to satisfy analyzers. | |||||
| [src/CartWise.Infrastructure/Data/Migrations/**.cs] | |||||
| dotnet_diagnostic.CA1861.severity = none | |||||
| @@ -302,14 +302,16 @@ A story is done when: | |||||
| - Optimistic concurrency is supported for list items | - Optimistic concurrency is supported for list items | ||||
| **Tasks** | **Tasks** | ||||
| - [ ] Create `GroceryConcept` | |||||
| - [ ] Create `ProductCategory` | |||||
| - [ ] Create `ShoppingList` | |||||
| - [ ] Create `ShoppingListItem` | |||||
| - [ ] Add list status enums | |||||
| - [ ] Add row version concurrency token | |||||
| - [ ] Add EF configurations | |||||
| - [ ] Create migration | |||||
| - [x] Create `GroceryConcept` | |||||
| - [x] Create `ProductCategory` | |||||
| - [x] Create `ShoppingList` | |||||
| - [x] Create `ShoppingListItem` | |||||
| - [x] Add list status enums | |||||
| - [x] Add row version concurrency token | |||||
| - [x] Add EF configurations | |||||
| - [x] Create migration | |||||
| **Status:** Done — `ProductCategory`, `GroceryConcept`, `ShoppingList` (aggregate root with an `Items` backing-field navigation, mirroring `Household`/`HouseholdMember`), and `ShoppingListItem` added in `CartWise.Domain.Entities`; `ShoppingListStatus` (`Active`/`Completed`/`Archived`) and `ShoppingListItemStatus` (`Needed`/`Purchased`/`Skipped`/`Unavailable`/`Substituted`) in `CartWise.Domain.Enums`. Concurrency: `ShoppingListItem.RowVersion` is a `Guid` configured with `.IsConcurrencyToken()` rather than `.IsRowVersion()` — SQLite has no native auto-generated rowversion column, and `IsConcurrencyToken()` works identically on SQLite and a future PostgreSQL move (EF includes the original value in the `WHERE` clause either way); regenerating it on mutation is deferred to `CW-STORY-03.4`, which is where mutation methods land. Scope note: unlike `Household.AddMember`, no `ShoppingList.AddItem(...)` aggregate method exists yet — `ShoppingListItem`'s constructor is public for now since 03.1 is pure modeling and `CW-STORY-03.3` owns the add-item use case; that story should consider tightening `ShoppingListItem`'s constructor to `internal` behind an aggregate method, matching the `Household` pattern. `ShoppingListItem.PreferredProductId` from the AGENTS.md §5.4 spec was intentionally omitted — `Product` doesn't exist until the post-MVP `CW-EPIC-04`, and MVP explicitly doesn't require product resolution; it will be added via a future migration once `Product` exists. `AddShoppingList` migration created and verified applying cleanly against a scratch SQLite database. | |||||
| ### `CW-STORY-03.2` Implement active shopping list service | ### `CW-STORY-03.2` Implement active shopping list service | ||||
| **Release:** MVP | **Release:** MVP | ||||
| @@ -323,11 +325,13 @@ A story is done when: | |||||
| - Household rule for default active list is enforced | - Household rule for default active list is enforced | ||||
| **Tasks** | **Tasks** | ||||
| - [ ] Create `IShoppingListService` | |||||
| - [ ] Implement get active list | |||||
| - [ ] Implement create list | |||||
| - [ ] Enforce one default active list rule | |||||
| - [ ] Add application tests | |||||
| - [x] Create `IShoppingListService` | |||||
| - [x] Implement get active list | |||||
| - [x] Implement create list | |||||
| - [x] Enforce one default active list rule | |||||
| - [x] Add application tests | |||||
| **Status:** Done — `IShoppingListService`/`ShoppingListService` in `CartWise.Application.{Interfaces,Services}`, following the exact pattern established by `HouseholdService` in `CW-STORY-02.2`: `GetActiveListAsync` (read, includes `Items`) and `CreateListAsync` (returns `Result<ShoppingList>`, rejects a second active list per the AGENTS.md §5.4 v1 rule with a clear error message). `IApplicationDbContext` extended with `ShoppingLists`/`ShoppingListItems` DbSets. Registered in `AddApplication()`. 6 application tests added (EF InMemory, same harness as `HouseholdServiceTests`), including a cross-household isolation check mirroring the pattern from `CW-STORY-02.4`. Also fixed a `CA1861` warning surfaced by the `AddShoppingList` migration's composite-column indexes by scoping a suppression to `Data/Migrations/**` in `.editorconfig` — migrations are generated code and shouldn't be hand-edited to satisfy analyzers. | |||||
| ### `CW-STORY-03.3` Add grocery items quickly | ### `CW-STORY-03.3` Add grocery items quickly | ||||
| **Release:** MVP | **Release:** MVP | ||||
| @@ -342,12 +346,20 @@ A story is done when: | |||||
| - Product resolution is not required to add an item | - Product resolution is not required to add an item | ||||
| **Tasks** | **Tasks** | ||||
| - [ ] Implement add item use case | |||||
| - [ ] Support `DisplayName`, quantity, and unit | |||||
| - [ ] Add validation rules | |||||
| - [ ] Add item partial view | |||||
| - [ ] Add controller POST action | |||||
| - [ ] Add tests | |||||
| - [x] Implement add item use case | |||||
| - [x] Support `DisplayName`, quantity, and unit | |||||
| - [x] Add validation rules | |||||
| - [x] Add item partial view | |||||
| - [x] Add controller POST action | |||||
| - [x] Add tests | |||||
| **Status:** Done — `ShoppingList.AddItem(...)` aggregate method added (mirrors `Household.AddMember`; `ShoppingListItem`'s constructor is now `internal`), computing `SortOrder` and rejecting adds to a non-active list. `IShoppingListService.AddItemAsync` added. `ShoppingListController` (`GET /list`, `POST /list/items`) auto-provisions a household's first list (named "Groceries") on first visit — no separate "create list" UI step, matching AGENTS.md's "one active list is enough for MVP" and the First End-to-End Slice in `CLAUDE.md`. `Views/ShoppingList/Index.cshtml` + `_ListItem.cshtml` built (functional, Bootstrap-styled; the dedicated mobile-first/touch-friendly pass is `CW-STORY-03.5`). Scope note carried from `CW-STORY-03.1`'s status resolved: the constructor tightening happened here as anticipated. | |||||
| Two real bugs found and fixed via testing, not just the earlier apostrophe-encoding test bug: | |||||
| 1. **EF Core state-tracking bug**: adding a new `ShoppingListItem` purely by mutating the already-tracked parent `ShoppingList`'s backing-field collection (`list.AddItem(...)`) left the new item's state ambiguous to EF Core's change tracker — since `ShoppingListItemId` is a client-generated `Guid` (not store-generated), EF couldn't reliably infer `Added` vs `Modified`, and attempted an `UPDATE` on a row that didn't exist yet, throwing `DbUpdateConcurrencyException`. Fixed by explicitly calling `_db.ShoppingListItems.Add(item)` in `ShoppingListService.AddItemAsync` after `list.AddItem(...)`. | |||||
| 2. **Model-binding prefix mismatch**: `Views/ShoppingList/Index.cshtml`'s tag helpers generate field names like `NewItem.DisplayName` (based on `ShoppingListViewModel.NewItem`'s property path), but `ShoppingListController.AddItem` bound a bare, unprefixed `AddShoppingListItemViewModel model` parameter — so nothing bound, `DisplayName` silently came through empty, and (surprisingly) no validation error surfaced either. Fixed with `[Bind(Prefix = "NewItem")]` on the action parameter. | |||||
| Both were caught by genuine integration tests (`ShoppingListAccessControlTests`, real HTTP + real SQLite) written for this story, not by the weaker EF-InMemory application tests alone — worth remembering for future controller/view-model pairs that use a nested form-section pattern like this one. Extracted `WebTestHelpers` (register/create-household/extract-antiforgery-token) out of `HouseholdAccessControlTests` into a shared file so `ShoppingListAccessControlTests` didn't duplicate it. Verified live end-to-end via `dotnet run` + curl, matching `CLAUDE.md`'s First End-to-End Slice exactly: register → create household → `/list` auto-creates "Groceries" → add "Milk" (2 gal) → reload shows it with `Needed` status; empty-name submission correctly redisplays the form with a visible validation error (200, not a redirect). | |||||
| ### `CW-STORY-03.4` Update shopping list items | ### `CW-STORY-03.4` Update shopping list items | ||||
| **Release:** MVP | **Release:** MVP | ||||
| @@ -10,6 +10,7 @@ public static class ApplicationServiceCollectionExtensions | |||||
| { | { | ||||
| services.AddSingleton(TimeProvider.System); | services.AddSingleton(TimeProvider.System); | ||||
| services.AddScoped<IHouseholdService, HouseholdService>(); | services.AddScoped<IHouseholdService, HouseholdService>(); | ||||
| services.AddScoped<IShoppingListService, ShoppingListService>(); | |||||
| return services; | return services; | ||||
| } | } | ||||
| @@ -9,5 +9,9 @@ public interface IApplicationDbContext | |||||
| DbSet<HouseholdMember> HouseholdMembers { get; } | DbSet<HouseholdMember> HouseholdMembers { get; } | ||||
| DbSet<ShoppingList> ShoppingLists { get; } | |||||
| DbSet<ShoppingListItem> ShoppingListItems { get; } | |||||
| Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); | Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); | ||||
| } | } | ||||
| @@ -0,0 +1,19 @@ | |||||
| using CartWise.Application.Results; | |||||
| using CartWise.Domain.Entities; | |||||
| namespace CartWise.Application.Interfaces; | |||||
| public interface IShoppingListService | |||||
| { | |||||
| Task<ShoppingList?> GetActiveListAsync(Guid householdId, CancellationToken cancellationToken = default); | |||||
| Task<Result<ShoppingList>> CreateListAsync(Guid householdId, string name, string createdByUserId, CancellationToken cancellationToken = default); | |||||
| Task<Result<ShoppingListItem>> AddItemAsync( | |||||
| Guid shoppingListId, | |||||
| string displayName, | |||||
| decimal? quantity, | |||||
| string? unit, | |||||
| string addedByUserId, | |||||
| CancellationToken cancellationToken = default); | |||||
| } | |||||
| @@ -0,0 +1,91 @@ | |||||
| using CartWise.Application.Interfaces; | |||||
| using CartWise.Application.Results; | |||||
| using CartWise.Domain.Entities; | |||||
| using CartWise.Domain.Enums; | |||||
| using Microsoft.EntityFrameworkCore; | |||||
| namespace CartWise.Application.Services; | |||||
| public class ShoppingListService : IShoppingListService | |||||
| { | |||||
| private readonly IApplicationDbContext _db; | |||||
| private readonly TimeProvider _timeProvider; | |||||
| public ShoppingListService(IApplicationDbContext db, TimeProvider timeProvider) | |||||
| { | |||||
| _db = db; | |||||
| _timeProvider = timeProvider; | |||||
| } | |||||
| public async Task<ShoppingList?> GetActiveListAsync(Guid householdId, CancellationToken cancellationToken = default) | |||||
| { | |||||
| return await _db.ShoppingLists | |||||
| .Include(l => l.Items) | |||||
| .AsNoTracking() | |||||
| .Where(l => l.HouseholdId == householdId && l.Status == ShoppingListStatus.Active) | |||||
| .FirstOrDefaultAsync(cancellationToken); | |||||
| } | |||||
| public async Task<Result<ShoppingList>> CreateListAsync(Guid householdId, string name, string createdByUserId, CancellationToken cancellationToken = default) | |||||
| { | |||||
| var hasActiveList = await _db.ShoppingLists | |||||
| .AsNoTracking() | |||||
| .AnyAsync(l => l.HouseholdId == householdId && l.Status == ShoppingListStatus.Active, cancellationToken); | |||||
| if (hasActiveList) | |||||
| { | |||||
| return Result<ShoppingList>.Failure("This household already has an active shopping list."); | |||||
| } | |||||
| ShoppingList list; | |||||
| try | |||||
| { | |||||
| list = new ShoppingList(householdId, name, createdByUserId, _timeProvider.GetUtcNow().UtcDateTime); | |||||
| } | |||||
| catch (ArgumentException ex) | |||||
| { | |||||
| return Result<ShoppingList>.Failure(ex.Message); | |||||
| } | |||||
| _db.ShoppingLists.Add(list); | |||||
| await _db.SaveChangesAsync(cancellationToken); | |||||
| return Result<ShoppingList>.Success(list); | |||||
| } | |||||
| public async Task<Result<ShoppingListItem>> AddItemAsync( | |||||
| Guid shoppingListId, | |||||
| string displayName, | |||||
| decimal? quantity, | |||||
| string? unit, | |||||
| string addedByUserId, | |||||
| CancellationToken cancellationToken = default) | |||||
| { | |||||
| var list = await _db.ShoppingLists | |||||
| .Include(l => l.Items) | |||||
| .FirstOrDefaultAsync(l => l.ShoppingListId == shoppingListId, cancellationToken); | |||||
| if (list is null) | |||||
| { | |||||
| return Result<ShoppingListItem>.Failure("Shopping list not found."); | |||||
| } | |||||
| ShoppingListItem item; | |||||
| try | |||||
| { | |||||
| item = list.AddItem(displayName, quantity, unit, addedByUserId, _timeProvider.GetUtcNow().UtcDateTime); | |||||
| } | |||||
| catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) | |||||
| { | |||||
| return Result<ShoppingListItem>.Failure(ex.Message); | |||||
| } | |||||
| // EF Core can't reliably infer Added state for a new child discovered only via a mutated | |||||
| // navigation collection on an already-tracked parent, since ShoppingListItemId is a | |||||
| // client-generated Guid rather than a store-generated key. Mark it explicitly. | |||||
| _db.ShoppingListItems.Add(item); | |||||
| await _db.SaveChangesAsync(cancellationToken); | |||||
| return Result<ShoppingListItem>.Success(item); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,32 @@ | |||||
| namespace CartWise.Domain.Entities; | |||||
| public class GroceryConcept | |||||
| { | |||||
| public Guid GroceryConceptId { get; private set; } | |||||
| public string Name { get; private set; } = null!; | |||||
| public string NormalizedName { get; private set; } = null!; | |||||
| public Guid? CategoryId { get; private set; } | |||||
| public DateTime CreatedUtc { get; private set; } | |||||
| private GroceryConcept() | |||||
| { | |||||
| } | |||||
| public GroceryConcept(string name, DateTime createdUtc, Guid? categoryId = null) | |||||
| { | |||||
| if (string.IsNullOrWhiteSpace(name)) | |||||
| { | |||||
| throw new ArgumentException("Grocery concept name is required.", nameof(name)); | |||||
| } | |||||
| GroceryConceptId = Guid.NewGuid(); | |||||
| Name = name.Trim(); | |||||
| NormalizedName = Name.ToUpperInvariant(); | |||||
| CategoryId = categoryId; | |||||
| CreatedUtc = createdUtc; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,26 @@ | |||||
| namespace CartWise.Domain.Entities; | |||||
| public class ProductCategory | |||||
| { | |||||
| public Guid ProductCategoryId { get; private set; } | |||||
| public string Name { get; private set; } = null!; | |||||
| public Guid? ParentCategoryId { get; private set; } | |||||
| private ProductCategory() | |||||
| { | |||||
| } | |||||
| public ProductCategory(string name, Guid? parentCategoryId = null) | |||||
| { | |||||
| if (string.IsNullOrWhiteSpace(name)) | |||||
| { | |||||
| throw new ArgumentException("Category name is required.", nameof(name)); | |||||
| } | |||||
| ProductCategoryId = Guid.NewGuid(); | |||||
| Name = name.Trim(); | |||||
| ParentCategoryId = parentCategoryId; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,66 @@ | |||||
| using CartWise.Domain.Enums; | |||||
| namespace CartWise.Domain.Entities; | |||||
| public class ShoppingList | |||||
| { | |||||
| private readonly List<ShoppingListItem> _items = []; | |||||
| public Guid ShoppingListId { get; private set; } | |||||
| public Guid HouseholdId { get; private set; } | |||||
| public string Name { get; private set; } = null!; | |||||
| public ShoppingListStatus Status { get; private set; } | |||||
| public DateTime CreatedUtc { get; private set; } | |||||
| public DateTime? CompletedUtc { get; private set; } | |||||
| public string CreatedByUserId { get; private set; } = null!; | |||||
| public IReadOnlyCollection<ShoppingListItem> Items => _items.AsReadOnly(); | |||||
| private ShoppingList() | |||||
| { | |||||
| } | |||||
| public ShoppingList(Guid householdId, string name, string createdByUserId, DateTime createdUtc) | |||||
| { | |||||
| if (householdId == Guid.Empty) | |||||
| { | |||||
| throw new ArgumentException("HouseholdId is required.", nameof(householdId)); | |||||
| } | |||||
| if (string.IsNullOrWhiteSpace(name)) | |||||
| { | |||||
| throw new ArgumentException("Shopping list name is required.", nameof(name)); | |||||
| } | |||||
| if (string.IsNullOrWhiteSpace(createdByUserId)) | |||||
| { | |||||
| throw new ArgumentException("CreatedByUserId is required.", nameof(createdByUserId)); | |||||
| } | |||||
| ShoppingListId = Guid.NewGuid(); | |||||
| HouseholdId = householdId; | |||||
| Name = name.Trim(); | |||||
| Status = ShoppingListStatus.Active; | |||||
| CreatedUtc = createdUtc; | |||||
| CreatedByUserId = createdByUserId; | |||||
| } | |||||
| public ShoppingListItem AddItem(string displayName, decimal? quantity, string? unit, string addedByUserId, DateTime addedUtc) | |||||
| { | |||||
| if (Status != ShoppingListStatus.Active) | |||||
| { | |||||
| throw new InvalidOperationException("Cannot add items to a list that is not active."); | |||||
| } | |||||
| var sortOrder = _items.Count == 0 ? 0 : _items.Max(i => i.SortOrder) + 1; | |||||
| var item = new ShoppingListItem(ShoppingListId, displayName, quantity, unit, sortOrder, addedByUserId, addedUtc); | |||||
| _items.Add(item); | |||||
| return item; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,72 @@ | |||||
| using CartWise.Domain.Enums; | |||||
| namespace CartWise.Domain.Entities; | |||||
| public class ShoppingListItem | |||||
| { | |||||
| public Guid ShoppingListItemId { get; private set; } | |||||
| public Guid ShoppingListId { get; private set; } | |||||
| public Guid? GroceryConceptId { get; private set; } | |||||
| public string DisplayName { get; private set; } = null!; | |||||
| public decimal? Quantity { get; private set; } | |||||
| public string? Unit { get; private set; } | |||||
| public ShoppingListItemStatus Status { get; private set; } | |||||
| public int SortOrder { get; private set; } | |||||
| public string AddedByUserId { get; private set; } = null!; | |||||
| public DateTime AddedUtc { get; private set; } | |||||
| public DateTime? PurchasedUtc { get; private set; } | |||||
| public string? Notes { get; private set; } | |||||
| public Guid RowVersion { get; private set; } | |||||
| private ShoppingListItem() | |||||
| { | |||||
| } | |||||
| internal ShoppingListItem( | |||||
| Guid shoppingListId, | |||||
| string displayName, | |||||
| decimal? quantity, | |||||
| string? unit, | |||||
| int sortOrder, | |||||
| string addedByUserId, | |||||
| DateTime addedUtc) | |||||
| { | |||||
| if (shoppingListId == Guid.Empty) | |||||
| { | |||||
| throw new ArgumentException("ShoppingListId is required.", nameof(shoppingListId)); | |||||
| } | |||||
| if (string.IsNullOrWhiteSpace(displayName)) | |||||
| { | |||||
| throw new ArgumentException("DisplayName is required.", nameof(displayName)); | |||||
| } | |||||
| if (string.IsNullOrWhiteSpace(addedByUserId)) | |||||
| { | |||||
| throw new ArgumentException("AddedByUserId is required.", nameof(addedByUserId)); | |||||
| } | |||||
| ShoppingListItemId = Guid.NewGuid(); | |||||
| ShoppingListId = shoppingListId; | |||||
| DisplayName = displayName.Trim(); | |||||
| Quantity = quantity; | |||||
| Unit = unit; | |||||
| Status = ShoppingListItemStatus.Needed; | |||||
| SortOrder = sortOrder; | |||||
| AddedByUserId = addedByUserId; | |||||
| AddedUtc = addedUtc; | |||||
| RowVersion = Guid.NewGuid(); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,10 @@ | |||||
| namespace CartWise.Domain.Enums; | |||||
| public enum ShoppingListItemStatus | |||||
| { | |||||
| Needed = 0, | |||||
| Purchased = 1, | |||||
| Skipped = 2, | |||||
| Unavailable = 3, | |||||
| Substituted = 4 | |||||
| } | |||||
| @@ -0,0 +1,8 @@ | |||||
| namespace CartWise.Domain.Enums; | |||||
| public enum ShoppingListStatus | |||||
| { | |||||
| Active = 0, | |||||
| Completed = 1, | |||||
| Archived = 2 | |||||
| } | |||||
| @@ -17,6 +17,14 @@ public class CartWiseDbContext : IdentityDbContext<ApplicationUser>, IApplicatio | |||||
| public DbSet<HouseholdMember> HouseholdMembers => Set<HouseholdMember>(); | public DbSet<HouseholdMember> HouseholdMembers => Set<HouseholdMember>(); | ||||
| public DbSet<ProductCategory> ProductCategories => Set<ProductCategory>(); | |||||
| public DbSet<GroceryConcept> GroceryConcepts => Set<GroceryConcept>(); | |||||
| public DbSet<ShoppingList> ShoppingLists => Set<ShoppingList>(); | |||||
| public DbSet<ShoppingListItem> ShoppingListItems => Set<ShoppingListItem>(); | |||||
| protected override void OnModelCreating(ModelBuilder builder) | protected override void OnModelCreating(ModelBuilder builder) | ||||
| { | { | ||||
| base.OnModelCreating(builder); | base.OnModelCreating(builder); | ||||
| @@ -0,0 +1,29 @@ | |||||
| using CartWise.Domain.Entities; | |||||
| using Microsoft.EntityFrameworkCore; | |||||
| using Microsoft.EntityFrameworkCore.Metadata.Builders; | |||||
| namespace CartWise.Infrastructure.Data.Configurations; | |||||
| public class GroceryConceptConfiguration : IEntityTypeConfiguration<GroceryConcept> | |||||
| { | |||||
| public void Configure(EntityTypeBuilder<GroceryConcept> builder) | |||||
| { | |||||
| builder.HasKey(c => c.GroceryConceptId); | |||||
| builder.Property(c => c.Name) | |||||
| .HasMaxLength(160) | |||||
| .IsRequired(); | |||||
| builder.Property(c => c.NormalizedName) | |||||
| .HasMaxLength(160) | |||||
| .IsRequired(); | |||||
| builder.HasIndex(c => c.NormalizedName) | |||||
| .IsUnique(); | |||||
| builder.HasOne<ProductCategory>() | |||||
| .WithMany() | |||||
| .HasForeignKey(c => c.CategoryId) | |||||
| .OnDelete(DeleteBehavior.SetNull); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,22 @@ | |||||
| using CartWise.Domain.Entities; | |||||
| using Microsoft.EntityFrameworkCore; | |||||
| using Microsoft.EntityFrameworkCore.Metadata.Builders; | |||||
| namespace CartWise.Infrastructure.Data.Configurations; | |||||
| public class ProductCategoryConfiguration : IEntityTypeConfiguration<ProductCategory> | |||||
| { | |||||
| public void Configure(EntityTypeBuilder<ProductCategory> builder) | |||||
| { | |||||
| builder.HasKey(c => c.ProductCategoryId); | |||||
| builder.Property(c => c.Name) | |||||
| .HasMaxLength(160) | |||||
| .IsRequired(); | |||||
| builder.HasOne<ProductCategory>() | |||||
| .WithMany() | |||||
| .HasForeignKey(c => c.ParentCategoryId) | |||||
| .OnDelete(DeleteBehavior.Restrict); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,33 @@ | |||||
| using CartWise.Domain.Entities; | |||||
| using CartWise.Infrastructure.Identity; | |||||
| using Microsoft.EntityFrameworkCore; | |||||
| using Microsoft.EntityFrameworkCore.Metadata.Builders; | |||||
| namespace CartWise.Infrastructure.Data.Configurations; | |||||
| public class ShoppingListConfiguration : IEntityTypeConfiguration<ShoppingList> | |||||
| { | |||||
| public void Configure(EntityTypeBuilder<ShoppingList> builder) | |||||
| { | |||||
| builder.HasKey(l => l.ShoppingListId); | |||||
| builder.Property(l => l.Name) | |||||
| .HasMaxLength(160) | |||||
| .IsRequired(); | |||||
| builder.HasOne<Household>() | |||||
| .WithMany() | |||||
| .HasForeignKey(l => l.HouseholdId) | |||||
| .OnDelete(DeleteBehavior.Cascade); | |||||
| builder.HasOne<ApplicationUser>() | |||||
| .WithMany() | |||||
| .HasForeignKey(l => l.CreatedByUserId) | |||||
| .OnDelete(DeleteBehavior.Restrict); | |||||
| builder.HasIndex(l => new { l.HouseholdId, l.Status }); | |||||
| builder.Metadata.FindNavigation(nameof(ShoppingList.Items))! | |||||
| .SetPropertyAccessMode(PropertyAccessMode.Field); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,44 @@ | |||||
| using CartWise.Domain.Entities; | |||||
| using CartWise.Infrastructure.Identity; | |||||
| using Microsoft.EntityFrameworkCore; | |||||
| using Microsoft.EntityFrameworkCore.Metadata.Builders; | |||||
| namespace CartWise.Infrastructure.Data.Configurations; | |||||
| public class ShoppingListItemConfiguration : IEntityTypeConfiguration<ShoppingListItem> | |||||
| { | |||||
| public void Configure(EntityTypeBuilder<ShoppingListItem> builder) | |||||
| { | |||||
| builder.HasKey(i => i.ShoppingListItemId); | |||||
| builder.Property(i => i.DisplayName) | |||||
| .HasMaxLength(240) | |||||
| .IsRequired(); | |||||
| builder.Property(i => i.Unit) | |||||
| .HasMaxLength(32); | |||||
| builder.Property(i => i.Notes) | |||||
| .HasMaxLength(500); | |||||
| builder.Property(i => i.RowVersion) | |||||
| .IsConcurrencyToken(); | |||||
| builder.HasOne<ShoppingList>() | |||||
| .WithMany(l => l.Items) | |||||
| .HasForeignKey(i => i.ShoppingListId) | |||||
| .OnDelete(DeleteBehavior.Cascade); | |||||
| builder.HasOne<GroceryConcept>() | |||||
| .WithMany() | |||||
| .HasForeignKey(i => i.GroceryConceptId) | |||||
| .OnDelete(DeleteBehavior.SetNull); | |||||
| builder.HasOne<ApplicationUser>() | |||||
| .WithMany() | |||||
| .HasForeignKey(i => i.AddedByUserId) | |||||
| .OnDelete(DeleteBehavior.Restrict); | |||||
| builder.HasIndex(i => new { i.ShoppingListId, i.Status }); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,554 @@ | |||||
| // <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("20260810165235_AddShoppingList")] | |||||
| partial class AddShoppingList | |||||
| { | |||||
| /// <inheritdoc /> | |||||
| protected override void BuildTargetModel(ModelBuilder modelBuilder) | |||||
| { | |||||
| #pragma warning disable 612, 618 | |||||
| modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); | |||||
| 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.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.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.ProductCategory", b => | |||||
| { | |||||
| b.HasOne("CartWise.Domain.Entities.ProductCategory", null) | |||||
| .WithMany() | |||||
| .HasForeignKey("ParentCategoryId") | |||||
| .OnDelete(DeleteBehavior.Restrict); | |||||
| }); | |||||
| 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.ShoppingList", b => | |||||
| { | |||||
| b.Navigation("Items"); | |||||
| }); | |||||
| #pragma warning restore 612, 618 | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,182 @@ | |||||
| using System; | |||||
| using Microsoft.EntityFrameworkCore.Migrations; | |||||
| #nullable disable | |||||
| namespace CartWise.Infrastructure.Data.Migrations | |||||
| { | |||||
| /// <inheritdoc /> | |||||
| public partial class AddShoppingList : Migration | |||||
| { | |||||
| /// <inheritdoc /> | |||||
| protected override void Up(MigrationBuilder migrationBuilder) | |||||
| { | |||||
| migrationBuilder.CreateTable( | |||||
| name: "ProductCategories", | |||||
| columns: table => new | |||||
| { | |||||
| ProductCategoryId = table.Column<Guid>(type: "TEXT", nullable: false), | |||||
| Name = table.Column<string>(type: "TEXT", maxLength: 160, nullable: false), | |||||
| ParentCategoryId = table.Column<Guid>(type: "TEXT", nullable: true) | |||||
| }, | |||||
| constraints: table => | |||||
| { | |||||
| table.PrimaryKey("PK_ProductCategories", x => x.ProductCategoryId); | |||||
| table.ForeignKey( | |||||
| name: "FK_ProductCategories_ProductCategories_ParentCategoryId", | |||||
| column: x => x.ParentCategoryId, | |||||
| principalTable: "ProductCategories", | |||||
| principalColumn: "ProductCategoryId", | |||||
| onDelete: ReferentialAction.Restrict); | |||||
| }); | |||||
| migrationBuilder.CreateTable( | |||||
| name: "ShoppingLists", | |||||
| columns: table => new | |||||
| { | |||||
| ShoppingListId = table.Column<Guid>(type: "TEXT", nullable: false), | |||||
| HouseholdId = table.Column<Guid>(type: "TEXT", nullable: false), | |||||
| Name = table.Column<string>(type: "TEXT", maxLength: 160, nullable: false), | |||||
| Status = table.Column<int>(type: "INTEGER", nullable: false), | |||||
| CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false), | |||||
| CompletedUtc = table.Column<DateTime>(type: "TEXT", nullable: true), | |||||
| CreatedByUserId = table.Column<string>(type: "TEXT", nullable: false) | |||||
| }, | |||||
| constraints: table => | |||||
| { | |||||
| table.PrimaryKey("PK_ShoppingLists", x => x.ShoppingListId); | |||||
| table.ForeignKey( | |||||
| name: "FK_ShoppingLists_AspNetUsers_CreatedByUserId", | |||||
| column: x => x.CreatedByUserId, | |||||
| principalTable: "AspNetUsers", | |||||
| principalColumn: "Id", | |||||
| onDelete: ReferentialAction.Restrict); | |||||
| table.ForeignKey( | |||||
| name: "FK_ShoppingLists_Households_HouseholdId", | |||||
| column: x => x.HouseholdId, | |||||
| principalTable: "Households", | |||||
| principalColumn: "HouseholdId", | |||||
| onDelete: ReferentialAction.Cascade); | |||||
| }); | |||||
| migrationBuilder.CreateTable( | |||||
| name: "GroceryConcepts", | |||||
| columns: table => new | |||||
| { | |||||
| GroceryConceptId = 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), | |||||
| CategoryId = table.Column<Guid>(type: "TEXT", nullable: true), | |||||
| CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false) | |||||
| }, | |||||
| constraints: table => | |||||
| { | |||||
| table.PrimaryKey("PK_GroceryConcepts", x => x.GroceryConceptId); | |||||
| table.ForeignKey( | |||||
| name: "FK_GroceryConcepts_ProductCategories_CategoryId", | |||||
| column: x => x.CategoryId, | |||||
| principalTable: "ProductCategories", | |||||
| principalColumn: "ProductCategoryId", | |||||
| onDelete: ReferentialAction.SetNull); | |||||
| }); | |||||
| migrationBuilder.CreateTable( | |||||
| name: "ShoppingListItems", | |||||
| columns: table => new | |||||
| { | |||||
| ShoppingListItemId = table.Column<Guid>(type: "TEXT", nullable: false), | |||||
| ShoppingListId = table.Column<Guid>(type: "TEXT", nullable: false), | |||||
| GroceryConceptId = table.Column<Guid>(type: "TEXT", nullable: true), | |||||
| DisplayName = table.Column<string>(type: "TEXT", maxLength: 240, nullable: false), | |||||
| Quantity = table.Column<decimal>(type: "TEXT", nullable: true), | |||||
| Unit = table.Column<string>(type: "TEXT", maxLength: 32, nullable: true), | |||||
| Status = table.Column<int>(type: "INTEGER", nullable: false), | |||||
| SortOrder = table.Column<int>(type: "INTEGER", nullable: false), | |||||
| AddedByUserId = table.Column<string>(type: "TEXT", nullable: false), | |||||
| AddedUtc = table.Column<DateTime>(type: "TEXT", nullable: false), | |||||
| PurchasedUtc = table.Column<DateTime>(type: "TEXT", nullable: true), | |||||
| Notes = table.Column<string>(type: "TEXT", maxLength: 500, nullable: true), | |||||
| RowVersion = table.Column<Guid>(type: "TEXT", nullable: false) | |||||
| }, | |||||
| constraints: table => | |||||
| { | |||||
| table.PrimaryKey("PK_ShoppingListItems", x => x.ShoppingListItemId); | |||||
| table.ForeignKey( | |||||
| name: "FK_ShoppingListItems_AspNetUsers_AddedByUserId", | |||||
| column: x => x.AddedByUserId, | |||||
| principalTable: "AspNetUsers", | |||||
| principalColumn: "Id", | |||||
| onDelete: ReferentialAction.Restrict); | |||||
| table.ForeignKey( | |||||
| name: "FK_ShoppingListItems_GroceryConcepts_GroceryConceptId", | |||||
| column: x => x.GroceryConceptId, | |||||
| principalTable: "GroceryConcepts", | |||||
| principalColumn: "GroceryConceptId", | |||||
| onDelete: ReferentialAction.SetNull); | |||||
| table.ForeignKey( | |||||
| name: "FK_ShoppingListItems_ShoppingLists_ShoppingListId", | |||||
| column: x => x.ShoppingListId, | |||||
| principalTable: "ShoppingLists", | |||||
| principalColumn: "ShoppingListId", | |||||
| onDelete: ReferentialAction.Cascade); | |||||
| }); | |||||
| migrationBuilder.CreateIndex( | |||||
| name: "IX_GroceryConcepts_CategoryId", | |||||
| table: "GroceryConcepts", | |||||
| column: "CategoryId"); | |||||
| migrationBuilder.CreateIndex( | |||||
| name: "IX_GroceryConcepts_NormalizedName", | |||||
| table: "GroceryConcepts", | |||||
| column: "NormalizedName", | |||||
| unique: true); | |||||
| migrationBuilder.CreateIndex( | |||||
| name: "IX_ProductCategories_ParentCategoryId", | |||||
| table: "ProductCategories", | |||||
| column: "ParentCategoryId"); | |||||
| migrationBuilder.CreateIndex( | |||||
| name: "IX_ShoppingListItems_AddedByUserId", | |||||
| table: "ShoppingListItems", | |||||
| column: "AddedByUserId"); | |||||
| migrationBuilder.CreateIndex( | |||||
| name: "IX_ShoppingListItems_GroceryConceptId", | |||||
| table: "ShoppingListItems", | |||||
| column: "GroceryConceptId"); | |||||
| migrationBuilder.CreateIndex( | |||||
| name: "IX_ShoppingListItems_ShoppingListId_Status", | |||||
| table: "ShoppingListItems", | |||||
| columns: new[] { "ShoppingListId", "Status" }); | |||||
| migrationBuilder.CreateIndex( | |||||
| name: "IX_ShoppingLists_CreatedByUserId", | |||||
| table: "ShoppingLists", | |||||
| column: "CreatedByUserId"); | |||||
| migrationBuilder.CreateIndex( | |||||
| name: "IX_ShoppingLists_HouseholdId_Status", | |||||
| table: "ShoppingLists", | |||||
| columns: new[] { "HouseholdId", "Status" }); | |||||
| } | |||||
| /// <inheritdoc /> | |||||
| protected override void Down(MigrationBuilder migrationBuilder) | |||||
| { | |||||
| migrationBuilder.DropTable( | |||||
| name: "ShoppingListItems"); | |||||
| migrationBuilder.DropTable( | |||||
| name: "GroceryConcepts"); | |||||
| migrationBuilder.DropTable( | |||||
| name: "ShoppingLists"); | |||||
| migrationBuilder.DropTable( | |||||
| name: "ProductCategories"); | |||||
| } | |||||
| } | |||||
| } | |||||
| @@ -17,6 +17,38 @@ namespace CartWise.Infrastructure.Data.Migrations | |||||
| #pragma warning disable 612, 618 | #pragma warning disable 612, 618 | ||||
| modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); | modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); | ||||
| modelBuilder.Entity("CartWise.Domain.Entities.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 => | modelBuilder.Entity("CartWise.Domain.Entities.Household", b => | ||||
| { | { | ||||
| b.Property<Guid>("HouseholdId") | b.Property<Guid>("HouseholdId") | ||||
| @@ -63,6 +95,122 @@ namespace CartWise.Infrastructure.Data.Migrations | |||||
| b.ToTable("HouseholdMembers"); | b.ToTable("HouseholdMembers"); | ||||
| }); | }); | ||||
| 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.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 => | modelBuilder.Entity("CartWise.Infrastructure.Identity.ApplicationUser", b => | ||||
| { | { | ||||
| b.Property<string>("Id") | b.Property<string>("Id") | ||||
| @@ -262,6 +410,14 @@ namespace CartWise.Infrastructure.Data.Migrations | |||||
| b.ToTable("AspNetUserTokens", (string)null); | 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 => | modelBuilder.Entity("CartWise.Domain.Entities.Household", b => | ||||
| { | { | ||||
| b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) | b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) | ||||
| @@ -286,6 +442,49 @@ namespace CartWise.Infrastructure.Data.Migrations | |||||
| .IsRequired(); | .IsRequired(); | ||||
| }); | }); | ||||
| 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.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 => | modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => | ||||
| { | { | ||||
| b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) | b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) | ||||
| @@ -341,6 +540,11 @@ namespace CartWise.Infrastructure.Data.Migrations | |||||
| { | { | ||||
| b.Navigation("Members"); | b.Navigation("Members"); | ||||
| }); | }); | ||||
| modelBuilder.Entity("CartWise.Domain.Entities.ShoppingList", b => | |||||
| { | |||||
| b.Navigation("Items"); | |||||
| }); | |||||
| #pragma warning restore 612, 618 | #pragma warning restore 612, 618 | ||||
| } | } | ||||
| } | } | ||||
| @@ -0,0 +1,115 @@ | |||||
| using System.Globalization; | |||||
| using CartWise.Application.Interfaces; | |||||
| using CartWise.Infrastructure.Identity; | |||||
| using CartWise.Web.ViewModels.ShoppingList; | |||||
| using Microsoft.AspNetCore.Authorization; | |||||
| using Microsoft.AspNetCore.Identity; | |||||
| using Microsoft.AspNetCore.Mvc; | |||||
| namespace CartWise.Web.Controllers; | |||||
| [Authorize] | |||||
| [Route("list")] | |||||
| public class ShoppingListController : Controller | |||||
| { | |||||
| private readonly IShoppingListService _shoppingListService; | |||||
| private readonly IHouseholdService _householdService; | |||||
| private readonly UserManager<ApplicationUser> _userManager; | |||||
| public ShoppingListController( | |||||
| IShoppingListService shoppingListService, | |||||
| IHouseholdService householdService, | |||||
| UserManager<ApplicationUser> userManager) | |||||
| { | |||||
| _shoppingListService = shoppingListService; | |||||
| _householdService = householdService; | |||||
| _userManager = userManager; | |||||
| } | |||||
| [HttpGet("")] | |||||
| public async Task<IActionResult> Index() | |||||
| { | |||||
| var userId = _userManager.GetUserId(User)!; | |||||
| var household = await _householdService.GetCurrentHouseholdAsync(userId); | |||||
| if (household is null) | |||||
| { | |||||
| return RedirectToAction("Create", "Household"); | |||||
| } | |||||
| var list = await GetOrCreateActiveListAsync(household.HouseholdId, userId); | |||||
| return View(ToViewModel(list)); | |||||
| } | |||||
| [HttpPost("items")] | |||||
| [ValidateAntiForgeryToken] | |||||
| public async Task<IActionResult> AddItem([Bind(Prefix = "NewItem")] AddShoppingListItemViewModel model) | |||||
| { | |||||
| var userId = _userManager.GetUserId(User)!; | |||||
| var household = await _householdService.GetCurrentHouseholdAsync(userId); | |||||
| if (household is null) | |||||
| { | |||||
| return RedirectToAction("Create", "Household"); | |||||
| } | |||||
| var list = await GetOrCreateActiveListAsync(household.HouseholdId, userId); | |||||
| if (!ModelState.IsValid) | |||||
| { | |||||
| var viewModel = ToViewModel(list); | |||||
| viewModel.NewItem = model; | |||||
| return View(nameof(Index), viewModel); | |||||
| } | |||||
| var result = await _shoppingListService.AddItemAsync(list.ShoppingListId, model.DisplayName, model.Quantity, model.Unit, userId); | |||||
| if (!result.IsSuccess) | |||||
| { | |||||
| ModelState.AddModelError(string.Empty, result.Error!); | |||||
| var viewModel = ToViewModel(list); | |||||
| viewModel.NewItem = model; | |||||
| return View(nameof(Index), viewModel); | |||||
| } | |||||
| return RedirectToAction(nameof(Index)); | |||||
| } | |||||
| private async Task<Domain.Entities.ShoppingList> GetOrCreateActiveListAsync(Guid householdId, string userId) | |||||
| { | |||||
| var list = await _shoppingListService.GetActiveListAsync(householdId); | |||||
| if (list is not null) | |||||
| { | |||||
| return list; | |||||
| } | |||||
| var createResult = await _shoppingListService.CreateListAsync(householdId, "Groceries", userId); | |||||
| return createResult.Value ?? await _shoppingListService.GetActiveListAsync(householdId) | |||||
| ?? throw new InvalidOperationException("Unable to resolve the household's active shopping list."); | |||||
| } | |||||
| private static ShoppingListViewModel ToViewModel(Domain.Entities.ShoppingList list) => new() | |||||
| { | |||||
| ShoppingListId = list.ShoppingListId, | |||||
| Name = list.Name, | |||||
| Items = list.Items | |||||
| .OrderBy(i => i.SortOrder) | |||||
| .Select(i => new ShoppingListItemViewModel | |||||
| { | |||||
| Id = i.ShoppingListItemId, | |||||
| DisplayName = i.DisplayName, | |||||
| QuantityDisplay = FormatQuantity(i.Quantity, i.Unit), | |||||
| Status = i.Status, | |||||
| ConcurrencyToken = i.RowVersion | |||||
| }) | |||||
| .ToList() | |||||
| }; | |||||
| private static string? FormatQuantity(decimal? quantity, string? unit) | |||||
| { | |||||
| if (quantity is null) | |||||
| { | |||||
| return unit; | |||||
| } | |||||
| var quantityText = quantity.Value.ToString("0.###", CultureInfo.InvariantCulture); | |||||
| return unit is null ? quantityText : $"{quantityText} {unit}"; | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,17 @@ | |||||
| using System.ComponentModel.DataAnnotations; | |||||
| namespace CartWise.Web.ViewModels.ShoppingList; | |||||
| public class AddShoppingListItemViewModel | |||||
| { | |||||
| [Required] | |||||
| [Display(Name = "Item")] | |||||
| [StringLength(240, MinimumLength = 1)] | |||||
| public string DisplayName { get; set; } = string.Empty; | |||||
| [Range(0.001, 100000)] | |||||
| public decimal? Quantity { get; set; } | |||||
| [StringLength(32)] | |||||
| public string? Unit { get; set; } | |||||
| } | |||||
| @@ -0,0 +1,27 @@ | |||||
| using CartWise.Domain.Enums; | |||||
| namespace CartWise.Web.ViewModels.ShoppingList; | |||||
| public class ShoppingListViewModel | |||||
| { | |||||
| public Guid ShoppingListId { get; set; } | |||||
| public string Name { get; set; } = string.Empty; | |||||
| public List<ShoppingListItemViewModel> Items { get; set; } = []; | |||||
| public AddShoppingListItemViewModel NewItem { get; set; } = new(); | |||||
| } | |||||
| public class ShoppingListItemViewModel | |||||
| { | |||||
| public Guid Id { get; set; } | |||||
| public string DisplayName { get; set; } = string.Empty; | |||||
| public string? QuantityDisplay { get; set; } | |||||
| public ShoppingListItemStatus Status { get; set; } | |||||
| public Guid ConcurrencyToken { get; set; } | |||||
| } | |||||
| @@ -25,6 +25,9 @@ | |||||
| </li> | </li> | ||||
| @if (User.Identity?.IsAuthenticated == true) | @if (User.Identity?.IsAuthenticated == true) | ||||
| { | { | ||||
| <li class="nav-item"> | |||||
| <a class="nav-link text-dark" asp-controller="ShoppingList" asp-action="Index">List</a> | |||||
| </li> | |||||
| <li class="nav-item"> | <li class="nav-item"> | ||||
| <a class="nav-link text-dark" asp-controller="Household" asp-action="Index">Household</a> | <a class="nav-link text-dark" asp-controller="Household" asp-action="Index">Household</a> | ||||
| </li> | </li> | ||||
| @@ -0,0 +1,46 @@ | |||||
| @model CartWise.Web.ViewModels.ShoppingList.ShoppingListViewModel | |||||
| @{ | |||||
| ViewData["Title"] = Model.Name; | |||||
| } | |||||
| <h1>@Model.Name</h1> | |||||
| <form asp-controller="ShoppingList" asp-action="AddItem" method="post" class="row g-2 mb-4"> | |||||
| <div asp-validation-summary="ModelOnly" class="text-danger"></div> | |||||
| <div class="col-12 col-sm-5"> | |||||
| <label asp-for="NewItem.DisplayName" class="visually-hidden"></label> | |||||
| <input asp-for="NewItem.DisplayName" class="form-control" placeholder="Add an item (e.g. Milk)" autofocus /> | |||||
| <span asp-validation-for="NewItem.DisplayName" class="text-danger"></span> | |||||
| </div> | |||||
| <div class="col-6 col-sm-3"> | |||||
| <label asp-for="NewItem.Quantity" class="visually-hidden"></label> | |||||
| <input asp-for="NewItem.Quantity" class="form-control" placeholder="Qty" /> | |||||
| <span asp-validation-for="NewItem.Quantity" class="text-danger"></span> | |||||
| </div> | |||||
| <div class="col-6 col-sm-2"> | |||||
| <label asp-for="NewItem.Unit" class="visually-hidden"></label> | |||||
| <input asp-for="NewItem.Unit" class="form-control" placeholder="Unit" /> | |||||
| <span asp-validation-for="NewItem.Unit" class="text-danger"></span> | |||||
| </div> | |||||
| <div class="col-12 col-sm-2"> | |||||
| <button type="submit" class="btn btn-primary w-100">Add</button> | |||||
| </div> | |||||
| </form> | |||||
| @if (Model.Items.Count == 0) | |||||
| { | |||||
| <p class="text-muted">No items yet. Add your first item above.</p> | |||||
| } | |||||
| else | |||||
| { | |||||
| <ul class="list-group" id="shopping-list-items"> | |||||
| @foreach (var item in Model.Items) | |||||
| { | |||||
| @await Html.PartialAsync("_ListItem", item) | |||||
| } | |||||
| </ul> | |||||
| } | |||||
| @section Scripts { | |||||
| @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} | |||||
| } | |||||
| @@ -0,0 +1,12 @@ | |||||
| @model CartWise.Web.ViewModels.ShoppingList.ShoppingListItemViewModel | |||||
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |||||
| <span> | |||||
| @Model.DisplayName | |||||
| @if (!string.IsNullOrEmpty(Model.QuantityDisplay)) | |||||
| { | |||||
| <span class="text-muted">(@Model.QuantityDisplay)</span> | |||||
| } | |||||
| </span> | |||||
| <span class="badge bg-secondary">@Model.Status</span> | |||||
| </li> | |||||
| @@ -0,0 +1,124 @@ | |||||
| using CartWise.Application.Services; | |||||
| using CartWise.Infrastructure.Data; | |||||
| using Microsoft.EntityFrameworkCore; | |||||
| namespace CartWise.Application.Tests; | |||||
| public class ShoppingListServiceTests : IDisposable | |||||
| { | |||||
| private readonly CartWiseDbContext _db; | |||||
| private readonly ShoppingListService _sut; | |||||
| public ShoppingListServiceTests() | |||||
| { | |||||
| var options = new DbContextOptionsBuilder<CartWiseDbContext>() | |||||
| .UseInMemoryDatabase(Guid.NewGuid().ToString()) | |||||
| .Options; | |||||
| _db = new CartWiseDbContext(options); | |||||
| _sut = new ShoppingListService(_db, TimeProvider.System); | |||||
| } | |||||
| public void Dispose() | |||||
| { | |||||
| _db.Dispose(); | |||||
| GC.SuppressFinalize(this); | |||||
| } | |||||
| [Fact] | |||||
| public async Task CreateListAsync_CreatesActiveList() | |||||
| { | |||||
| var householdId = Guid.NewGuid(); | |||||
| var result = await _sut.CreateListAsync(householdId, "Groceries", "user-1"); | |||||
| Assert.True(result.IsSuccess); | |||||
| Assert.Equal("Groceries", result.Value!.Name); | |||||
| Assert.Equal(householdId, result.Value.HouseholdId); | |||||
| } | |||||
| [Fact] | |||||
| public async Task CreateListAsync_FailsWhenHouseholdAlreadyHasAnActiveList() | |||||
| { | |||||
| var householdId = Guid.NewGuid(); | |||||
| await _sut.CreateListAsync(householdId, "Groceries", "user-1"); | |||||
| var result = await _sut.CreateListAsync(householdId, "Second List", "user-1"); | |||||
| Assert.False(result.IsSuccess); | |||||
| Assert.NotNull(result.Error); | |||||
| } | |||||
| [Fact] | |||||
| public async Task CreateListAsync_FailsWhenNameIsMissing() | |||||
| { | |||||
| var result = await _sut.CreateListAsync(Guid.NewGuid(), " ", "user-1"); | |||||
| Assert.False(result.IsSuccess); | |||||
| } | |||||
| [Fact] | |||||
| public async Task GetActiveListAsync_ReturnsNullWhenHouseholdHasNoList() | |||||
| { | |||||
| var list = await _sut.GetActiveListAsync(Guid.NewGuid()); | |||||
| Assert.Null(list); | |||||
| } | |||||
| [Fact] | |||||
| public async Task GetActiveListAsync_ReturnsTheActiveList() | |||||
| { | |||||
| var householdId = Guid.NewGuid(); | |||||
| var created = await _sut.CreateListAsync(householdId, "Groceries", "user-1"); | |||||
| var list = await _sut.GetActiveListAsync(householdId); | |||||
| Assert.NotNull(list); | |||||
| Assert.Equal(created.Value!.ShoppingListId, list!.ShoppingListId); | |||||
| } | |||||
| [Fact] | |||||
| public async Task GetActiveListAsync_DoesNotReturnAnotherHouseholdsList() | |||||
| { | |||||
| var householdA = Guid.NewGuid(); | |||||
| var householdB = Guid.NewGuid(); | |||||
| await _sut.CreateListAsync(householdA, "Household A's List", "user-1"); | |||||
| var list = await _sut.GetActiveListAsync(householdB); | |||||
| Assert.Null(list); | |||||
| } | |||||
| [Fact] | |||||
| public async Task AddItemAsync_AddsItemAndPersistsIt() | |||||
| { | |||||
| var created = await _sut.CreateListAsync(Guid.NewGuid(), "Groceries", "user-1"); | |||||
| var result = await _sut.AddItemAsync(created.Value!.ShoppingListId, "Milk", 2m, "gal", "user-1"); | |||||
| Assert.True(result.IsSuccess); | |||||
| Assert.Equal("Milk", result.Value!.DisplayName); | |||||
| var reloaded = await _sut.GetActiveListAsync(created.Value.HouseholdId); | |||||
| Assert.Single(reloaded!.Items); | |||||
| Assert.Equal("Milk", reloaded.Items.Single().DisplayName); | |||||
| } | |||||
| [Fact] | |||||
| public async Task AddItemAsync_FailsWhenListDoesNotExist() | |||||
| { | |||||
| var result = await _sut.AddItemAsync(Guid.NewGuid(), "Milk", null, null, "user-1"); | |||||
| Assert.False(result.IsSuccess); | |||||
| } | |||||
| [Fact] | |||||
| public async Task AddItemAsync_FailsWhenDisplayNameIsMissing() | |||||
| { | |||||
| var created = await _sut.CreateListAsync(Guid.NewGuid(), "Groceries", "user-1"); | |||||
| var result = await _sut.AddItemAsync(created.Value!.ShoppingListId, " ", null, null, "user-1"); | |||||
| Assert.False(result.IsSuccess); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,81 @@ | |||||
| using CartWise.Domain.Entities; | |||||
| using CartWise.Domain.Enums; | |||||
| namespace CartWise.Domain.Tests; | |||||
| public class ShoppingListTests | |||||
| { | |||||
| private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc); | |||||
| [Fact] | |||||
| public void Constructor_CreatesActiveListWithNoItems() | |||||
| { | |||||
| var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); | |||||
| Assert.Equal(ShoppingListStatus.Active, list.Status); | |||||
| Assert.Empty(list.Items); | |||||
| } | |||||
| [Theory] | |||||
| [InlineData("")] | |||||
| [InlineData(" ")] | |||||
| [InlineData(null)] | |||||
| public void Constructor_ThrowsWhenNameIsMissing(string? name) | |||||
| { | |||||
| Assert.Throws<ArgumentException>(() => new ShoppingList(Guid.NewGuid(), name!, "user-1", Now)); | |||||
| } | |||||
| [Fact] | |||||
| public void Constructor_ThrowsWhenHouseholdIdIsEmpty() | |||||
| { | |||||
| Assert.Throws<ArgumentException>(() => new ShoppingList(Guid.Empty, "Groceries", "user-1", Now)); | |||||
| } | |||||
| [Fact] | |||||
| public void AddItem_AddsItemWithNeededStatusAndZeroSortOrder() | |||||
| { | |||||
| var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); | |||||
| var item = list.AddItem("Milk", 2m, "gal", "user-1", Now); | |||||
| Assert.Single(list.Items); | |||||
| Assert.Equal("Milk", item.DisplayName); | |||||
| Assert.Equal(ShoppingListItemStatus.Needed, item.Status); | |||||
| Assert.Equal(0, item.SortOrder); | |||||
| Assert.Equal(list.ShoppingListId, item.ShoppingListId); | |||||
| } | |||||
| [Fact] | |||||
| public void AddItem_AssignsIncrementingSortOrder() | |||||
| { | |||||
| var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); | |||||
| var first = list.AddItem("Milk", null, null, "user-1", Now); | |||||
| var second = list.AddItem("Bread", null, null, "user-1", Now); | |||||
| Assert.Equal(0, first.SortOrder); | |||||
| Assert.Equal(1, second.SortOrder); | |||||
| } | |||||
| [Fact] | |||||
| public void AddItem_DoesNotRequireQuantityOrUnit() | |||||
| { | |||||
| var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); | |||||
| var item = list.AddItem("Milk", null, null, "user-1", Now); | |||||
| Assert.Null(item.Quantity); | |||||
| Assert.Null(item.Unit); | |||||
| } | |||||
| [Theory] | |||||
| [InlineData("")] | |||||
| [InlineData(" ")] | |||||
| [InlineData(null)] | |||||
| public void AddItem_ThrowsWhenDisplayNameIsMissing(string? displayName) | |||||
| { | |||||
| var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); | |||||
| Assert.Throws<ArgumentException>(() => list.AddItem(displayName!, null, null, "user-1", Now)); | |||||
| } | |||||
| } | |||||
| @@ -1,5 +1,4 @@ | |||||
| using System.Net; | using System.Net; | ||||
| using System.Text.RegularExpressions; | |||||
| using Microsoft.AspNetCore.Mvc.Testing; | using Microsoft.AspNetCore.Mvc.Testing; | ||||
| namespace CartWise.Web.Tests; | namespace CartWise.Web.Tests; | ||||
| @@ -30,11 +29,11 @@ public class HouseholdAccessControlTests : IClassFixture<CartWiseWebApplicationF | |||||
| var clientA = _factory.CreateClient(); | var clientA = _factory.CreateClient(); | ||||
| var clientB = _factory.CreateClient(); | var clientB = _factory.CreateClient(); | ||||
| await RegisterAsync(clientA, "alice2@example.com", "Alice"); | |||||
| await RegisterAsync(clientB, "bob2@example.com", "Bob"); | |||||
| await WebTestHelpers.RegisterAsync(clientA, "alice2@example.com", "Alice"); | |||||
| await WebTestHelpers.RegisterAsync(clientB, "bob2@example.com", "Bob"); | |||||
| await CreateHouseholdAsync(clientA, "Alice's Household"); | |||||
| await CreateHouseholdAsync(clientB, "Bob's Household"); | |||||
| await WebTestHelpers.CreateHouseholdAsync(clientA, "Alice's Household"); | |||||
| await WebTestHelpers.CreateHouseholdAsync(clientB, "Bob's Household"); | |||||
| var householdPageA = await clientA.GetStringAsync("/household"); | var householdPageA = await clientA.GetStringAsync("/household"); | ||||
| var householdPageB = await clientB.GetStringAsync("/household"); | var householdPageB = await clientB.GetStringAsync("/household"); | ||||
| @@ -45,43 +44,4 @@ public class HouseholdAccessControlTests : IClassFixture<CartWiseWebApplicationF | |||||
| HtmlAssert.Contains("Bob's Household", householdPageB); | HtmlAssert.Contains("Bob's Household", householdPageB); | ||||
| HtmlAssert.DoesNotContain("Alice's Household", householdPageB); | HtmlAssert.DoesNotContain("Alice's Household", householdPageB); | ||||
| } | } | ||||
| private static async Task RegisterAsync(HttpClient client, string email, string displayName) | |||||
| { | |||||
| var html = await client.GetStringAsync("/Account/Register"); | |||||
| var token = ExtractAntiForgeryToken(html); | |||||
| var form = new Dictionary<string, string> | |||||
| { | |||||
| ["__RequestVerificationToken"] = token, | |||||
| ["DisplayName"] = displayName, | |||||
| ["Email"] = email, | |||||
| ["Password"] = "Sup3rSecret!23", | |||||
| ["ConfirmPassword"] = "Sup3rSecret!23" | |||||
| }; | |||||
| var response = await client.PostAsync("/Account/Register", new FormUrlEncodedContent(form)); | |||||
| response.EnsureSuccessStatusCode(); | |||||
| } | |||||
| private static async Task CreateHouseholdAsync(HttpClient client, string name) | |||||
| { | |||||
| var html = await client.GetStringAsync("/household/create"); | |||||
| var token = ExtractAntiForgeryToken(html); | |||||
| var form = new Dictionary<string, string> | |||||
| { | |||||
| ["__RequestVerificationToken"] = token, | |||||
| ["Name"] = name | |||||
| }; | |||||
| var response = await client.PostAsync("/household/create", new FormUrlEncodedContent(form)); | |||||
| response.EnsureSuccessStatusCode(); | |||||
| } | |||||
| private static string ExtractAntiForgeryToken(string html) | |||||
| { | |||||
| var match = Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\""); | |||||
| return match.Success ? match.Groups[1].Value : throw new InvalidOperationException("Anti-forgery token not found."); | |||||
| } | |||||
| } | } | ||||
| @@ -0,0 +1,123 @@ | |||||
| using System.Net; | |||||
| using Microsoft.AspNetCore.Mvc.Testing; | |||||
| namespace CartWise.Web.Tests; | |||||
| public class ShoppingListAccessControlTests : IClassFixture<CartWiseWebApplicationFactory> | |||||
| { | |||||
| private readonly CartWiseWebApplicationFactory _factory; | |||||
| public ShoppingListAccessControlTests(CartWiseWebApplicationFactory factory) | |||||
| { | |||||
| _factory = factory; | |||||
| } | |||||
| [Fact] | |||||
| public async Task AnonymousUser_IsRedirectedToLoginWhenRequestingList() | |||||
| { | |||||
| var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); | |||||
| var response = await client.GetAsync("/list"); | |||||
| Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); | |||||
| Assert.Contains("/Account/Login", response.Headers.Location?.ToString()); | |||||
| } | |||||
| [Fact] | |||||
| public async Task Index_AutoCreatesDefaultListForNewHousehold() | |||||
| { | |||||
| var client = _factory.CreateClient(); | |||||
| await WebTestHelpers.RegisterAsync(client, "listuser1@example.com", "List User"); | |||||
| await WebTestHelpers.CreateHouseholdAsync(client, "List Household"); | |||||
| var html = await client.GetStringAsync("/list"); | |||||
| HtmlAssert.Contains("Groceries", html); | |||||
| } | |||||
| [Fact] | |||||
| public async Task AddItem_AppearsOnActiveList() | |||||
| { | |||||
| var client = _factory.CreateClient(); | |||||
| await WebTestHelpers.RegisterAsync(client, "listuser2@example.com", "List User 2"); | |||||
| await WebTestHelpers.CreateHouseholdAsync(client, "Another Household"); | |||||
| var listHtml = await client.GetStringAsync("/list"); | |||||
| var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); | |||||
| var form = new Dictionary<string, string> | |||||
| { | |||||
| ["__RequestVerificationToken"] = token, | |||||
| ["NewItem.DisplayName"] = "Milk", | |||||
| ["NewItem.Quantity"] = "2", | |||||
| ["NewItem.Unit"] = "gal" | |||||
| }; | |||||
| var postResponse = await client.PostAsync("/list/items", new FormUrlEncodedContent(form)); | |||||
| postResponse.EnsureSuccessStatusCode(); | |||||
| var updatedHtml = await client.GetStringAsync("/list"); | |||||
| HtmlAssert.Contains("Milk", updatedHtml); | |||||
| HtmlAssert.Contains("2 gal", updatedHtml); | |||||
| } | |||||
| [Fact] | |||||
| public async Task AddItem_WithMissingDisplayName_ShowsValidationErrorAndDoesNotAddItem() | |||||
| { | |||||
| var client = _factory.CreateClient(); | |||||
| await WebTestHelpers.RegisterAsync(client, "listuser3@example.com", "List User 3"); | |||||
| await WebTestHelpers.CreateHouseholdAsync(client, "Third Household"); | |||||
| var listHtml = await client.GetStringAsync("/list"); | |||||
| var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); | |||||
| var form = new Dictionary<string, string> | |||||
| { | |||||
| ["__RequestVerificationToken"] = token, | |||||
| ["NewItem.DisplayName"] = string.Empty | |||||
| }; | |||||
| var postResponse = await client.PostAsync("/list/items", new FormUrlEncodedContent(form)); | |||||
| var body = await postResponse.Content.ReadAsStringAsync(); | |||||
| Assert.Equal(HttpStatusCode.OK, postResponse.StatusCode); | |||||
| Assert.Contains("field-validation-error", body); | |||||
| } | |||||
| [Fact] | |||||
| public async Task EachHousehold_OnlySeesItsOwnListItems() | |||||
| { | |||||
| var clientA = _factory.CreateClient(); | |||||
| var clientB = _factory.CreateClient(); | |||||
| await WebTestHelpers.RegisterAsync(clientA, "listalice@example.com", "List Alice"); | |||||
| await WebTestHelpers.RegisterAsync(clientB, "listbob@example.com", "List Bob"); | |||||
| await WebTestHelpers.CreateHouseholdAsync(clientA, "Alice's List Household"); | |||||
| await WebTestHelpers.CreateHouseholdAsync(clientB, "Bob's List Household"); | |||||
| var listHtmlA = await clientA.GetStringAsync("/list"); | |||||
| var tokenA = WebTestHelpers.ExtractAntiForgeryToken(listHtmlA); | |||||
| await clientA.PostAsync("/list/items", new FormUrlEncodedContent(new Dictionary<string, string> | |||||
| { | |||||
| ["__RequestVerificationToken"] = tokenA, | |||||
| ["NewItem.DisplayName"] = "Alice Item" | |||||
| })); | |||||
| var listHtmlB = await clientB.GetStringAsync("/list"); | |||||
| var tokenB = WebTestHelpers.ExtractAntiForgeryToken(listHtmlB); | |||||
| await clientB.PostAsync("/list/items", new FormUrlEncodedContent(new Dictionary<string, string> | |||||
| { | |||||
| ["__RequestVerificationToken"] = tokenB, | |||||
| ["NewItem.DisplayName"] = "Bob Item" | |||||
| })); | |||||
| var finalA = await clientA.GetStringAsync("/list"); | |||||
| var finalB = await clientB.GetStringAsync("/list"); | |||||
| HtmlAssert.Contains("Alice Item", finalA); | |||||
| HtmlAssert.DoesNotContain("Bob Item", finalA); | |||||
| HtmlAssert.Contains("Bob Item", finalB); | |||||
| HtmlAssert.DoesNotContain("Alice Item", finalB); | |||||
| } | |||||
| } | |||||
| @@ -0,0 +1,45 @@ | |||||
| using System.Text.RegularExpressions; | |||||
| namespace CartWise.Web.Tests; | |||||
| internal static class WebTestHelpers | |||||
| { | |||||
| public static async Task RegisterAsync(HttpClient client, string email, string displayName, string password = "Sup3rSecret!23") | |||||
| { | |||||
| var html = await client.GetStringAsync("/Account/Register"); | |||||
| var token = ExtractAntiForgeryToken(html); | |||||
| var form = new Dictionary<string, string> | |||||
| { | |||||
| ["__RequestVerificationToken"] = token, | |||||
| ["DisplayName"] = displayName, | |||||
| ["Email"] = email, | |||||
| ["Password"] = password, | |||||
| ["ConfirmPassword"] = password | |||||
| }; | |||||
| var response = await client.PostAsync("/Account/Register", new FormUrlEncodedContent(form)); | |||||
| response.EnsureSuccessStatusCode(); | |||||
| } | |||||
| public static async Task CreateHouseholdAsync(HttpClient client, string name) | |||||
| { | |||||
| var html = await client.GetStringAsync("/household/create"); | |||||
| var token = ExtractAntiForgeryToken(html); | |||||
| var form = new Dictionary<string, string> | |||||
| { | |||||
| ["__RequestVerificationToken"] = token, | |||||
| ["Name"] = name | |||||
| }; | |||||
| var response = await client.PostAsync("/household/create", new FormUrlEncodedContent(form)); | |||||
| response.EnsureSuccessStatusCode(); | |||||
| } | |||||
| public static string ExtractAntiForgeryToken(string html) | |||||
| { | |||||
| var match = Regex.Match(html, "name=\"__RequestVerificationToken\"[^>]*value=\"([^\"]+)\""); | |||||
| return match.Success ? match.Groups[1].Value : throw new InvalidOperationException("Anti-forgery token not found."); | |||||
| } | |||||
| } | |||||
Powered by TurnKey Linux.