| @@ -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')" | |||
| ] | |||
| } | |||
| } | |||
| @@ -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<T>.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 | |||
| @@ -0,0 +1,12 @@ | |||
| <?xml version="1.0" encoding="UTF-8"?> | |||
| <project version="4"> | |||
| <component name="DataSourceManagerImpl" format="xml" multifile-model="true"> | |||
| <data-source source="LOCAL" name="verify.db" uuid="14da974c-9f66-4dc4-8d2b-32b9c3a4ed59"> | |||
| <driver-ref>sqlite.xerial</driver-ref> | |||
| <synchronize>true</synchronize> | |||
| <jdbc-driver>org.sqlite.JDBC</jdbc-driver> | |||
| <jdbc-url>jdbc:sqlite:G:\development\C Sharp AI\CartWise\src\CartWise.Web\verify.db</jdbc-url> | |||
| <working-dir>$ProjectFileDir$</working-dir> | |||
| </data-source> | |||
| </component> | |||
| </project> | |||
| @@ -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` | |||
| @@ -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). | |||
| @@ -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<Household>`), `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<Household>`/`DbSet<HouseholdMember>` 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<ApplicationUser>` 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 | |||
| @@ -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<IHouseholdService, HouseholdService>(); | |||
| return services; | |||
| } | |||
| } | |||
| @@ -4,6 +4,10 @@ | |||
| <ProjectReference Include="..\CartWise.Domain\CartWise.Domain.csproj" /> | |||
| </ItemGroup> | |||
| <ItemGroup> | |||
| <PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.10" /> | |||
| </ItemGroup> | |||
| <PropertyGroup> | |||
| <TargetFramework>net10.0</TargetFramework> | |||
| </PropertyGroup> | |||
| @@ -0,0 +1,13 @@ | |||
| using CartWise.Domain.Entities; | |||
| using Microsoft.EntityFrameworkCore; | |||
| namespace CartWise.Application.Interfaces; | |||
| public interface IApplicationDbContext | |||
| { | |||
| DbSet<Household> Households { get; } | |||
| DbSet<HouseholdMember> HouseholdMembers { get; } | |||
| Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); | |||
| } | |||
| @@ -0,0 +1,13 @@ | |||
| using CartWise.Application.Results; | |||
| using CartWise.Domain.Entities; | |||
| namespace CartWise.Application.Interfaces; | |||
| public interface IHouseholdService | |||
| { | |||
| Task<Result<Household>> CreateHouseholdAsync(string name, string userId, CancellationToken cancellationToken = default); | |||
| Task<Household?> GetCurrentHouseholdAsync(string userId, CancellationToken cancellationToken = default); | |||
| Task<bool> IsMemberAsync(Guid householdId, string userId, CancellationToken cancellationToken = default); | |||
| } | |||
| @@ -0,0 +1,21 @@ | |||
| namespace CartWise.Application.Results; | |||
| public class Result<T> | |||
| { | |||
| 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<T> Success(T value) => new(true, value, null); | |||
| public static Result<T> Failure(string error) => new(false, default, error); | |||
| } | |||
| @@ -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<Result<Household>> CreateHouseholdAsync(string name, string userId, CancellationToken cancellationToken = default) | |||
| { | |||
| var alreadyBelongsToAHousehold = await _db.HouseholdMembers | |||
| .AsNoTracking() | |||
| .AnyAsync(m => m.UserId == userId, cancellationToken); | |||
| if (alreadyBelongsToAHousehold) | |||
| { | |||
| return Result<Household>.Failure("You already belong to a household."); | |||
| } | |||
| Household household; | |||
| try | |||
| { | |||
| household = new Household(name, userId, _timeProvider.GetUtcNow().UtcDateTime); | |||
| } | |||
| catch (ArgumentException ex) | |||
| { | |||
| return Result<Household>.Failure(ex.Message); | |||
| } | |||
| _db.Households.Add(household); | |||
| await _db.SaveChangesAsync(cancellationToken); | |||
| return Result<Household>.Success(household); | |||
| } | |||
| public async Task<Household?> 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<bool> IsMemberAsync(Guid householdId, string userId, CancellationToken cancellationToken = default) | |||
| { | |||
| return await _db.HouseholdMembers | |||
| .AsNoTracking() | |||
| .AnyAsync(m => m.HouseholdId == householdId && m.UserId == userId, cancellationToken); | |||
| } | |||
| } | |||
| @@ -0,0 +1,56 @@ | |||
| using CartWise.Domain.Enums; | |||
| namespace CartWise.Domain.Entities; | |||
| public class Household | |||
| { | |||
| private readonly List<HouseholdMember> _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<HouseholdMember> 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); | |||
| } | |||
| @@ -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; | |||
| } | |||
| } | |||
| @@ -0,0 +1,7 @@ | |||
| namespace CartWise.Domain.Enums; | |||
| public enum HouseholdRole | |||
| { | |||
| Owner = 0, | |||
| Member = 1 | |||
| } | |||
| @@ -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<ApplicationUser> | |||
| public class CartWiseDbContext : IdentityDbContext<ApplicationUser>, IApplicationDbContext | |||
| { | |||
| public CartWiseDbContext(DbContextOptions<CartWiseDbContext> options) | |||
| : base(options) | |||
| { | |||
| } | |||
| public DbSet<Household> Households => Set<Household>(); | |||
| public DbSet<HouseholdMember> HouseholdMembers => Set<HouseholdMember>(); | |||
| protected override void OnModelCreating(ModelBuilder builder) | |||
| { | |||
| base.OnModelCreating(builder); | |||
| builder.ApplyConfigurationsFromAssembly(typeof(CartWiseDbContext).Assembly); | |||
| } | |||
| } | |||
| @@ -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<Household> | |||
| { | |||
| public void Configure(EntityTypeBuilder<Household> builder) | |||
| { | |||
| builder.HasKey(h => h.HouseholdId); | |||
| builder.Property(h => h.Name) | |||
| .HasMaxLength(120) | |||
| .IsRequired(); | |||
| builder.Property(h => h.CreatedByUserId) | |||
| .IsRequired(); | |||
| builder.HasOne<ApplicationUser>() | |||
| .WithMany() | |||
| .HasForeignKey(h => h.CreatedByUserId) | |||
| .OnDelete(DeleteBehavior.Restrict); | |||
| builder.Metadata.FindNavigation(nameof(Household.Members))! | |||
| .SetPropertyAccessMode(PropertyAccessMode.Field); | |||
| } | |||
| } | |||
| @@ -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<HouseholdMember> | |||
| { | |||
| public void Configure(EntityTypeBuilder<HouseholdMember> builder) | |||
| { | |||
| builder.HasKey(m => new { m.HouseholdId, m.UserId }); | |||
| builder.Property(m => m.UserId) | |||
| .IsRequired(); | |||
| builder.HasOne<Household>() | |||
| .WithMany(h => h.Members) | |||
| .HasForeignKey(m => m.HouseholdId) | |||
| .OnDelete(DeleteBehavior.Cascade); | |||
| builder.HasOne<ApplicationUser>() | |||
| .WithMany() | |||
| .HasForeignKey(m => m.UserId) | |||
| .OnDelete(DeleteBehavior.Cascade); | |||
| builder.HasIndex(m => m.UserId); | |||
| } | |||
| } | |||
| @@ -0,0 +1,350 @@ | |||
| // <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("20260810161409_AddHousehold")] | |||
| partial class AddHousehold | |||
| { | |||
| /// <inheritdoc /> | |||
| 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<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.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.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<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"); | |||
| }); | |||
| #pragma warning restore 612, 618 | |||
| } | |||
| } | |||
| } | |||
| @@ -0,0 +1,81 @@ | |||
| using System; | |||
| using Microsoft.EntityFrameworkCore.Migrations; | |||
| #nullable disable | |||
| namespace CartWise.Infrastructure.Data.Migrations | |||
| { | |||
| /// <inheritdoc /> | |||
| public partial class AddHousehold : Migration | |||
| { | |||
| /// <inheritdoc /> | |||
| protected override void Up(MigrationBuilder migrationBuilder) | |||
| { | |||
| migrationBuilder.CreateTable( | |||
| name: "Households", | |||
| columns: table => new | |||
| { | |||
| HouseholdId = table.Column<Guid>(type: "TEXT", nullable: false), | |||
| Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false), | |||
| CreatedUtc = table.Column<DateTime>(type: "TEXT", nullable: false), | |||
| CreatedByUserId = table.Column<string>(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<Guid>(type: "TEXT", nullable: false), | |||
| UserId = table.Column<string>(type: "TEXT", nullable: false), | |||
| Role = table.Column<int>(type: "INTEGER", nullable: false), | |||
| JoinedUtc = table.Column<DateTime>(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"); | |||
| } | |||
| /// <inheritdoc /> | |||
| protected override void Down(MigrationBuilder migrationBuilder) | |||
| { | |||
| migrationBuilder.DropTable( | |||
| name: "HouseholdMembers"); | |||
| migrationBuilder.DropTable( | |||
| name: "Households"); | |||
| } | |||
| } | |||
| } | |||
| @@ -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<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.Infrastructure.Identity.ApplicationUser", b => | |||
| { | |||
| b.Property<string>("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<string>", 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 | |||
| } | |||
| } | |||
| @@ -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<CartWiseDbContext>(options => options.UseSqlite(connectionString)); | |||
| services.AddScoped<IApplicationDbContext>(sp => sp.GetRequiredService<CartWiseDbContext>()); | |||
| services.AddIdentity<ApplicationUser, IdentityRole>(options => | |||
| { | |||
| @@ -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<HouseholdMemberRequirement, Guid> | |||
| { | |||
| private readonly IHouseholdService _householdService; | |||
| private readonly UserManager<ApplicationUser> _userManager; | |||
| public HouseholdMemberAuthorizationHandler(IHouseholdService householdService, UserManager<ApplicationUser> 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); | |||
| } | |||
| } | |||
| } | |||
| @@ -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 | |||
| { | |||
| } | |||
| @@ -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<ApplicationUser> _userManager; | |||
| public HouseholdController(IHouseholdService householdService, UserManager<ApplicationUser> userManager) | |||
| { | |||
| _householdService = householdService; | |||
| _userManager = userManager; | |||
| } | |||
| [HttpGet] | |||
| [Route("household")] | |||
| public async Task<IActionResult> Index() | |||
| { | |||
| var userId = _userManager.GetUserId(User)!; | |||
| var household = await _householdService.GetCurrentHouseholdAsync(userId); | |||
| if (household is null) | |||
| { | |||
| return RedirectToAction(nameof(Create)); | |||
| } | |||
| var members = new List<HouseholdMemberViewModel>(); | |||
| 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<IActionResult> 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<IActionResult> 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)); | |||
| } | |||
| } | |||
| @@ -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<IAuthorizationHandler, HouseholdMemberAuthorizationHandler>(); | |||
| builder.Services.AddAuthorizationBuilder() | |||
| .AddPolicy("HouseholdMember", policy => policy.Requirements.Add(new HouseholdMemberRequirement())); | |||
| var app = builder.Build(); | |||
| // Configure the HTTP request pipeline. | |||
| @@ -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; | |||
| } | |||
| @@ -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<HouseholdMemberViewModel> 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; } | |||
| } | |||
| @@ -0,0 +1,24 @@ | |||
| @model CartWise.Web.ViewModels.Household.CreateHouseholdViewModel | |||
| @{ | |||
| ViewData["Title"] = "Create household"; | |||
| } | |||
| <h1>@ViewData["Title"]</h1> | |||
| <div class="row"> | |||
| <div class="col-md-6"> | |||
| <form asp-controller="Household" asp-action="Create" method="post"> | |||
| <div asp-validation-summary="ModelOnly" class="text-danger"></div> | |||
| <div class="form-group mb-3"> | |||
| <label asp-for="Name" class="form-label"></label> | |||
| <input asp-for="Name" class="form-control" autofocus /> | |||
| <span asp-validation-for="Name" class="text-danger"></span> | |||
| </div> | |||
| <button type="submit" class="btn btn-primary">Create household</button> | |||
| </form> | |||
| </div> | |||
| </div> | |||
| @section Scripts { | |||
| @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} | |||
| } | |||
| @@ -0,0 +1,28 @@ | |||
| @model CartWise.Web.ViewModels.Household.HouseholdIndexViewModel | |||
| @{ | |||
| ViewData["Title"] = Model.Name; | |||
| } | |||
| <h1>@Model.Name</h1> | |||
| <p class="text-muted">Created @Model.CreatedUtc.ToLocalTime().ToString("MMMM d, yyyy")</p> | |||
| <h2>Members</h2> | |||
| <table class="table"> | |||
| <thead> | |||
| <tr> | |||
| <th scope="col">Name</th> | |||
| <th scope="col">Role</th> | |||
| <th scope="col">Joined</th> | |||
| </tr> | |||
| </thead> | |||
| <tbody> | |||
| @foreach (var member in Model.Members) | |||
| { | |||
| <tr> | |||
| <td>@member.DisplayName</td> | |||
| <td>@member.Role</td> | |||
| <td>@member.JoinedUtc.ToLocalTime().ToString("MMMM d, yyyy")</td> | |||
| </tr> | |||
| } | |||
| </tbody> | |||
| </table> | |||
| @@ -23,6 +23,12 @@ | |||
| <li class="nav-item"> | |||
| <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Index">Home</a> | |||
| </li> | |||
| @if (User.Identity?.IsAuthenticated == true) | |||
| { | |||
| <li class="nav-item"> | |||
| <a class="nav-link text-dark" asp-controller="Household" asp-action="Index">Household</a> | |||
| </li> | |||
| } | |||
| <li class="nav-item"> | |||
| <a class="nav-link text-dark" asp-area="" asp-controller="Home" asp-action="Privacy">Privacy</a> | |||
| </li> | |||
| @@ -7,6 +7,7 @@ | |||
| <ItemGroup> | |||
| <PackageReference Include="coverlet.collector" Version="6.0.4" /> | |||
| <PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.10" /> | |||
| <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" /> | |||
| <PackageReference Include="xunit" Version="2.9.3" /> | |||
| <PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" /> | |||
| @@ -19,6 +20,7 @@ | |||
| <ItemGroup> | |||
| <ProjectReference Include="..\..\src\CartWise.Application\CartWise.Application.csproj" /> | |||
| <ProjectReference Include="..\..\src\CartWise.Domain\CartWise.Domain.csproj" /> | |||
| <ProjectReference Include="..\..\src\CartWise.Infrastructure\CartWise.Infrastructure.csproj" /> | |||
| </ItemGroup> | |||
| </Project> | |||
| @@ -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<CartWiseDbContext>() | |||
| .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")); | |||
| } | |||
| } | |||
| @@ -1,10 +0,0 @@ | |||
| namespace CartWise.Application.Tests; | |||
| public class UnitTest1 | |||
| { | |||
| [Fact] | |||
| public void Test1() | |||
| { | |||
| } | |||
| } | |||
| @@ -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<ArgumentException>(() => new Household(name!, "user-1", Now)); | |||
| } | |||
| [Theory] | |||
| [InlineData("")] | |||
| [InlineData(" ")] | |||
| [InlineData(null)] | |||
| public void Constructor_ThrowsWhenCreatedByUserIdIsMissing(string? userId) | |||
| { | |||
| Assert.Throws<ArgumentException>(() => 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<InvalidOperationException>(() => 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")); | |||
| } | |||
| } | |||
| @@ -1,10 +0,0 @@ | |||
| namespace CartWise.Domain.Tests; | |||
| public class UnitTest1 | |||
| { | |||
| [Fact] | |||
| public void Test1() | |||
| { | |||
| } | |||
| } | |||
| @@ -0,0 +1,87 @@ | |||
| using System.Net; | |||
| using System.Text.RegularExpressions; | |||
| using Microsoft.AspNetCore.Mvc.Testing; | |||
| namespace CartWise.Web.Tests; | |||
| public class HouseholdAccessControlTests : IClassFixture<CartWiseWebApplicationFactory> | |||
| { | |||
| 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<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,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<CartWiseWebApplicationFactory> | |||
| { | |||
| 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<UserManager<ApplicationUser>>(); | |||
| var householdService = services.GetRequiredService<IHouseholdService>(); | |||
| var authorizationService = services.GetRequiredService<IAuthorizationService>(); | |||
| 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<ApplicationUser> CreateUserAsync(UserManager<ApplicationUser> 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); | |||
| } | |||
| } | |||
| @@ -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)); | |||
| } | |||
| } | |||
Powered by TurnKey Linux.