diff --git a/.claude/settings.json b/.claude/settings.json index 9291d6a..1603bb8 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -16,7 +16,10 @@ "Bash(dotnet tool *)", "Bash(rm -f \"g:/development/C Sharp AI/CartWise/src/CartWise.Web/App_Data/cartwise.db\")", "Bash(ASPNETCORE_URLS=\"http://127.0.0.1:5187\" ASPNETCORE_ENVIRONMENT=\"Development\" dotnet run --no-launch-profile)", - "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(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')" ] } } diff --git a/.editorconfig b/.editorconfig index 5ce2502..92105bc 100644 --- a/.editorconfig +++ b/.editorconfig @@ -391,3 +391,9 @@ dotnet_naming_style.s_camelcase.capitalization = camel_case # Test method names use the xUnit/NUnit Method_Scenario_Expected convention. [tests/**.cs] dotnet_diagnostic.CA1707.severity = none + +# Result.Success/Failure static factory methods are the intended API shape (common +# pattern, e.g. FluentResults/ErrorOr) despite CA1000's general "avoid static members on +# generic types" guidance. +[src/CartWise.Application/Results/**.cs] +dotnet_diagnostic.CA1000.severity = none diff --git a/.idea/.idea.CartWise/.idea/dataSources.xml b/.idea/.idea.CartWise/.idea/dataSources.xml new file mode 100644 index 0000000..99fb153 --- /dev/null +++ b/.idea/.idea.CartWise/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:G:\development\C Sharp AI\CartWise\src\CartWise.Web\verify.db + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/README.md b/README.md index 20349f7..7fdc4c3 100644 --- a/README.md +++ b/README.md @@ -95,9 +95,9 @@ tests/ ## Planned Features -### Phase 0 - Repository and Baseline +### Phase 0 - Repository and Baseline (Complete) - Solution and project setup -- PostgreSQL configuration +- SQLite configuration for MVP (PostgreSQL remains the planned hosted-production target — see `docs/decision-log.md` DEC-005) - Identity authentication - Build and test baseline @@ -146,11 +146,22 @@ This repository is being built from the `AGENTS.md` build specification. The cur Progress so far (see `docs/scrum-backlog.md` for the authoritative task-level state): +`CW-EPIC-01` Platform Foundation is complete: + - [x] `CW-STORY-01.1` Solution skeleton — 4 src projects + 3 test projects, correct dependency direction, nullable enabled - [x] `CW-STORY-01.2` Application startup — MVC shell runs, SQLite wired via `CartWiseDbContext`, `wwwroot` structure organized - [x] `CW-STORY-01.3` ASP.NET Core Identity authentication — register, sign in, sign out - [x] `CW-STORY-01.4` Engineering baseline — shared analyzer settings, `.editorconfig`, tuned logging, smoke tests +`CW-EPIC-02` Household and Access Control is complete: + +- [x] `CW-STORY-02.1` Household/HouseholdMember entities, EF configuration, migration, domain tests +- [x] `CW-STORY-02.2` `HouseholdService` — create household, resolve current household, membership checks +- [x] `CW-STORY-02.3` Household UI — create and overview pages +- [x] `CW-STORY-02.4` Reusable `"HouseholdMember"` authorization policy, cross-household isolation verified + +Next up: `CW-EPIC-03` Smart Shopping List. + ## Project Planning - Delivery guidance and architectural rules live in `AGENTS.md` diff --git a/docs/decision-log.md b/docs/decision-log.md index 916a486..fc33565 100644 --- a/docs/decision-log.md +++ b/docs/decision-log.md @@ -89,3 +89,11 @@ This file records product, scope, architecture, and delivery decisions for CartW - Reason: Price history and replenishment value can be delivered without sophisticated scan or shopping workflows. - Impact: Purchase and price stories stay in MVP while more advanced purchase linkage can evolve later. - Revisit Trigger: Manual purchase entry creates too much friction during testing. + +### DEC-009 - One household per user for MVP +- Date: 2026-08-10 +- Status: Accepted +- Decision: A user belongs to exactly one household. `CW-STORY-02.2`'s `HouseholdService.CreateHouseholdAsync` rejects creating a second household for a user who already has a membership row anywhere; `GetCurrentHouseholdAsync` resolves "current household" unambiguously from that single membership. +- 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). diff --git a/docs/scrum-backlog.md b/docs/scrum-backlog.md index 27285cd..27486b3 100644 --- a/docs/scrum-backlog.md +++ b/docs/scrum-backlog.md @@ -214,12 +214,14 @@ A story is done when: - EF configurations and migration are created **Tasks** -- [ ] Create `Household` entity -- [ ] Create `HouseholdMember` entity -- [ ] Add role enum -- [ ] Add EF configurations -- [ ] Create migration -- [ ] Add domain tests +- [x] Create `Household` entity +- [x] Create `HouseholdMember` entity +- [x] Add role enum +- [x] Add EF configurations +- [x] Create migration +- [x] Add domain tests + +**Status:** Done — `Household` and `HouseholdMember` live in `CartWise.Domain.Entities`, `HouseholdRole` (`Owner`/`Member`) in `CartWise.Domain.Enums`. `Household` is the aggregate root: its constructor auto-creates an `Owner` membership for the creator, `AddMember` rejects duplicate users, and `HouseholdMember`'s constructor is `internal` so members can only be created through the aggregate — Domain stays free of any ASP.NET Core/Identity reference (`CreatedByUserId`/`UserId` are plain strings, not `ApplicationUser` navigations). `HouseholdConfiguration`/`HouseholdMemberConfiguration` in `CartWise.Infrastructure/Data/Configurations` establish the real FK to `AspNetUsers` (`Restrict` on `Household.CreatedByUserId` to protect household history from user-deletion cascades; `Cascade` on `HouseholdMember` rows), a composite PK on `(HouseholdId, UserId)`, and an index on `HouseholdMember.UserId` for "which households does this user belong to" lookups. `AddHousehold` migration created and verified applying cleanly against a scratch SQLite database (and implicitly on every Web smoke test run, since those auto-migrate on boot). 11 domain tests added covering the invariants above. ### `CW-STORY-02.2` Implement household service **Release:** MVP @@ -233,12 +235,14 @@ A story is done when: - Membership checks are reusable **Tasks** -- [ ] Create `IHouseholdService` -- [ ] Implement `HouseholdService` -- [ ] Add create household use case -- [ ] Add get current household use case -- [ ] Add membership verification logic -- [ ] Add application tests +- [x] Create `IHouseholdService` +- [x] Implement `HouseholdService` +- [x] Add create household use case +- [x] Add get current household use case +- [x] Add membership verification logic +- [x] Add application tests + +**Status:** Done — `IHouseholdService`/`HouseholdService` live in `CartWise.Application.{Interfaces,Services}`, exposing `CreateHouseholdAsync` (returns `Result`), `GetCurrentHouseholdAsync`, and `IsMemberAsync` (the reusable membership check `CW-STORY-02.4` will build authorization on top of). One household per user is enforced at this layer — see `DEC-009`. Data access goes through a new `IApplicationDbContext` abstraction (`CartWise.Application.Interfaces`, exposing just the `DbSet`/`DbSet` needed) rather than a generic repository, since AGENTS.md explicitly rules out "generic repository abstractions added only for pattern compliance" — `CartWiseDbContext` implements it directly and is registered against the interface in `AddInfrastructure`. Time comes from the built-in `TimeProvider` (registered as `TimeProvider.System`) rather than a bespoke clock abstraction, keeping `CreateHouseholdAsync` deterministically testable without extra packages. `AddApplication()` (new `CartWise.Application` DI extension) is called from `Program.cs` alongside `AddInfrastructure`. 6 application tests added using EF Core's InMemory provider against the real `CartWiseDbContext` (via a new `CartWise.Infrastructure` reference in `CartWise.Application.Tests`, scoped to tests only — production `CartWise.Application` still has zero reference to `CartWise.Infrastructure`). ### `CW-STORY-02.3` Build household UI **Release:** MVP @@ -252,12 +256,14 @@ A story is done when: - Validation errors are shown clearly **Tasks** -- [ ] Create `HouseholdController` -- [ ] Create household view models -- [ ] Build `Views/Household/Create.cshtml` -- [ ] Build `Views/Household/Index.cshtml` -- [ ] Add validation messaging -- [ ] Add redirect flow after creation +- [x] Create `HouseholdController` +- [x] Create household view models +- [x] Build `Views/Household/Create.cshtml` +- [x] Build `Views/Household/Index.cshtml` +- [x] Add validation messaging +- [x] Add redirect flow after creation + +**Status:** Done — `HouseholdController` (`[Authorize]`, attribute-routed to `/household` and `/household/create` per AGENTS.md §11) with `HouseholdIndexViewModel`/`HouseholdMemberViewModel`/`CreateHouseholdViewModel` in `CartWise.Web.ViewModels.Household`. `Index` resolves member display names via `UserManager` in the controller (not the Application layer, to keep `CartWise.Application` free of Identity references) and redirects to `Create` when the signed-in user has no household yet; `Create` redirects back to `Index` if a household already exists (enforcing `DEC-009`'s one-per-user rule at the UI layer too, on top of the service-layer guard). Added a "Household" nav link, visible only when authenticated. Only Create + Index were built — member invite/remove (`/household/members/*`) are explicitly out of scope per AGENTS.md, which marks invite `[future/simple v1 if desired]`, and `HouseholdService` has no add/remove-member use case yet to back them. Verified live end-to-end via `dotnet run` + curl: anonymous-to-registered-to-household-created-to-overview-shown flow, member display name and `Owner` role render correctly, revisiting `/household/create` after a household exists redirects to `/household`, and an empty-name submission redisplays the form with visible validation errors (200, not a redirect). ### `CW-STORY-02.4` Enforce household authorization **Release:** MVP @@ -271,10 +277,12 @@ A story is done when: - Resource isolation is covered by tests **Tasks** -- [ ] Add household authorization policy or helper -- [ ] Enforce membership checks in services -- [ ] Add integration tests for access control -- [ ] Add tests for unauthorized and cross-household access +- [x] Add household authorization policy or helper +- [x] Enforce membership checks in services +- [x] Add integration tests for access control +- [x] Add tests for unauthorized and cross-household access + +**Status:** Done — added a reusable resource-based `"HouseholdMember"` authorization policy: `HouseholdMemberRequirement` + `HouseholdMemberAuthorizationHandler` (`CartWise.Web.Authorization`), backed by the `IHouseholdService.IsMemberAsync` check already built in `CW-STORY-02.2`, registered via `AddAuthorizationBuilder()` in `Program.cs`. Note on current scope: `HouseholdController`'s existing actions (`Index`/`Create`) never accept a client-supplied household id — they always resolve "my household" from the authenticated user's own membership — so there's no IDOR surface to protect on them today; the new policy exists so the first household-scoped resource that *does* take an id (starting with `CW-EPIC-03`'s shopping list) can apply `[Authorize(Policy = "HouseholdMember")]`/resource-based `AuthorizeAsync` immediately instead of hand-rolling the check. Verified with: a unit-style test exercising the policy through the real DI container (`HouseholdAuthorizationTests` — two real users/households, confirms the policy succeeds for the owning member and fails for a user from a different household), and Web integration tests (`HouseholdAccessControlTests`) proving an anonymous request to `/household` redirects to login, and that two independently-registered users each only ever see their own household's name on `/household` — never each other's. --- @@ -828,6 +836,25 @@ A story is done when: - [ ] Seed purchase and price history - [ ] Restrict seeding to development only +### `CW-STORY-08.5` Harden HTML assertions in Web integration tests +**Release:** MVP +**Priority:** P2 +**Effort:** XS +**User story:** As a developer, I want Web integration test assertions to be resilient to HTML encoding so tests don't produce false negatives on ordinary characters in test data. + +**Acceptance criteria** +- Web integration test assertions against rendered HTML compare against HTML-decoded content, not raw encoded output +- Test data is not artificially restricted to avoid HTML-special characters (apostrophes, ampersands, quotes) + +**Tasks** +- [x] Add an HTML-decoding assertion helper to `CartWise.Web.Tests` +- [x] Update `HouseholdAccessControlTests` to use realistic household names (including an apostrophe) via the new helper +- [x] Note the pattern so future integration tests use it by default + +**Status:** Done — added `HtmlAssert.Contains`/`DoesNotContain` (`tests/CartWise.Web.Tests/HtmlAssert.cs`), which runs `WebUtility.HtmlDecode` before comparing. `HouseholdAccessControlTests` now uses `"Alice's Household"` / `"Bob's Household"` (real apostrophes) through the new helper and passes. Future Web integration tests asserting against rendered HTML should use `HtmlAssert` rather than raw `Assert.Contains`/`DoesNotContain`. + +**Origin:** Discovered during `CW-STORY-02.4` verification — `EachUser_OnlySeesTheirOwnHousehold` failed against `"Alice's Household"` because Razor correctly HTML-encodes `'` as `'` in `@Model.Name`, but the test asserted a literal apostrophe. The immediate fix (switching to apostrophe-free test data) papered over the real issue: any test data with `&`, `'`, `"`, `<`, or `>` would hit the same false negative. This story is the real fix. + --- ## Suggested 30-Day Delivery Sequence diff --git a/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs b/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs new file mode 100644 index 0000000..c634f57 --- /dev/null +++ b/src/CartWise.Application/ApplicationServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using CartWise.Application.Interfaces; +using CartWise.Application.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace CartWise.Application; + +public static class ApplicationServiceCollectionExtensions +{ + public static IServiceCollection AddApplication(this IServiceCollection services) + { + services.AddSingleton(TimeProvider.System); + services.AddScoped(); + + return services; + } +} diff --git a/src/CartWise.Application/CartWise.Application.csproj b/src/CartWise.Application/CartWise.Application.csproj index 8e820ff..b8d6736 100644 --- a/src/CartWise.Application/CartWise.Application.csproj +++ b/src/CartWise.Application/CartWise.Application.csproj @@ -4,6 +4,10 @@ + + + + net10.0 diff --git a/src/CartWise.Application/Interfaces/IApplicationDbContext.cs b/src/CartWise.Application/Interfaces/IApplicationDbContext.cs new file mode 100644 index 0000000..293e336 --- /dev/null +++ b/src/CartWise.Application/Interfaces/IApplicationDbContext.cs @@ -0,0 +1,13 @@ +using CartWise.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace CartWise.Application.Interfaces; + +public interface IApplicationDbContext +{ + DbSet Households { get; } + + DbSet HouseholdMembers { get; } + + Task SaveChangesAsync(CancellationToken cancellationToken = default); +} diff --git a/src/CartWise.Application/Interfaces/IHouseholdService.cs b/src/CartWise.Application/Interfaces/IHouseholdService.cs new file mode 100644 index 0000000..8484231 --- /dev/null +++ b/src/CartWise.Application/Interfaces/IHouseholdService.cs @@ -0,0 +1,13 @@ +using CartWise.Application.Results; +using CartWise.Domain.Entities; + +namespace CartWise.Application.Interfaces; + +public interface IHouseholdService +{ + Task> CreateHouseholdAsync(string name, string userId, CancellationToken cancellationToken = default); + + Task GetCurrentHouseholdAsync(string userId, CancellationToken cancellationToken = default); + + Task IsMemberAsync(Guid householdId, string userId, CancellationToken cancellationToken = default); +} diff --git a/src/CartWise.Application/Results/Result.cs b/src/CartWise.Application/Results/Result.cs new file mode 100644 index 0000000..b3bbaba --- /dev/null +++ b/src/CartWise.Application/Results/Result.cs @@ -0,0 +1,21 @@ +namespace CartWise.Application.Results; + +public class Result +{ + public bool IsSuccess { get; } + + public T? Value { get; } + + public string? Error { get; } + + private Result(bool isSuccess, T? value, string? error) + { + IsSuccess = isSuccess; + Value = value; + Error = error; + } + + public static Result Success(T value) => new(true, value, null); + + public static Result Failure(string error) => new(false, default, error); +} diff --git a/src/CartWise.Application/Services/HouseholdService.cs b/src/CartWise.Application/Services/HouseholdService.cs new file mode 100644 index 0000000..23df56b --- /dev/null +++ b/src/CartWise.Application/Services/HouseholdService.cs @@ -0,0 +1,71 @@ +using CartWise.Application.Interfaces; +using CartWise.Application.Results; +using CartWise.Domain.Entities; +using Microsoft.EntityFrameworkCore; + +namespace CartWise.Application.Services; + +public class HouseholdService : IHouseholdService +{ + private readonly IApplicationDbContext _db; + private readonly TimeProvider _timeProvider; + + public HouseholdService(IApplicationDbContext db, TimeProvider timeProvider) + { + _db = db; + _timeProvider = timeProvider; + } + + public async Task> CreateHouseholdAsync(string name, string userId, CancellationToken cancellationToken = default) + { + var alreadyBelongsToAHousehold = await _db.HouseholdMembers + .AsNoTracking() + .AnyAsync(m => m.UserId == userId, cancellationToken); + + if (alreadyBelongsToAHousehold) + { + return Result.Failure("You already belong to a household."); + } + + Household household; + try + { + household = new Household(name, userId, _timeProvider.GetUtcNow().UtcDateTime); + } + catch (ArgumentException ex) + { + return Result.Failure(ex.Message); + } + + _db.Households.Add(household); + await _db.SaveChangesAsync(cancellationToken); + + return Result.Success(household); + } + + public async Task GetCurrentHouseholdAsync(string userId, CancellationToken cancellationToken = default) + { + var householdId = await _db.HouseholdMembers + .AsNoTracking() + .Where(m => m.UserId == userId) + .Select(m => m.HouseholdId) + .FirstOrDefaultAsync(cancellationToken); + + if (householdId == Guid.Empty) + { + return null; + } + + return await _db.Households + .Include(h => h.Members) + .AsNoTracking() + .FirstOrDefaultAsync(h => h.HouseholdId == householdId, cancellationToken); + } + + public async Task IsMemberAsync(Guid householdId, string userId, CancellationToken cancellationToken = default) + { + return await _db.HouseholdMembers + .AsNoTracking() + .AnyAsync(m => m.HouseholdId == householdId && m.UserId == userId, cancellationToken); + } +} diff --git a/src/CartWise.Domain/Entities/Household.cs b/src/CartWise.Domain/Entities/Household.cs new file mode 100644 index 0000000..0619640 --- /dev/null +++ b/src/CartWise.Domain/Entities/Household.cs @@ -0,0 +1,56 @@ +using CartWise.Domain.Enums; + +namespace CartWise.Domain.Entities; + +public class Household +{ + private readonly List _members = []; + + public Guid HouseholdId { get; private set; } + + public string Name { get; private set; } = null!; + + public DateTime CreatedUtc { get; private set; } + + public string CreatedByUserId { get; private set; } = null!; + + public IReadOnlyCollection Members => _members.AsReadOnly(); + + private Household() + { + } + + public Household(string name, string createdByUserId, DateTime createdUtc) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException("Household name is required.", nameof(name)); + } + + if (string.IsNullOrWhiteSpace(createdByUserId)) + { + throw new ArgumentException("CreatedByUserId is required.", nameof(createdByUserId)); + } + + HouseholdId = Guid.NewGuid(); + Name = name.Trim(); + CreatedByUserId = createdByUserId; + CreatedUtc = createdUtc; + + _members.Add(new HouseholdMember(HouseholdId, createdByUserId, HouseholdRole.Owner, createdUtc)); + } + + public HouseholdMember AddMember(string userId, HouseholdRole role, DateTime joinedUtc) + { + if (_members.Any(m => m.UserId == userId)) + { + throw new InvalidOperationException("User is already a member of this household."); + } + + var member = new HouseholdMember(HouseholdId, userId, role, joinedUtc); + _members.Add(member); + return member; + } + + public bool IsMember(string userId) => _members.Any(m => m.UserId == userId); +} diff --git a/src/CartWise.Domain/Entities/HouseholdMember.cs b/src/CartWise.Domain/Entities/HouseholdMember.cs new file mode 100644 index 0000000..62d60c5 --- /dev/null +++ b/src/CartWise.Domain/Entities/HouseholdMember.cs @@ -0,0 +1,31 @@ +using CartWise.Domain.Enums; + +namespace CartWise.Domain.Entities; + +public class HouseholdMember +{ + public Guid HouseholdId { get; private set; } + + public string UserId { get; private set; } = null!; + + public HouseholdRole Role { get; private set; } + + public DateTime JoinedUtc { get; private set; } + + private HouseholdMember() + { + } + + internal HouseholdMember(Guid householdId, string userId, HouseholdRole role, DateTime joinedUtc) + { + if (string.IsNullOrWhiteSpace(userId)) + { + throw new ArgumentException("UserId is required.", nameof(userId)); + } + + HouseholdId = householdId; + UserId = userId; + Role = role; + JoinedUtc = joinedUtc; + } +} diff --git a/src/CartWise.Domain/Enums/HouseholdRole.cs b/src/CartWise.Domain/Enums/HouseholdRole.cs new file mode 100644 index 0000000..dca5f27 --- /dev/null +++ b/src/CartWise.Domain/Enums/HouseholdRole.cs @@ -0,0 +1,7 @@ +namespace CartWise.Domain.Enums; + +public enum HouseholdRole +{ + Owner = 0, + Member = 1 +} diff --git a/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs b/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs index a2f729a..c4721c0 100644 --- a/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs +++ b/src/CartWise.Infrastructure/Data/CartWiseDbContext.cs @@ -1,13 +1,26 @@ +using CartWise.Application.Interfaces; +using CartWise.Domain.Entities; using CartWise.Infrastructure.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using Microsoft.EntityFrameworkCore; namespace CartWise.Infrastructure.Data; -public class CartWiseDbContext : IdentityDbContext +public class CartWiseDbContext : IdentityDbContext, IApplicationDbContext { public CartWiseDbContext(DbContextOptions options) : base(options) { } + + public DbSet Households => Set(); + + public DbSet HouseholdMembers => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + + builder.ApplyConfigurationsFromAssembly(typeof(CartWiseDbContext).Assembly); + } } diff --git a/src/CartWise.Infrastructure/Data/Configurations/HouseholdConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/HouseholdConfiguration.cs new file mode 100644 index 0000000..c68e33f --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Configurations/HouseholdConfiguration.cs @@ -0,0 +1,29 @@ +using CartWise.Domain.Entities; +using CartWise.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CartWise.Infrastructure.Data.Configurations; + +public class HouseholdConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(h => h.HouseholdId); + + builder.Property(h => h.Name) + .HasMaxLength(120) + .IsRequired(); + + builder.Property(h => h.CreatedByUserId) + .IsRequired(); + + builder.HasOne() + .WithMany() + .HasForeignKey(h => h.CreatedByUserId) + .OnDelete(DeleteBehavior.Restrict); + + builder.Metadata.FindNavigation(nameof(Household.Members))! + .SetPropertyAccessMode(PropertyAccessMode.Field); + } +} diff --git a/src/CartWise.Infrastructure/Data/Configurations/HouseholdMemberConfiguration.cs b/src/CartWise.Infrastructure/Data/Configurations/HouseholdMemberConfiguration.cs new file mode 100644 index 0000000..3338e2c --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Configurations/HouseholdMemberConfiguration.cs @@ -0,0 +1,29 @@ +using CartWise.Domain.Entities; +using CartWise.Infrastructure.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace CartWise.Infrastructure.Data.Configurations; + +public class HouseholdMemberConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(m => new { m.HouseholdId, m.UserId }); + + builder.Property(m => m.UserId) + .IsRequired(); + + builder.HasOne() + .WithMany(h => h.Members) + .HasForeignKey(m => m.HouseholdId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasOne() + .WithMany() + .HasForeignKey(m => m.UserId) + .OnDelete(DeleteBehavior.Cascade); + + builder.HasIndex(m => m.UserId); + } +} diff --git a/src/CartWise.Infrastructure/Data/Migrations/20260810161409_AddHousehold.Designer.cs b/src/CartWise.Infrastructure/Data/Migrations/20260810161409_AddHousehold.Designer.cs new file mode 100644 index 0000000..39cc03a --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Migrations/20260810161409_AddHousehold.Designer.cs @@ -0,0 +1,350 @@ +// +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("20260810161409_AddHousehold")] + partial class AddHousehold + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("CartWise.Domain.Entities.Household", b => + { + b.Property("HouseholdId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("HouseholdId"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("Households"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.HouseholdMember", b => + { + b.Property("HouseholdId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("JoinedUtc") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("HouseholdId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("HouseholdMembers"); + }); + + modelBuilder.Entity("CartWise.Infrastructure.Identity.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.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("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("CartWise.Infrastructure.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.Household", b => + { + b.Navigation("Members"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/CartWise.Infrastructure/Data/Migrations/20260810161409_AddHousehold.cs b/src/CartWise.Infrastructure/Data/Migrations/20260810161409_AddHousehold.cs new file mode 100644 index 0000000..0f0a36a --- /dev/null +++ b/src/CartWise.Infrastructure/Data/Migrations/20260810161409_AddHousehold.cs @@ -0,0 +1,81 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace CartWise.Infrastructure.Data.Migrations +{ + /// + public partial class AddHousehold : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "Households", + columns: table => new + { + HouseholdId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 120, nullable: false), + CreatedUtc = table.Column(type: "TEXT", nullable: false), + CreatedByUserId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Households", x => x.HouseholdId); + table.ForeignKey( + name: "FK_Households_AspNetUsers_CreatedByUserId", + column: x => x.CreatedByUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "HouseholdMembers", + columns: table => new + { + HouseholdId = table.Column(type: "TEXT", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + Role = table.Column(type: "INTEGER", nullable: false), + JoinedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_HouseholdMembers", x => new { x.HouseholdId, x.UserId }); + table.ForeignKey( + name: "FK_HouseholdMembers_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_HouseholdMembers_Households_HouseholdId", + column: x => x.HouseholdId, + principalTable: "Households", + principalColumn: "HouseholdId", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_HouseholdMembers_UserId", + table: "HouseholdMembers", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Households_CreatedByUserId", + table: "Households", + column: "CreatedByUserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "HouseholdMembers"); + + migrationBuilder.DropTable( + name: "Households"); + } + } +} diff --git a/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs b/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs index b925809..fe42641 100644 --- a/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs +++ b/src/CartWise.Infrastructure/Data/Migrations/CartWiseDbContextModelSnapshot.cs @@ -17,6 +17,52 @@ namespace CartWise.Infrastructure.Data.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + modelBuilder.Entity("CartWise.Domain.Entities.Household", b => + { + b.Property("HouseholdId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedByUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("HouseholdId"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("Households"); + }); + + modelBuilder.Entity("CartWise.Domain.Entities.HouseholdMember", b => + { + b.Property("HouseholdId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("JoinedUtc") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("HouseholdId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("HouseholdMembers"); + }); + modelBuilder.Entity("CartWise.Infrastructure.Identity.ApplicationUser", b => { b.Property("Id") @@ -216,6 +262,30 @@ namespace CartWise.Infrastructure.Data.Migrations b.ToTable("AspNetUserTokens", (string)null); }); + 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("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) @@ -266,6 +336,11 @@ namespace CartWise.Infrastructure.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + modelBuilder.Entity("CartWise.Domain.Entities.Household", b => + { + b.Navigation("Members"); + }); #pragma warning restore 612, 618 } } diff --git a/src/CartWise.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/src/CartWise.Infrastructure/InfrastructureServiceCollectionExtensions.cs index c2d305c..74fd7ef 100644 --- a/src/CartWise.Infrastructure/InfrastructureServiceCollectionExtensions.cs +++ b/src/CartWise.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -1,3 +1,4 @@ +using CartWise.Application.Interfaces; using CartWise.Infrastructure.Data; using CartWise.Infrastructure.Identity; using Microsoft.AspNetCore.Identity; @@ -15,6 +16,7 @@ public static class InfrastructureServiceCollectionExtensions ?? throw new InvalidOperationException("Connection string 'DefaultConnection' is not configured."); services.AddDbContext(options => options.UseSqlite(connectionString)); + services.AddScoped(sp => sp.GetRequiredService()); services.AddIdentity(options => { diff --git a/src/CartWise.Web/Authorization/HouseholdMemberAuthorizationHandler.cs b/src/CartWise.Web/Authorization/HouseholdMemberAuthorizationHandler.cs new file mode 100644 index 0000000..35236b1 --- /dev/null +++ b/src/CartWise.Web/Authorization/HouseholdMemberAuthorizationHandler.cs @@ -0,0 +1,35 @@ +using CartWise.Application.Interfaces; +using CartWise.Infrastructure.Identity; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; + +namespace CartWise.Web.Authorization; + +public class HouseholdMemberAuthorizationHandler : AuthorizationHandler +{ + private readonly IHouseholdService _householdService; + private readonly UserManager _userManager; + + public HouseholdMemberAuthorizationHandler(IHouseholdService householdService, UserManager userManager) + { + _householdService = householdService; + _userManager = userManager; + } + + protected override async Task HandleRequirementAsync( + AuthorizationHandlerContext context, + HouseholdMemberRequirement requirement, + Guid householdId) + { + var userId = _userManager.GetUserId(context.User); + if (userId is null) + { + return; + } + + if (await _householdService.IsMemberAsync(householdId, userId)) + { + context.Succeed(requirement); + } + } +} diff --git a/src/CartWise.Web/Authorization/HouseholdMemberRequirement.cs b/src/CartWise.Web/Authorization/HouseholdMemberRequirement.cs new file mode 100644 index 0000000..77b7bc7 --- /dev/null +++ b/src/CartWise.Web/Authorization/HouseholdMemberRequirement.cs @@ -0,0 +1,8 @@ +using Microsoft.AspNetCore.Authorization; + +namespace CartWise.Web.Authorization; + +// Paired with a Guid household id as the resource in resource-based authorization calls. +public class HouseholdMemberRequirement : IAuthorizationRequirement +{ +} diff --git a/src/CartWise.Web/Controllers/HouseholdController.cs b/src/CartWise.Web/Controllers/HouseholdController.cs new file mode 100644 index 0000000..66b08c2 --- /dev/null +++ b/src/CartWise.Web/Controllers/HouseholdController.cs @@ -0,0 +1,98 @@ +using CartWise.Application.Interfaces; +using CartWise.Infrastructure.Identity; +using CartWise.Web.ViewModels.Household; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace CartWise.Web.Controllers; + +[Authorize] +public class HouseholdController : Controller +{ + private readonly IHouseholdService _householdService; + private readonly UserManager _userManager; + + public HouseholdController(IHouseholdService householdService, UserManager userManager) + { + _householdService = householdService; + _userManager = userManager; + } + + [HttpGet] + [Route("household")] + public async Task Index() + { + var userId = _userManager.GetUserId(User)!; + var household = await _householdService.GetCurrentHouseholdAsync(userId); + + if (household is null) + { + return RedirectToAction(nameof(Create)); + } + + var members = new List(); + foreach (var member in household.Members.OrderBy(m => m.JoinedUtc)) + { + var memberUser = await _userManager.FindByIdAsync(member.UserId); + var displayName = !string.IsNullOrWhiteSpace(memberUser?.DisplayName) + ? memberUser.DisplayName + : memberUser?.Email ?? "Unknown"; + + members.Add(new HouseholdMemberViewModel + { + UserId = member.UserId, + DisplayName = displayName, + Role = member.Role, + JoinedUtc = member.JoinedUtc + }); + } + + var viewModel = new HouseholdIndexViewModel + { + HouseholdId = household.HouseholdId, + Name = household.Name, + CreatedUtc = household.CreatedUtc, + Members = members + }; + + return View(viewModel); + } + + [HttpGet] + [Route("household/create")] + public async Task Create() + { + var userId = _userManager.GetUserId(User)!; + var existingHousehold = await _householdService.GetCurrentHouseholdAsync(userId); + + if (existingHousehold is not null) + { + return RedirectToAction(nameof(Index)); + } + + return View(new CreateHouseholdViewModel()); + } + + [HttpPost] + [Route("household/create")] + [ValidateAntiForgeryToken] + public async Task Create(CreateHouseholdViewModel model) + { + if (!ModelState.IsValid) + { + return View(model); + } + + var userId = _userManager.GetUserId(User)!; + var result = await _householdService.CreateHouseholdAsync(model.Name, userId); + + if (!result.IsSuccess) + { + ModelState.AddModelError(string.Empty, result.Error!); + return View(model); + } + + return RedirectToAction(nameof(Index)); + } +} diff --git a/src/CartWise.Web/Program.cs b/src/CartWise.Web/Program.cs index bbb80c4..3877833 100644 --- a/src/CartWise.Web/Program.cs +++ b/src/CartWise.Web/Program.cs @@ -1,13 +1,21 @@ +using CartWise.Application; using CartWise.Infrastructure; using CartWise.Infrastructure.Data; +using CartWise.Web.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); // Add services to the container. builder.Services.AddControllersWithViews(); +builder.Services.AddApplication(); builder.Services.AddInfrastructure(builder.Configuration); +builder.Services.AddScoped(); +builder.Services.AddAuthorizationBuilder() + .AddPolicy("HouseholdMember", policy => policy.Requirements.Add(new HouseholdMemberRequirement())); + var app = builder.Build(); // Configure the HTTP request pipeline. diff --git a/src/CartWise.Web/ViewModels/Household/CreateHouseholdViewModel.cs b/src/CartWise.Web/ViewModels/Household/CreateHouseholdViewModel.cs new file mode 100644 index 0000000..834cdb2 --- /dev/null +++ b/src/CartWise.Web/ViewModels/Household/CreateHouseholdViewModel.cs @@ -0,0 +1,11 @@ +using System.ComponentModel.DataAnnotations; + +namespace CartWise.Web.ViewModels.Household; + +public class CreateHouseholdViewModel +{ + [Required] + [Display(Name = "Household name")] + [StringLength(120, MinimumLength = 1)] + public string Name { get; set; } = string.Empty; +} diff --git a/src/CartWise.Web/ViewModels/Household/HouseholdIndexViewModel.cs b/src/CartWise.Web/ViewModels/Household/HouseholdIndexViewModel.cs new file mode 100644 index 0000000..47844b0 --- /dev/null +++ b/src/CartWise.Web/ViewModels/Household/HouseholdIndexViewModel.cs @@ -0,0 +1,25 @@ +using CartWise.Domain.Enums; + +namespace CartWise.Web.ViewModels.Household; + +public class HouseholdIndexViewModel +{ + public Guid HouseholdId { get; set; } + + public string Name { get; set; } = string.Empty; + + public DateTime CreatedUtc { get; set; } + + public List Members { get; set; } = []; +} + +public class HouseholdMemberViewModel +{ + public string UserId { get; set; } = string.Empty; + + public string DisplayName { get; set; } = string.Empty; + + public HouseholdRole Role { get; set; } + + public DateTime JoinedUtc { get; set; } +} diff --git a/src/CartWise.Web/Views/Household/Create.cshtml b/src/CartWise.Web/Views/Household/Create.cshtml new file mode 100644 index 0000000..70ba38b --- /dev/null +++ b/src/CartWise.Web/Views/Household/Create.cshtml @@ -0,0 +1,24 @@ +@model CartWise.Web.ViewModels.Household.CreateHouseholdViewModel +@{ + ViewData["Title"] = "Create household"; +} + +

