mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Add user avatar and profile feature, consolidate Auth and UserManagement features (#257)
This commit is contained in:
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user