Add user avatar and profile feature, consolidate Auth and UserManagement features (#257)

This commit is contained in:
Aaron Po
2026-08-31 00:11:48 -04:00
committed by GitHub
parent ade2128a73
commit 39c5a89ca7
130 changed files with 1005 additions and 613 deletions

View File

@@ -25,9 +25,11 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
| Table | Backed by | Notes |
| --------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Auth`/`Features.UserManagement` | `Username` and `Email` are unique |
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Auth` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Auth` | Written when a user confirms their email |
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique |
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Users` | Written when a user confirms their email |
| `UserProfile` | `Domain.Entities.UserProfile`, `Features.Users` | 1:1 with `UserAccount`; holds the user's biography; written via `IUserProfileRepository` |
| `UserAvatar` | `Domain.Entities.UserAvatar`, `Features.Users` | Links a `UserProfile` to a `Photo` as its avatar; written via `IUserProfileRepository.SaveAvatarAsync` |
| `BreweryPost` | `Domain.Entities.BreweryPost`, `Features.Breweries` | A user-submitted brewery listing |
| `BreweryPostLocation` | `Domain.Entities.BreweryPostLocation`, `Features.Breweries` | 1:1 with `BreweryPost`; `Coordinates` is a `GEOGRAPHY` column, read via a plain Dapper query that selects `CONVERT(varbinary(max), Coordinates)`, since Dapper can't deserialize SQL Server UDTs directly |
| `City` | `Domain.Entities.City`, `Features.Locations` | Referenced by `BreweryPostLocation.CityID`; written via `ILocationRepository`, read via `ILocationRepository` and `IBreweryRepository`'s location joins, currently written only by `Database.Seed` |
@@ -42,7 +44,6 @@ repository or seed data yet:
| Table | Purpose (from schema) |
| ------------------ | -------------------------------------------------------------------------------------------------------- |
| `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar |
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
| `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet |
| `BeerPost` | A beer listing, linked to a `BreweryPost` and `BeerStyle`; `ABV`/`IBU` are constrained (`0-67`, `0-120`); has a `Domain.Entities.BeerPost` type, but no repository yet |
@@ -80,7 +81,7 @@ repository (`Features.PhotoUpload`) but no seed data.
409, 400, ...); an unmapped `SqlException` (e.g. a genuine connectivity or
constraint-violation failure) maps to 503.
- One repository-level exception: `Features.Auth`'s `AuthRepository` does
catch `SqlException` directly, to detect a duplicate-key race (SQL Server
error 2601 or 2627) when two requests try to verify the same account
concurrently — see `Auth Repository.IsDuplicateVerificationViolation`.
- One exception: `Features.Users`' `DapperUserStore` does catch
`SqlException` directly, to detect a duplicate-key race (SQL Server error
2601 or 2627) when two requests try to verify the same account
concurrently — see `DapperUserStore.IsDuplicateKeyViolation`.

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 50 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 71 KiB

After