@ViewData["Title"]

+ +
+
+
+
+
+ + + +
+ +
+
+
+ +@section Scripts { + @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} +} diff --git a/src/CartWise.Web/Views/Household/Index.cshtml b/src/CartWise.Web/Views/Household/Index.cshtml new file mode 100644 index 0000000..5533b3e --- /dev/null +++ b/src/CartWise.Web/Views/Household/Index.cshtml @@ -0,0 +1,28 @@ +@model CartWise.Web.ViewModels.Household.HouseholdIndexViewModel +@{ + ViewData["Title"] = Model.Name; +} + +

@Model.Name

+

Created @Model.CreatedUtc.ToLocalTime().ToString("MMMM d, yyyy")

+ +

Members

+ + + + + + + + + + @foreach (var member in Model.Members) + { + + + + + + } + +
NameRoleJoined
@member.DisplayName@member.Role@member.JoinedUtc.ToLocalTime().ToString("MMMM d, yyyy")
diff --git a/src/CartWise.Web/Views/Shared/_Layout.cshtml b/src/CartWise.Web/Views/Shared/_Layout.cshtml index 18e248b..b1c6e93 100644 --- a/src/CartWise.Web/Views/Shared/_Layout.cshtml +++ b/src/CartWise.Web/Views/Shared/_Layout.cshtml @@ -23,6 +23,12 @@ + @if (User.Identity?.IsAuthenticated == true) + { + + } diff --git a/tests/CartWise.Application.Tests/CartWise.Application.Tests.csproj b/tests/CartWise.Application.Tests/CartWise.Application.Tests.csproj index 7555149..7772e07 100644 --- a/tests/CartWise.Application.Tests/CartWise.Application.Tests.csproj +++ b/tests/CartWise.Application.Tests/CartWise.Application.Tests.csproj @@ -7,6 +7,7 @@ + @@ -19,6 +20,7 @@ + \ No newline at end of file diff --git a/tests/CartWise.Application.Tests/HouseholdServiceTests.cs b/tests/CartWise.Application.Tests/HouseholdServiceTests.cs new file mode 100644 index 0000000..79a1697 --- /dev/null +++ b/tests/CartWise.Application.Tests/HouseholdServiceTests.cs @@ -0,0 +1,83 @@ +using CartWise.Application.Services; +using CartWise.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace CartWise.Application.Tests; + +public class HouseholdServiceTests : IDisposable +{ + private readonly CartWiseDbContext _db; + private readonly HouseholdService _sut; + + public HouseholdServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + _db = new CartWiseDbContext(options); + _sut = new HouseholdService(_db, TimeProvider.System); + } + + public void Dispose() + { + _db.Dispose(); + GC.SuppressFinalize(this); + } + + [Fact] + public async Task CreateHouseholdAsync_CreatesHouseholdWithOwnerMembership() + { + var result = await _sut.CreateHouseholdAsync("The Smiths", "user-1"); + + Assert.True(result.IsSuccess); + Assert.Equal("The Smiths", result.Value!.Name); + Assert.True(await _sut.IsMemberAsync(result.Value.HouseholdId, "user-1")); + } + + [Fact] + public async Task CreateHouseholdAsync_FailsWhenUserAlreadyBelongsToAHousehold() + { + await _sut.CreateHouseholdAsync("The Smiths", "user-1"); + + var result = await _sut.CreateHouseholdAsync("Second Household", "user-1"); + + Assert.False(result.IsSuccess); + Assert.NotNull(result.Error); + } + + [Fact] + public async Task CreateHouseholdAsync_FailsWhenNameIsMissing() + { + var result = await _sut.CreateHouseholdAsync(" ", "user-1"); + + Assert.False(result.IsSuccess); + } + + [Fact] + public async Task GetCurrentHouseholdAsync_ReturnsNullWhenUserHasNoHousehold() + { + var household = await _sut.GetCurrentHouseholdAsync("user-1"); + + Assert.Null(household); + } + + [Fact] + public async Task GetCurrentHouseholdAsync_ReturnsHouseholdForMember() + { + var created = await _sut.CreateHouseholdAsync("The Smiths", "user-1"); + + var household = await _sut.GetCurrentHouseholdAsync("user-1"); + + Assert.NotNull(household); + Assert.Equal(created.Value!.HouseholdId, household!.HouseholdId); + } + + [Fact] + public async Task IsMemberAsync_ReturnsFalseForNonMember() + { + var created = await _sut.CreateHouseholdAsync("The Smiths", "user-1"); + + Assert.False(await _sut.IsMemberAsync(created.Value!.HouseholdId, "user-2")); + } +} diff --git a/tests/CartWise.Application.Tests/UnitTest1.cs b/tests/CartWise.Application.Tests/UnitTest1.cs deleted file mode 100644 index 2743377..0000000 --- a/tests/CartWise.Application.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CartWise.Application.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/tests/CartWise.Domain.Tests/HouseholdTests.cs b/tests/CartWise.Domain.Tests/HouseholdTests.cs new file mode 100644 index 0000000..5363df5 --- /dev/null +++ b/tests/CartWise.Domain.Tests/HouseholdTests.cs @@ -0,0 +1,76 @@ +using CartWise.Domain.Entities; +using CartWise.Domain.Enums; + +namespace CartWise.Domain.Tests; + +public class HouseholdTests +{ + private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void Constructor_CreatesOwnerMembershipForCreator() + { + var household = new Household("The Smiths", "user-1", Now); + + var member = Assert.Single(household.Members); + Assert.Equal("user-1", member.UserId); + Assert.Equal(HouseholdRole.Owner, member.Role); + Assert.Equal(household.HouseholdId, member.HouseholdId); + Assert.Equal(Now, member.JoinedUtc); + } + + [Fact] + public void Constructor_TrimsName() + { + var household = new Household(" The Smiths ", "user-1", Now); + + Assert.Equal("The Smiths", household.Name); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Constructor_ThrowsWhenNameIsMissing(string? name) + { + Assert.Throws(() => new Household(name!, "user-1", Now)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void Constructor_ThrowsWhenCreatedByUserIdIsMissing(string? userId) + { + Assert.Throws(() => new Household("The Smiths", userId!, Now)); + } + + [Fact] + public void AddMember_AddsMemberWithGivenRole() + { + var household = new Household("The Smiths", "user-1", Now); + + var member = household.AddMember("user-2", HouseholdRole.Member, Now); + + Assert.Equal(2, household.Members.Count); + Assert.Equal("user-2", member.UserId); + Assert.Equal(HouseholdRole.Member, member.Role); + } + + [Fact] + public void AddMember_ThrowsWhenUserIsAlreadyAMember() + { + var household = new Household("The Smiths", "user-1", Now); + + Assert.Throws(() => household.AddMember("user-1", HouseholdRole.Member, Now)); + } + + [Fact] + public void IsMember_ReturnsTrueForExistingMemberAndFalseOtherwise() + { + var household = new Household("The Smiths", "user-1", Now); + + Assert.True(household.IsMember("user-1")); + Assert.False(household.IsMember("user-2")); + } +} diff --git a/tests/CartWise.Domain.Tests/UnitTest1.cs b/tests/CartWise.Domain.Tests/UnitTest1.cs deleted file mode 100644 index 66062b1..0000000 --- a/tests/CartWise.Domain.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace CartWise.Domain.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -} diff --git a/tests/CartWise.Web.Tests/HouseholdAccessControlTests.cs b/tests/CartWise.Web.Tests/HouseholdAccessControlTests.cs new file mode 100644 index 0000000..64d6321 --- /dev/null +++ b/tests/CartWise.Web.Tests/HouseholdAccessControlTests.cs @@ -0,0 +1,87 @@ +using System.Net; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace CartWise.Web.Tests; + +public class HouseholdAccessControlTests : IClassFixture +{ + private readonly CartWiseWebApplicationFactory _factory; + + public HouseholdAccessControlTests(CartWiseWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task AnonymousUser_IsRedirectedToLoginWhenRequestingHousehold() + { + var client = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + + var response = await client.GetAsync("/household"); + + Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); + Assert.Contains("/Account/Login", response.Headers.Location?.ToString()); + } + + [Fact] + public async Task EachUser_OnlySeesTheirOwnHousehold() + { + var clientA = _factory.CreateClient(); + var clientB = _factory.CreateClient(); + + await RegisterAsync(clientA, "alice2@example.com", "Alice"); + await RegisterAsync(clientB, "bob2@example.com", "Bob"); + + await CreateHouseholdAsync(clientA, "Alice's Household"); + await CreateHouseholdAsync(clientB, "Bob's Household"); + + var householdPageA = await clientA.GetStringAsync("/household"); + var householdPageB = await clientB.GetStringAsync("/household"); + + HtmlAssert.Contains("Alice's Household", householdPageA); + HtmlAssert.DoesNotContain("Bob's Household", householdPageA); + + HtmlAssert.Contains("Bob'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 + { + ["__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 + { + ["__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."); + } +} diff --git a/tests/CartWise.Web.Tests/HouseholdAuthorizationTests.cs b/tests/CartWise.Web.Tests/HouseholdAuthorizationTests.cs new file mode 100644 index 0000000..f12dc16 --- /dev/null +++ b/tests/CartWise.Web.Tests/HouseholdAuthorizationTests.cs @@ -0,0 +1,66 @@ +using System.Security.Claims; +using CartWise.Application.Interfaces; +using CartWise.Infrastructure.Identity; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.Extensions.DependencyInjection; + +namespace CartWise.Web.Tests; + +public class HouseholdAuthorizationTests : IClassFixture +{ + private readonly CartWiseWebApplicationFactory _factory; + + public HouseholdAuthorizationTests(CartWiseWebApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task HouseholdMemberPolicy_SucceedsForMemberAndFailsForNonMember() + { + using var scope = _factory.Services.CreateScope(); + var services = scope.ServiceProvider; + + var userManager = services.GetRequiredService>(); + var householdService = services.GetRequiredService(); + var authorizationService = services.GetRequiredService(); + + var userA = await CreateUserAsync(userManager, "alice@example.com"); + var userB = await CreateUserAsync(userManager, "bob@example.com"); + + var householdA = await householdService.CreateHouseholdAsync("Household A", userA.Id); + Assert.True(householdA.IsSuccess); + + var principalA = CreatePrincipal(userA); + var principalB = CreatePrincipal(userB); + + var ownResult = await authorizationService.AuthorizeAsync(principalA, householdA.Value!.HouseholdId, "HouseholdMember"); + var crossResult = await authorizationService.AuthorizeAsync(principalB, householdA.Value!.HouseholdId, "HouseholdMember"); + + Assert.True(ownResult.Succeeded); + Assert.False(crossResult.Succeeded); + } + + private static async Task CreateUserAsync(UserManager userManager, string email) + { + var user = new ApplicationUser + { + UserName = email, + Email = email, + DisplayName = email, + CreatedUtc = DateTime.UtcNow + }; + + var result = await userManager.CreateAsync(user, "Sup3rSecret!23"); + Assert.True(result.Succeeded); + + return user; + } + + private static ClaimsPrincipal CreatePrincipal(ApplicationUser user) + { + var identity = new ClaimsIdentity([new Claim(ClaimTypes.NameIdentifier, user.Id)], "Test"); + return new ClaimsPrincipal(identity); + } +} diff --git a/tests/CartWise.Web.Tests/HtmlAssert.cs b/tests/CartWise.Web.Tests/HtmlAssert.cs new file mode 100644 index 0000000..e2343a2 --- /dev/null +++ b/tests/CartWise.Web.Tests/HtmlAssert.cs @@ -0,0 +1,18 @@ +using System.Net; + +namespace CartWise.Web.Tests; + +// Rendered Razor output HTML-encodes special characters (e.g. ' -> '), so raw substring +// assertions against response bodies produce false negatives for ordinary test data. Decode first. +internal static class HtmlAssert +{ + public static void Contains(string expectedText, string html) + { + Assert.Contains(expectedText, WebUtility.HtmlDecode(html)); + } + + public static void DoesNotContain(string expectedText, string html) + { + Assert.DoesNotContain(expectedText, WebUtility.HtmlDecode(html)); + } +}