Width:  |  Height:  |  Size: 76 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -19,17 +19,22 @@ package "API.Core (thin host)" #E3F2FD {
}
package "Feature Slices" #F3E5F5 {
[Features.Auth] as AuthSlice
[Features.Users] as UsersSlice
[Features.Breweries] as BrewerySlice
[Features.UserManagement] as UserSlice
[Features.Emails] as EmailsSlice
[Features.Locations] as LocationsSlice
note right of AuthSlice
note right of UsersSlice
Each slice owns its own:
- Controller (HTTP endpoints)
- Commands/Queries + Handlers
- Validators
- Repository (Dapper)
Features.Users covers auth
(Commands/Authentication),
account management
(Commands/Account), and
profile/avatar (Commands/Profile)
end note
note right of EmailsSlice
No controller - invoked only
@@ -70,6 +75,7 @@ package "Domain Layer" #E8F5E9 {
[Domain.Exceptions] as DomainExceptions
note right of Domain
- UserAccount, UserCredential, UserVerification
- UserProfile, UserAvatar
- BreweryPost, BreweryPostLocation
- City, StateProvince, Country
- BeerPost, BeerStyle
@@ -84,29 +90,24 @@ database "SQL Server" {
node "Database.Seed\n(separate console app)" as Seed
' Relationships
API ..> AuthSlice : AddApplicationPart\n+ MediatR scan
API ..> UsersSlice : AddApplicationPart\n+ MediatR scan
API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan
API ..> UserSlice : AddApplicationPart\n+ MediatR scan
API ..> EmailsSlice : MediatR scan only\n(no controller)
Seed ..> AuthSlice : AddFeaturesAuth()\n(DI only, no API/MediatR)
Seed ..> UsersSlice : AddFeaturesUsers()\n(DI only, no API/MediatR)
Seed ..> BrewerySlice : AddFeaturesBreweries()
Seed ..> LocationsSlice : AddFeaturesLocations()
AuthSlice --> Sql
AuthSlice --> JWT
AuthSlice --> PwdHash
AuthSlice --> SharedContracts
AuthSlice --> SharedApp
UsersSlice --> Sql
UsersSlice --> JWT
UsersSlice --> PwdHash
UsersSlice --> SharedContracts
UsersSlice --> SharedApp
BrewerySlice --> Sql
BrewerySlice --> SharedContracts
BrewerySlice --> SharedApp
UserSlice --> Sql
UserSlice --> SharedContracts
UserSlice --> SharedApp
LocationsSlice --> Sql
EmailsSlice --> Email
@@ -114,21 +115,19 @@ EmailsSlice --> EmailTemplates
EmailsSlice --> SharedApp
' The one cross-slice interaction: a MediatR command contract, not a project reference
AuthSlice ..> SharedApp : sends\nSendRegistrationEmailCommand
UsersSlice ..> SharedApp : sends\nSendRegistrationEmailCommand
EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
Sql --> Domain
AuthSlice --> Tables : Dapper SQL
UsersSlice --> Tables : Dapper SQL
BrewerySlice --> Tables : Dapper SQL
UserSlice --> Tables : Dapper SQL
LocationsSlice --> Tables : Dapper SQL
AuthSlice --> Domain
UsersSlice --> Domain
BrewerySlice --> Domain
UserSlice --> Domain
LocationsSlice --> Domain
AuthSlice --> DomainExceptions
UsersSlice --> DomainExceptions
' Notes
note left of Sql
@@ -138,7 +137,7 @@ note left of Sql
no stored procedures
end note
note bottom of AuthSlice
note bottom of UsersSlice
No Features.* project
ever references another
Features.* project directly

View File

@@ -39,11 +39,23 @@ package "User & Auth" {
* VerificationDateTime: DATETIME
}
entity "UserAvatar (*)" as Avatar {
* UserAvatarID: UNIQUEIDENTIFIER <<PK>>
entity "UserProfile" as Profile {
* UserProfileID: UNIQUEIDENTIFIER <<PK>>
--
* UserAccountID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
* Biography: NVARCHAR(MAX)
}
entity "UserAvatar" as Avatar {
* UserAvatarID: UNIQUEIDENTIFIER <<PK>>
--
* UserProfileID: UNIQUEIDENTIFIER <<FK>>
* PhotoID: UNIQUEIDENTIFIER <<FK>>
* ValidFrom: DATETIME2(3)
ValidTo: DATETIME2(3)
..
At most one row per UserProfileID
has ValidTo IS NULL (active avatar)
}
entity "UserFollow (*)" as Follow {
@@ -163,7 +175,8 @@ package "Beer (*)" {
' Relationships: User & Auth
User ||--o{ Cred : "has"
User ||--o| Verify : "has"
User ||--o| Avatar : "has"
User ||--o| Profile : "has"
Profile ||--o{ Avatar : "has (history)"
Avatar }o--|| Photo : "refers to"
User ||--o{ Follow : "follows (UserAccountID)"
User ||--o{ Follow : "followed by (FollowingID)"
@@ -193,9 +206,9 @@ note bottom of User
Auth reads/writes (registration, login, credential
rotation, verification) go through ASP.NET Core
Identity's UserManager<ApplicationUser>, backed by
Features.Auth's DapperUserStore - inline SQL, no
stored procedures. Profile data still goes through
Features.UserManagement's UserAccountRepository.
Features.Users' DapperUserStore - inline SQL, no
stored procedures. Profile/avatar data goes through
Features.Users' UserProfileRepository.
end note
note bottom of Brewery

View File

@@ -130,9 +130,8 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
restores/builds against Core.slnx
then runs `dotnet test` for every
Features/*.Tests project in turn:
- Features.Auth.Tests
- Features.Users.Tests
- Features.Breweries.Tests
- Features.UserManagement.Tests
- Features.Emails.Tests
Uses: Moq to mock each handler's
@@ -261,9 +260,8 @@ package "Minimal Environment\n(docker-compose.min.yaml)" #FFF9C4 {
folder "test-results/\n(mounted volume)" as Results {
file "api-specs/\n results.trx" as Result1
file "Features.Auth.Tests.trx" as Result2
file "Features.Users.Tests.trx" as Result2
file "Features.Breweries.Tests.trx" as Result3
file "Features.UserManagement.Tests.trx" as Result4
file "Features.Emails.Tests.trx" as Result5
note bottom

View File

@@ -101,7 +101,7 @@ docker compose --env-file web/.env.test -f web/docker-compose.test.yaml up --abo
# View results
ls -la test-results/
cat test-results/api-specs/results.trx
cat test-results/Features.Auth.Tests.trx
cat test-results/Features.Users.Tests.trx
# Clean up
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v

View File

@@ -10,10 +10,10 @@ The project uses a multi-layered testing approach across backend and frontend:
- **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against a
live, seeded database
- **Features.\*.Tests** - One unit test project per backend feature slice
(`Features.Auth.Tests`, `Features.Breweries.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`,
`Features.PhotoUpload.Tests`), covering that slice's command/query
handlers, with dependencies mocked via Moq (no real database required)
(`Features.Users.Tests`, `Features.Breweries.Tests`,
`Features.Emails.Tests`, `Features.PhotoUpload.Tests`), covering that
slice's command/query handlers, with dependencies mocked via Moq (no real
database required)
- **Storybook Vitest project** - Browser-based interaction tests for shared
website stories
- **Storybook Playwright suite** - Browser checks against Storybook-rendered
@@ -45,9 +45,8 @@ ls -la test-results/
# View specific test results
cat test-results/api-specs/results.trx
cat test-results/Features.Auth.Tests.trx
cat test-results/Features.Users.Tests.trx
cat test-results/Features.Breweries.Tests.trx
cat test-results/Features.UserManagement.Tests.trx
cat test-results/Features.Emails.Tests.trx
cat test-results/Features.PhotoUpload.Tests.trx
```
@@ -83,9 +82,8 @@ handlers:
```bash
cd web/backend
dotnet test Features/Features.Auth.Tests/Features.Auth.Tests.csproj
dotnet test Features/Features.Users.Tests/Features.Users.Tests.csproj
dotnet test Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj
dotnet test Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj
dotnet test Features/Features.Emails.Tests/Features.Emails.Tests.csproj
dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj
@@ -150,7 +148,7 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
### Current coverage
**Features.Auth.Tests**:
**Features.Users.Tests**:
- User registration with validation
- User login with JWT token generation
@@ -162,6 +160,7 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
- Account management: username, email, password, and profile updates, and
account deletion (blocked while dependent posts, comments, photos, or follows
exist)
- User get-by-id/get-all queries
**Features.Breweries.Tests**:
@@ -169,10 +168,6 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
- Brewery location queries: all locations, and locations within a given range
of a coordinate
**Features.UserManagement.Tests**:
- User get-by-id/get-all queries and the (currently unrouted) update command
**Features.Emails.Tests**:
- Registration and resend-confirmation email dispatch handlers
@@ -264,10 +259,10 @@ Scenario: Successful user registration
And my account should be created
```
### Features.Auth.Tests
### Features.Users.Tests
```
Features.Auth.Tests/
Features.Users.Tests/
├── Commands/
│ ├── RegisterUserHandlerTests.cs
│ ├── ConfirmUserHandlerTests.cs
@@ -279,14 +274,16 @@ Features.Auth.Tests/
│ ├── UpdateProfileHandlerTests.cs
│ └── DeleteAccountHandlerTests.cs
├── Queries/
│ └── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
└── Services/
├── TokenServiceRefreshTests.cs
└── TokenServiceValidationTests.cs
│ ├── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
│ ├── GetAllUsersHandlerTests.cs
│ └── GetUserByIdHandlerTests.cs
├── Services/
│ ├── TokenServiceRefreshTests.cs
│ └── TokenServiceValidationTests.cs
└── TestSupport/ # shared test fixtures/helpers
```
Each of the other slices (`Features.Breweries.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`,
Each of the other slices (`Features.Breweries.Tests`, `Features.Emails.Tests`,
`Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/`
folder with one test file per handler (`Features.PhotoUpload.Tests` also adds
a validator test file alongside its handler test).

View File

@@ -30,9 +30,9 @@ Low-level JWT operations.
- Algorithm: HS256 (HMAC-SHA256)
- Validates token lifetime, signature, and well-formedness
### Features.Auth slice
### Features.Users slice
#### [ITokenService](../../web/backend/Features/Features.Auth/Services/ITokenService.cs)
#### [ITokenService](../../web/backend/Features/Features.Users/Services/ITokenService.cs)
Both token generation and validation live on the same slice-internal service
(there is no separate validation service/class).
@@ -57,31 +57,32 @@ Both token generation and validation live on the same slice-internal service
- `Principal` (ClaimsPrincipal) - Full JWT claims
**Implementation:**
[TokenService.cs](../../web/backend/Features/Features.Auth/Services/TokenService.cs)
[TokenService.cs](../../web/backend/Features/Features.Users/Services/TokenService.cs)
- Reads token secrets from environment variables
- Extracts and validates claims (Sub, UniqueName)
- Throws `UnauthorizedException` on validation failure
`TokenService` is registered by `Features.Auth`'s own `AddFeaturesAuth()`
`TokenService` is registered by `Features.Users`' own `AddFeaturesUsers()`
extension method, except for the lower-level `ITokenInfrastructure` (JWT
signing/verification) it depends on, which is registered by the host
(`API.Core/Program.cs`) since `JwtAuthenticationHandler` (host-level auth
middleware) also depends on it directly.
signing/verification) it
depends on, which is registered by the host (`API.Core/Program.cs`) since
`JwtAuthenticationHandler` (host-level auth middleware) also depends on it
directly.
### Integration points
#### [ConfirmUserHandler](../../web/backend/Features/Features.Auth/Commands/ConfirmUser/ConfirmUserHandler.cs)
#### [ConfirmUserHandler](../../web/backend/Features/Features.Users/Commands/Authentication/ConfirmUser/ConfirmUserHandler.cs)
**Flow:**
1. Receives confirmation token from user via `ConfirmUserCommand`
2. Calls `ITokenService.ValidateConfirmationTokenAsync()`
3. Extracts user ID from validated token
4. Calls `IAuthRepository.ConfirmUserAccountAsync()` to update database
3. Extracts user ID from validated token and looks it up via `UserManager<ApplicationUser>`
4. Marks the account confirmed via `IUserEmailStore<ApplicationUser>.SetEmailConfirmedAsync()`
5. Returns confirmation result
#### [ResendConfirmationEmailHandler](../../web/backend/Features/Features.Auth/Commands/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs)
#### [ResendConfirmationEmailHandler](../../web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs)
**Flow:**
@@ -92,17 +93,16 @@ middleware) also depends on it directly.
`Features.Emails` slice, with no direct project reference between the two
slices
#### [RefreshTokenHandler](../../web/backend/Features/Features.Auth/Commands/RefreshToken/RefreshTokenHandler.cs)
#### [RefreshTokenHandler](../../web/backend/Features/Features.Users/Commands/Authentication/RefreshToken/RefreshTokenHandler.cs)
**Flow:**
1. Receives a refresh token via `RefreshTokenCommand`
2. Delegates to `ITokenService.RefreshTokenAsync()`, which validates the token,
retrieves the user account via `IAuthRepository.GetUserByIdAsync()`, and
issues a new access/refresh token pair
retrieves the user account, and issues a new access/refresh token pair
3. Maps the result onto `LoginPayload`
#### [AuthController](../../web/backend/Features/Features.Auth/Controllers/AuthController.cs)
#### [AuthController](../../web/backend/Features/Features.Users/Controllers/AuthController.cs)
**Endpoints:**
@@ -193,7 +193,7 @@ Validation failures return HTTP 401 Unauthorized:
## Testing
All of the following live in `Features.Auth.Tests`.
All of the following live in `Features.Users.Tests`.
### Unit tests