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

@@ -34,13 +34,12 @@ For visual representations, see:
### Vertical-slice architecture pattern ### Vertical-slice architecture pattern
The backend organizes business capabilities as feature slices instead of The backend organizes business capabilities as feature slices instead of
technical layers. Each feature (`Features.Auth`, `Features.Breweries`, technical layers. Each feature (`Features.Users`, `Features.Breweries`,
`Features.UserManagement`, `Features.Emails`, `Features.Locations`, `Features.Emails`, `Features.Locations`, `Features.PhotoUpload`) is a single
`Features.PhotoUpload`) is a single project that owns its own MediatR project that owns its own MediatR commands/queries/handlers, validators, and
commands/queries/handlers, validators, and repository end to end. repository end to end. `Features.Emails`, `Features.Locations`, and
`Features.Emails`, `Features.Locations`, and `Features.PhotoUpload` have no `Features.PhotoUpload` have no `Controllers/` folder — all three are invoked
`Controllers/` folder — all three are invoked internally by other slices via internally by other slices via MediatR/DI, never over HTTP:
MediatR/DI, never over HTTP:
``` ```
┌───────────────────────────────────────────────────────────────────────┐ ┌───────────────────────────────────────────────────────────────────────┐
@@ -49,15 +48,15 @@ MediatR/DI, never over HTTP:
│ - Swagger/OpenAPI, JWT auth middleware, global exception filter │ │ - Swagger/OpenAPI, JWT auth middleware, global exception filter │
└───────────────────────────────────────────────────────────────────────┘ └───────────────────────────────────────────────────────────────────────┘
↓ discovers controllers via AddApplicationPart ↓ discovers controllers via AddApplicationPart
┌───────────────┬───────────────┬───────────────────┬──────────────────┬──────────────────┬──────────────────┐ ┌────────────────┬───────────────┬──────────────────┬──────────────────┬──────────────────┐
│ Features.Auth │Features. │ Features. │ Features.Emails │ Features. │ Features. │ │ Features.Users │Features. │ Features.Emails │ Features. │ Features. │
│ │Breweries │ UserManagement │ (no controller, │ Locations │ PhotoUpload │ │ │Breweries │ (no controller, │ Locations │ PhotoUpload │
│ Controller │ Controller │ Controller │ internal only) │ (no controller, │ (no controller, │ │ Controllers │ Controller │ internal only) │ (no controller, │ (no controller, │
│ Commands/ │ Commands/ │ Commands/ │ Commands/ │ internal only) │ internal only) │ │ Commands/ │ Commands/ │ Commands/ │ internal only) │ internal only) │
│ Queries + │ Queries + │ Queries + │ Handlers │ Repository │ Commands/ │ │ Queries + │ Queries + │ Handlers │ Repository │ Commands/ │
│ Handlers │ Handlers │ Handlers │ │ │ Handlers │ │ Handlers │ Handlers │ │ │ Handlers │
│ Repository │ Repository │ Repository │ EmailDispatcher │ │ Repository │ │ Repository │ Repository │ EmailDispatcher │ │ Repository │
└───────────────┴───────────────┴───────────────────┴──────────────────┴──────────────────┴──────────────────┘ └────────────────┴───────────────┴──────────────────┴──────────────────┴──────────────────┘
↓ each slice depends only on shared/domain/infra, never on another slice ↓ each slice depends only on shared/domain/infra, never on another slice
┌─────────────────────────┬─────────────────────────┬────────────────────┐ ┌─────────────────────────┬─────────────────────────┬────────────────────┐
│ Shared.Contracts │ Shared.Application │ Domain.Entities / │ │ Shared.Contracts │ Shared.Application │ Domain.Entities / │
@@ -77,7 +76,7 @@ MediatR/DI, never over HTTP:
``` ```
Slices never reference each other's project. The one cross-slice interaction Slices never reference each other's project. The one cross-slice interaction
(`Features.Auth` triggering a confirmation email handled by `Features.Emails`) (`Features.Users` triggering a confirmation email handled by `Features.Emails`)
goes through a MediatR command (`SendRegistrationEmailCommand`) whose contract goes through a MediatR command (`SendRegistrationEmailCommand`) whose contract
lives in `Shared.Application`, so neither slice takes a project reference on the lives in `Shared.Application`, so neither slice takes a project reference on the
other. other.
@@ -110,7 +109,7 @@ own
- No controllers, no business logic, no feature-specific contracts - No controllers, no business logic, no feature-specific contracts
- Exists purely to host and wire up the feature slices - Exists purely to host and wire up the feature slices
#### Feature slices (`Features.Auth`, `Features.Breweries`, `Features.UserManagement`, `Features.Emails`, `Features.Locations`, `Features.PhotoUpload`) #### Feature slices (`Features.Users`, `Features.Breweries`, `Features.Emails`, `Features.Locations`, `Features.PhotoUpload`)
**Purpose**: Each slice is the complete vertical for one business capability **Purpose**: Each slice is the complete vertical for one business capability
@@ -145,7 +144,7 @@ for exercising the upload path end to end.
- `Domain.Entities`, `Domain.Exceptions` - `Domain.Entities`, `Domain.Exceptions`
- `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever infrastructure - `Infrastructure.Sql` (generic ADO.NET plumbing) plus whichever infrastructure
project the slice needs (`Infrastructure.Jwt`/ project the slice needs (`Infrastructure.Jwt`/
`Infrastructure.PasswordHashing` for Auth, `Infrastructure.Email`/ `Infrastructure.PasswordHashing` for Users, `Infrastructure.Email`/
`Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for `Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for
PhotoUpload) PhotoUpload)
- `Shared.Contracts`, `Shared.Application` - `Shared.Contracts`, `Shared.Application`
@@ -277,9 +276,9 @@ public class CreateBreweryHandler(IBreweryRepository repository) : IRequestHandl
**Implementation**: each slice owns its own repository, scoped to that feature **Implementation**: each slice owns its own repository, scoped to that feature
only: only:
- `Features.Auth/Repository/IAuthRepository.cs` - `Features.Users/Repository/IUserListRepository.cs`,
`Features.Users/Repository/IUserProfileRepository.cs`
- `Features.Breweries/Repository/IBreweryRepository.cs` - `Features.Breweries/Repository/IBreweryRepository.cs`
- `Features.UserManagement/Repository/IUserAccountRepository.cs`
- `Features.PhotoUpload/Repository/IPhotoUploadRepository.cs` - `Features.PhotoUpload/Repository/IPhotoUploadRepository.cs`
- `Database.Connection/DefaultSqlConnectionFactory.cs`: the generic connection - `Database.Connection/DefaultSqlConnectionFactory.cs`: the generic connection
factory every slice's repository builds on factory every slice's repository builds on
@@ -297,10 +296,10 @@ only:
**Example**: **Example**:
```csharp ```csharp
public interface IAuthRepository public interface IUserListRepository
{ {
Task<UserCredential> GetUserCredentialAsync(string username); Task<UserAccount?> GetByIdAsync(Guid id);
Task<int> CreateUserAccountAsync(UserAccount user, UserCredential credential); Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
} }
``` ```
@@ -427,7 +426,7 @@ for reference only. Active product and engineering documentation should point to
2. **Email confirmation**: 2. **Email confirmation**:
- User follows the confirmation link/token from the email - User follows the confirmation link/token from the email
- `Features.Auth`'s `ConfirmUser` command marks the account verified - `Features.Users`' `ConfirmUser` command marks the account verified
3. **Login**: 3. **Login**:
- User submits credentials - User submits credentials
@@ -437,7 +436,7 @@ for reference only. Active product and engineering documentation should point to
4. **Token refresh**: 4. **Token refresh**:
- Client exchanges a valid, unexpired refresh token for a new access/refresh - Client exchanges a valid, unexpired refresh token for a new access/refresh
pair via `Features.Auth`'s `RefreshToken` command pair via `Features.Users`' `RefreshToken` command
- Refresh tokens are stateless JWTs, not tracked server-side: the previous - Refresh tokens are stateless JWTs, not tracked server-side: the previous
refresh token is not invalidated and remains usable until its own refresh token is not invalidated and remains usable until its own
expiration expiration
@@ -521,7 +520,8 @@ scripts/
├── 01-UserAccount.sql ├── 01-UserAccount.sql
├── 02-Photo.sql ├── 02-Photo.sql
├── ... ├── ...
└── 16-BeerPostComment.sql # one script per CREATE TABLE ├── 16-UserProfile.sql
└── 17-UserAvatar.sql # one script per CREATE TABLE
``` ```
Each table has its own script, numbered so a table's foreign keys always point Each table has its own script, numbered so a table's foreign keys always point
@@ -541,7 +541,7 @@ pipeline under `tooling/pipeline/` (see [Pipeline README](pipeline/README.md))
- Countries, state/provinces, and cities (via `Features.Locations`' - Countries, state/provinces, and cities (via `Features.Locations`'
`ILocationRepository`, which creates `Country`/`StateProvince` rows on `ILocationRepository`, which creates `Country`/`StateProvince` rows on
demand while resolving a city) demand while resolving a city)
- User accounts (via `Features.Auth`'s repository) - User accounts (via `Features.Users`' repository)
- Brewery posts with locations (via `Features.Breweries`' repository) - Brewery posts with locations (via `Features.Breweries`' repository)
`Photo` rows are written via `Features.PhotoUpload`'s `IPhotoUploadRepository`, `Photo` rows are written via `Features.PhotoUpload`'s `IPhotoUploadRepository`,
@@ -595,10 +595,10 @@ healthcheck:
│ Integration │ ← API.Specs (Reqnroll) │ Integration │ ← API.Specs (Reqnroll)
│ Tests │ │ Tests │
├──────────────┤ ├──────────────┤
│ Unit Tests │ ← Features.Auth.Tests, Features.Breweries.Tests, │ Unit Tests │ ← Features.Users.Tests, Features.Breweries.Tests,
│ (per slice, │ Features.UserManagement.Tests, Features.Emails.Tests, │ (per slice, │ Features.Emails.Tests, Features.PhotoUpload.Tests
│ handler │ Features.PhotoUpload.Tests (commands/queries/handlers, │ handler │ (commands/queries/handlers, with repository/service
│ tests) │ with repository/service dependencies mocked via Moq) │ tests) │ dependencies mocked via Moq)
└──────────────┘ └──────────────┘
``` ```

View File

@@ -25,9 +25,11 @@ updates (`UPDATE ... WHERE ... AND RowVersion = @RowVersion`).
| Table | Backed by | Notes | | Table | Backed by | Notes |
| --------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------- | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UserAccount` | `Domain.Entities.UserAccount`, `Features.Auth`/`Features.UserManagement` | `Username` and `Email` are unique | | `UserAccount` | `Domain.Entities.UserAccount`, `Features.Users` | `Username` and `Email` are unique |
| `UserCredential` | `Domain.Entities.UserCredential`, `Features.Auth` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs | | `UserCredential` | `Domain.Entities.UserCredential`, `Features.Users` | Argon2id password hash; `IsRevoked`/`RevokedAt` track credential rotation, not JWTs |
| `UserVerification` | `Domain.Entities.UserVerification`, `Features.Auth` | Written when a user confirms their email | | `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 | | `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 | | `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` | | `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) | | Table | Purpose (from schema) |
| ------------------ | -------------------------------------------------------------------------------------------------------- | | ------------------ | -------------------------------------------------------------------------------------------------------- |
| `UserAvatar` | Links a `UserAccount` to a `Photo` as its avatar |
| `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) | | `UserFollow` | One user following another (`CannotFollowOwnAccount` check constraint) |
| `BeerStyle` | A beer style/category; has a `Domain.Entities.BeerStyle` type, but no repository yet | | `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 | | `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 409, 400, ...); an unmapped `SqlException` (e.g. a genuine connectivity or
constraint-violation failure) maps to 503. constraint-violation failure) maps to 503.
- One repository-level exception: `Features.Auth`'s `AuthRepository` does - One exception: `Features.Users`' `DapperUserStore` does catch
catch `SqlException` directly, to detect a duplicate-key race (SQL Server `SqlException` directly, to detect a duplicate-key race (SQL Server error
error 2601 or 2627) when two requests try to verify the same account 2601 or 2627) when two requests try to verify the same account
concurrently — see `Auth Repository.IsDuplicateVerificationViolation`. 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 { package "Feature Slices" #F3E5F5 {
[Features.Auth] as AuthSlice [Features.Users] as UsersSlice
[Features.Breweries] as BrewerySlice [Features.Breweries] as BrewerySlice
[Features.UserManagement] as UserSlice
[Features.Emails] as EmailsSlice [Features.Emails] as EmailsSlice
[Features.Locations] as LocationsSlice [Features.Locations] as LocationsSlice
note right of AuthSlice note right of UsersSlice
Each slice owns its own: Each slice owns its own:
- Controller (HTTP endpoints) - Controller (HTTP endpoints)
- Commands/Queries + Handlers - Commands/Queries + Handlers
- Validators - Validators
- Repository (Dapper) - Repository (Dapper)
Features.Users covers auth
(Commands/Authentication),
account management
(Commands/Account), and
profile/avatar (Commands/Profile)
end note end note
note right of EmailsSlice note right of EmailsSlice
No controller - invoked only No controller - invoked only
@@ -70,6 +75,7 @@ package "Domain Layer" #E8F5E9 {
[Domain.Exceptions] as DomainExceptions [Domain.Exceptions] as DomainExceptions
note right of Domain note right of Domain
- UserAccount, UserCredential, UserVerification - UserAccount, UserCredential, UserVerification
- UserProfile, UserAvatar
- BreweryPost, BreweryPostLocation - BreweryPost, BreweryPostLocation
- City, StateProvince, Country - City, StateProvince, Country
- BeerPost, BeerStyle - BeerPost, BeerStyle
@@ -84,29 +90,24 @@ database "SQL Server" {
node "Database.Seed\n(separate console app)" as Seed node "Database.Seed\n(separate console app)" as Seed
' Relationships ' Relationships
API ..> AuthSlice : AddApplicationPart\n+ MediatR scan API ..> UsersSlice : AddApplicationPart\n+ MediatR scan
API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan API ..> BrewerySlice : AddApplicationPart\n+ MediatR scan
API ..> UserSlice : AddApplicationPart\n+ MediatR scan
API ..> EmailsSlice : MediatR scan only\n(no controller) 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 ..> BrewerySlice : AddFeaturesBreweries()
Seed ..> LocationsSlice : AddFeaturesLocations() Seed ..> LocationsSlice : AddFeaturesLocations()
AuthSlice --> Sql UsersSlice --> Sql
AuthSlice --> JWT UsersSlice --> JWT
AuthSlice --> PwdHash UsersSlice --> PwdHash
AuthSlice --> SharedContracts UsersSlice --> SharedContracts
AuthSlice --> SharedApp UsersSlice --> SharedApp
BrewerySlice --> Sql BrewerySlice --> Sql
BrewerySlice --> SharedContracts BrewerySlice --> SharedContracts
BrewerySlice --> SharedApp BrewerySlice --> SharedApp
UserSlice --> Sql
UserSlice --> SharedContracts
UserSlice --> SharedApp
LocationsSlice --> Sql LocationsSlice --> Sql
EmailsSlice --> Email EmailsSlice --> Email
@@ -114,21 +115,19 @@ EmailsSlice --> EmailTemplates
EmailsSlice --> SharedApp EmailsSlice --> SharedApp
' The one cross-slice interaction: a MediatR command contract, not a project reference ' 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 EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
Sql --> Domain Sql --> Domain
AuthSlice --> Tables : Dapper SQL UsersSlice --> Tables : Dapper SQL
BrewerySlice --> Tables : Dapper SQL BrewerySlice --> Tables : Dapper SQL
UserSlice --> Tables : Dapper SQL
LocationsSlice --> Tables : Dapper SQL LocationsSlice --> Tables : Dapper SQL
AuthSlice --> Domain UsersSlice --> Domain
BrewerySlice --> Domain BrewerySlice --> Domain
UserSlice --> Domain
LocationsSlice --> Domain LocationsSlice --> Domain
AuthSlice --> DomainExceptions UsersSlice --> DomainExceptions
' Notes ' Notes
note left of Sql note left of Sql
@@ -138,7 +137,7 @@ note left of Sql
no stored procedures no stored procedures
end note end note
note bottom of AuthSlice note bottom of UsersSlice
No Features.* project No Features.* project
ever references another ever references another
Features.* project directly Features.* project directly

View File

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

View File

@@ -130,9 +130,8 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
restores/builds against Core.slnx restores/builds against Core.slnx
then runs `dotnet test` for every then runs `dotnet test` for every
Features/*.Tests project in turn: Features/*.Tests project in turn:
- Features.Auth.Tests - Features.Users.Tests
- Features.Breweries.Tests - Features.Breweries.Tests
- Features.UserManagement.Tests
- Features.Emails.Tests - Features.Emails.Tests
Uses: Moq to mock each handler's 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 { folder "test-results/\n(mounted volume)" as Results {
file "api-specs/\n results.trx" as Result1 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.Breweries.Tests.trx" as Result3
file "Features.UserManagement.Tests.trx" as Result4
file "Features.Emails.Tests.trx" as Result5 file "Features.Emails.Tests.trx" as Result5
note bottom 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 # View results
ls -la test-results/ ls -la test-results/
cat test-results/api-specs/results.trx cat test-results/api-specs/results.trx
cat test-results/Features.Auth.Tests.trx cat test-results/Features.Users.Tests.trx
# Clean up # Clean up
docker compose --env-file web/.env.test -f web/docker-compose.test.yaml down -v 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 - **API.Specs** - BDD integration tests using Reqnroll (Gherkin), run against a
live, seeded database live, seeded database
- **Features.\*.Tests** - One unit test project per backend feature slice - **Features.\*.Tests** - One unit test project per backend feature slice
(`Features.Auth.Tests`, `Features.Breweries.Tests`, (`Features.Users.Tests`, `Features.Breweries.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), covering that
`Features.PhotoUpload.Tests`), covering that slice's command/query slice's command/query handlers, with dependencies mocked via Moq (no real
handlers, with dependencies mocked via Moq (no real database required) database required)
- **Storybook Vitest project** - Browser-based interaction tests for shared - **Storybook Vitest project** - Browser-based interaction tests for shared
website stories website stories
- **Storybook Playwright suite** - Browser checks against Storybook-rendered - **Storybook Playwright suite** - Browser checks against Storybook-rendered
@@ -45,9 +45,8 @@ ls -la test-results/
# View specific test results # View specific test results
cat test-results/api-specs/results.trx 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.Breweries.Tests.trx
cat test-results/Features.UserManagement.Tests.trx
cat test-results/Features.Emails.Tests.trx cat test-results/Features.Emails.Tests.trx
cat test-results/Features.PhotoUpload.Tests.trx cat test-results/Features.PhotoUpload.Tests.trx
``` ```
@@ -83,9 +82,8 @@ handlers:
```bash ```bash
cd web/backend 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.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.Emails.Tests/Features.Emails.Tests.csproj
dotnet test Features/Features.PhotoUpload.Tests/Features.PhotoUpload.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 ### Current coverage
**Features.Auth.Tests**: **Features.Users.Tests**:
- User registration with validation - User registration with validation
- User login with JWT token generation - 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 management: username, email, password, and profile updates, and
account deletion (blocked while dependent posts, comments, photos, or follows account deletion (blocked while dependent posts, comments, photos, or follows
exist) exist)
- User get-by-id/get-all queries
**Features.Breweries.Tests**: **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 - Brewery location queries: all locations, and locations within a given range
of a coordinate of a coordinate
**Features.UserManagement.Tests**:
- User get-by-id/get-all queries and the (currently unrouted) update command
**Features.Emails.Tests**: **Features.Emails.Tests**:
- Registration and resend-confirmation email dispatch handlers - Registration and resend-confirmation email dispatch handlers
@@ -264,10 +259,10 @@ Scenario: Successful user registration
And my account should be created And my account should be created
``` ```
### Features.Auth.Tests ### Features.Users.Tests
``` ```
Features.Auth.Tests/ Features.Users.Tests/
├── Commands/ ├── Commands/
│ ├── RegisterUserHandlerTests.cs │ ├── RegisterUserHandlerTests.cs
│ ├── ConfirmUserHandlerTests.cs │ ├── ConfirmUserHandlerTests.cs
@@ -279,14 +274,16 @@ Features.Auth.Tests/
│ ├── UpdateProfileHandlerTests.cs │ ├── UpdateProfileHandlerTests.cs
│ └── DeleteAccountHandlerTests.cs │ └── DeleteAccountHandlerTests.cs
├── Queries/ ├── Queries/
│ └── LoginHandlerTests.cs # tests LoginCommand, despite the folder name │ ├── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
└── Services/ │ ├── GetAllUsersHandlerTests.cs
├── TokenServiceRefreshTests.cs │ └── GetUserByIdHandlerTests.cs
└── TokenServiceValidationTests.cs ├── Services/
│ ├── TokenServiceRefreshTests.cs
│ └── TokenServiceValidationTests.cs
└── TestSupport/ # shared test fixtures/helpers
``` ```
Each of the other slices (`Features.Breweries.Tests`, Each of the other slices (`Features.Breweries.Tests`, `Features.Emails.Tests`,
`Features.UserManagement.Tests`, `Features.Emails.Tests`,
`Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/` `Features.PhotoUpload.Tests`) follows the same shape: a `Commands/`/`Queries/`
folder with one test file per handler (`Features.PhotoUpload.Tests` also adds folder with one test file per handler (`Features.PhotoUpload.Tests` also adds
a validator test file alongside its handler test). a validator test file alongside its handler test).

View File

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

View File

@@ -22,8 +22,7 @@
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Jwt\Infrastructure.Jwt.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" /> <ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.UserManagement\Features.UserManagement.csproj" /> <ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" />
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" />
<ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" /> <ProjectReference Include="..\..\Features\Features.Emails\Features.Emails.csproj" />
<ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" /> <ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" /> <ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />

View File

@@ -22,8 +22,6 @@ COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Datab
COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"] COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"] COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"] COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"]
COPY ["Features/Features.Auth/Features.Auth.csproj", "Features/Features.Auth/"]
COPY ["Features/Features.Auth.Tests/Features.Auth.Tests.csproj", "Features/Features.Auth.Tests/"]
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"] COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"] COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"] COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
@@ -32,8 +30,8 @@ COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Feature
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"] COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Features.PhotoUpload/"] COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Features.PhotoUpload/"]
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"] COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"] COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"]
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"] COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"]
COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"] COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"] COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]

View File

@@ -2,15 +2,13 @@ using API.Core;
using API.Core.Authentication; using API.Core.Authentication;
using Database.Connection.DependencyInjection; using Database.Connection.DependencyInjection;
using Features.Auth.Controllers; using Features.Auth.Controllers;
using Features.Auth.DependencyInjection; using Features.Users.DependencyInjection;
using Features.Breweries.Controllers; using Features.Breweries.Controllers;
using Features.Breweries.DependencyInjection; using Features.Breweries.DependencyInjection;
using Features.Emails.DependencyInjection; using Features.Emails.DependencyInjection;
using Features.Emails.Services; using Features.Emails.Services;
using Features.ImageUploads.Commands.UploadPhoto; using Features.ImageUploads.Commands.UploadPhoto;
using Features.ImageUploads.DependencyInjection; using Features.ImageUploads.DependencyInjection;
using Features.UserManagement.Controllers;
using Features.UserManagement.DependencyInjection;
using FluentValidation; using FluentValidation;
using FluentValidation.AspNetCore; using FluentValidation.AspNetCore;
using Infrastructure.FileUpload; using Infrastructure.FileUpload;
@@ -25,12 +23,8 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
// Add current controllers to the exception filter // Add current controllers to the exception filter
builder builder
.Services.AddControllers(options => .Services.AddControllers(options => { options.Filters.Add<GlobalExceptionFilter>(); })
{
options.Filters.Add<GlobalExceptionFilter>();
})
.AddApplicationPart(typeof(BreweryController).Assembly) .AddApplicationPart(typeof(BreweryController).Assembly)
.AddApplicationPart(typeof(UserController).Assembly)
.AddApplicationPart(typeof(AuthController).Assembly); .AddApplicationPart(typeof(AuthController).Assembly);
builder.Services.AddEndpointsApiExplorer(); builder.Services.AddEndpointsApiExplorer();
@@ -69,7 +63,6 @@ builder.Services.AddOpenApi();
builder.Services.AddValidatorsFromAssemblyContaining<Program>(); builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly); builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly); builder.Services.AddValidatorsFromAssembly(typeof(AuthController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly); builder.Services.AddValidatorsFromAssembly(typeof(UploadPhotoCommand).Assembly);
builder.Services.AddFluentValidationAutoValidation(); builder.Services.AddFluentValidationAutoValidation();
@@ -80,7 +73,6 @@ builder.Services.AddMediatR(cfg =>
{ {
cfg.RegisterServicesFromAssemblyContaining<Program>(); cfg.RegisterServicesFromAssemblyContaining<Program>();
cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly); cfg.RegisterServicesFromAssembly(typeof(BreweryController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(UserController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly); cfg.RegisterServicesFromAssembly(typeof(AuthController).Assembly);
cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly); cfg.RegisterServicesFromAssembly(typeof(UploadPhotoCommand).Assembly);
cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly); cfg.RegisterServicesFromAssembly(typeof(IEmailDispatcher).Assembly);
@@ -98,8 +90,7 @@ if (!builder.Environment.IsProduction())
builder.Services.AddDatabaseConnection(); builder.Services.AddDatabaseConnection();
builder.Services.AddFeaturesBreweries(); builder.Services.AddFeaturesBreweries();
builder.Services.AddFeaturesUserManagement(); builder.Services.AddFeaturesUsers();
builder.Services.AddFeaturesAuth();
builder.Services.AddFeaturesEmails(); builder.Services.AddFeaturesEmails();
builder.Services.AddFeaturesPhotoUpload(); builder.Services.AddFeaturesPhotoUpload();
@@ -130,20 +121,6 @@ app.MapHealthChecks("/health");
app.MapControllers(); app.MapControllers();
app.MapPost(
"/upload-test",
async (IFormFile file, ClaimsPrincipal user, IMediator mediator) =>
{
Guid uploadedById = Guid.Parse(user.FindFirst(JwtRegisteredClaimNames.Sub)!.Value);
Guid photoId = await mediator.Send(
new UploadPhotoCommand(uploadedById, "upload-test", file)
);
return Results.Ok(photoId);
}
)
.RequireAuthorization()
.DisableAntiforgery();
app.MapFallbackToController("Handle404", "NotFound"); app.MapFallbackToController("Handle404", "NotFound");
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>(); IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();

View File

@@ -12,8 +12,6 @@ COPY ["Database/Database.Migrations/Database.Migrations.csproj", "Database/Datab
COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"] COPY ["Database/Database.Seed/Database.Seed.csproj", "Database/Database.Seed/"]
COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"] COPY ["Domain/Domain.Entities/Domain.Entities.csproj", "Domain/Domain.Entities/"]
COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"] COPY ["Domain/Domain.Exceptions/Domain.Exceptions.csproj", "Domain/Domain.Exceptions/"]
COPY ["Features/Features.Auth/Features.Auth.csproj", "Features/Features.Auth/"]
COPY ["Features/Features.Auth.Tests/Features.Auth.Tests.csproj", "Features/Features.Auth.Tests/"]
COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"] COPY ["Features/Features.Breweries/Features.Breweries.csproj", "Features/Features.Breweries/"]
COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"] COPY ["Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj", "Features/Features.Breweries.Tests/"]
COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"] COPY ["Features/Features.Emails/Features.Emails.csproj", "Features/Features.Emails/"]
@@ -22,8 +20,8 @@ COPY ["Features/Features.Locations/Features.Locations.csproj", "Features/Feature
COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"] COPY ["Features/Features.Locations.Tests/Features.Locations.Tests.csproj", "Features/Features.Locations.Tests/"]
COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Features.PhotoUpload/"] COPY ["Features/Features.PhotoUpload/Features.PhotoUpload.csproj", "Features/Features.PhotoUpload/"]
COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"] COPY ["Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj", "Features/Features.PhotoUpload.Tests/"]
COPY ["Features/Features.UserManagement/Features.UserManagement.csproj", "Features/Features.UserManagement/"] COPY ["Features/Features.Users/Features.Users.csproj", "Features/Features.Users/"]
COPY ["Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj", "Features/Features.UserManagement.Tests/"] COPY ["Features/Features.Users.Tests/Features.Users.Tests.csproj", "Features/Features.Users.Tests/"]
COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"] COPY ["Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj", "Infrastructure/Infrastructure.Configuration/"]
COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"] COPY ["Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj", "Infrastructure/Infrastructure.Email/"]
COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"] COPY ["Infrastructure/Infrastructure.Email.Templates/Infrastructure.Email.Templates.csproj", "Infrastructure/Infrastructure.Email.Templates/"]

View File

@@ -12,6 +12,10 @@
<Project Path="Domain/Domain.Entities/Domain.Entities.csproj" /> <Project Path="Domain/Domain.Entities/Domain.Entities.csproj" />
<Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" /> <Project Path="Domain/Domain.Exceptions/Domain.Exceptions.csproj" />
</Folder> </Folder>
<Folder Name="/Features/Users/">
<Project Path="Features\Features.Users.Tests\Features.Users.Tests.csproj" />
<Project Path="Features\Features.Users\Features.Users.csproj" />
</Folder>
<Folder Name="/Infrastructure/"> <Folder Name="/Infrastructure/">
<Project Path="Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj" /> <Project Path="Infrastructure/Infrastructure.Configuration/Infrastructure.Configuration.csproj" />
<Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" /> <Project Path="Infrastructure/Infrastructure.Email/Infrastructure.Email.csproj" />
@@ -20,18 +24,21 @@
<Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" /> <Project Path="Infrastructure/Infrastructure.Jwt/Infrastructure.Jwt.csproj" />
<Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" /> <Project Path="Infrastructure/Infrastructure.PasswordHashing/Infrastructure.PasswordHashing.csproj" />
</Folder> </Folder>
<Folder Name="/Features/"> <Folder Name="/Features/" />
<Folder Name="/Features/Breweries/">
<Project Path="Features/Features.Breweries/Features.Breweries.csproj" /> <Project Path="Features/Features.Breweries/Features.Breweries.csproj" />
<Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj" /> <Project Path="Features/Features.Breweries.Tests/Features.Breweries.Tests.csproj" />
<Project Path="Features/Features.UserManagement/Features.UserManagement.csproj" /> </Folder>
<Project Path="Features/Features.UserManagement.Tests/Features.UserManagement.Tests.csproj" /> <Folder Name="/Features/Emails/">
<Project Path="Features/Features.Auth/Features.Auth.csproj" />
<Project Path="Features/Features.Auth.Tests/Features.Auth.Tests.csproj" />
<Project Path="Features/Features.Emails/Features.Emails.csproj" /> <Project Path="Features/Features.Emails/Features.Emails.csproj" />
<Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" /> <Project Path="Features/Features.Emails.Tests/Features.Emails.Tests.csproj" />
</Folder>
<Folder Name="/Features/Locations/">
<Project Path="Features/Features.Locations/Features.Locations.csproj" /> <Project Path="Features/Features.Locations/Features.Locations.csproj" />
<Project Path="Features/Features.Locations.Tests/Features.Locations.Tests.csproj" /> <Project Path="Features/Features.Locations.Tests/Features.Locations.Tests.csproj" />
<Project Path="Features\Features.PhotoUpload\Features.PhotoUpload.csproj" /> </Folder>
<Folder Name="/Features/PhotoUpload/">
<Project Path="Features/Features.PhotoUpload/Features.PhotoUpload.csproj" />
<Project Path="Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj" /> <Project Path="Features/Features.PhotoUpload.Tests/Features.PhotoUpload.Tests.csproj" />
</Folder> </Folder>
<Folder Name="/Shared/"> <Folder Name="/Shared/">

View File

@@ -31,8 +31,24 @@ Table Photo {
Table UserAvatar { Table UserAvatar {
UserAvatarID uniqueidentifier [pk, default: `newid()`] UserAvatarID uniqueidentifier [pk, default: `newid()`]
UserAccountID uniqueidentifier [not null, unique] UserProfileID uniqueidentifier [not null]
PhotoID uniqueidentifier [not null] PhotoID uniqueidentifier [not null]
ValidFrom "datetime2(3)" [not null, default: `sysutcdatetime()`]
ValidTo "datetime2(3)" [note: 'NULL = currently active']
RowVersion rowversion
Note: 'Deleted when the user profile is deleted. At most one row per profile has ValidTo = NULL.'
Indexes {
UserProfileID [unique, name: 'UX_UserAvatar_ActiveOne', note: 'filtered: WHERE ValidTo IS NULL']
PhotoID [name: 'IX_UserAvatar_Photo']
}
}
Table UserProfile {
UserProfileID uniqueidentifier [pk, default: `newid()`]
Biography "nvarchar(max)" [not null]
UserAccountID uniqueidentifier [not null, unique]
RowVersion rowversion RowVersion rowversion
Note: 'Deleted when the user account is deleted' Note: 'Deleted when the user account is deleted'
@@ -216,8 +232,9 @@ Table BeerPostComment {
} }
Ref: Photo.UploadedByID > UserAccount.UserAccountID [delete: no action] Ref: Photo.UploadedByID > UserAccount.UserAccountID [delete: no action]
Ref: UserAvatar.UserAccountID - UserAccount.UserAccountID [delete: cascade] Ref: UserAvatar.PhotoID > Photo.PhotoID [delete: no action]
Ref: UserAvatar.PhotoID > Photo.PhotoID Ref: UserAvatar.UserProfileID > UserProfile.UserProfileID [delete: cascade]
Ref: UserProfile.UserAccountID - UserAccount.UserAccountID [delete: cascade]
Ref: UserVerification.UserAccountID - UserAccount.UserAccountID [delete: cascade] Ref: UserVerification.UserAccountID - UserAccount.UserAccountID [delete: cascade]
Ref: UserCredential.UserAccountID > UserAccount.UserAccountID [delete: cascade] Ref: UserCredential.UserAccountID > UserAccount.UserAccountID [delete: cascade]
Ref: UserFollow.UserAccountID > UserAccount.UserAccountID [delete: no action] Ref: UserFollow.UserAccountID > UserAccount.UserAccountID [delete: no action]

View File

@@ -29,3 +29,5 @@ CREATE TABLE dbo.UserAccount
CONSTRAINT AK_Email CONSTRAINT AK_Email
UNIQUE (Email) UNIQUE (Email)
); );

View File

@@ -1,29 +0,0 @@
CREATE TABLE UserAvatar -- delete avatar photo when user account is deleted
(
UserAvatarID UNIQUEIDENTIFIER
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
UserAccountID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL,
RowVersion ROWVERSION,
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
CONSTRAINT FK_UserAvatar_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE,
CONSTRAINT FK_UserAvatar_PhotoID
FOREIGN KEY (PhotoID)
REFERENCES Photo (PhotoID),
CONSTRAINT AK_UserAvatar_UserAccountID
UNIQUE (UserAccountID)
);
CREATE
NONCLUSTERED INDEX IX_UserAvatar_UserAccount
ON UserAvatar(UserAccountID);

View File

@@ -0,0 +1,22 @@
CREATE TABLE dbo.UserProfile
(
UserProfileID UNIQUEIDENTIFIER
CONSTRAINT DF_UserProfileID DEFAULT NEWID(),
Biography NVARCHAR(MAX) NOT NULL,
UserAccountID UNIQUEIDENTIFIER NOT NULL,
RowVersion ROWVERSION,
CONSTRAINT PK_UserProfile
PRIMARY KEY (UserProfileID),
CONSTRAINT AK_UserProfile_UserAccountID
UNIQUE (UserAccountID),
CONSTRAINT FK_UserProfile_UserAccount
FOREIGN KEY (UserAccountID)
REFERENCES UserAccount (UserAccountID)
ON DELETE CASCADE
);

View File

@@ -0,0 +1,43 @@
CREATE TABLE dbo.UserAvatar
(
UserAvatarID UNIQUEIDENTIFIER
CONSTRAINT DF_UserAvatarID DEFAULT NEWID(),
UserProfileID UNIQUEIDENTIFIER NOT NULL,
PhotoID UNIQUEIDENTIFIER NOT NULL,
ValidFrom DATETIME2(3) NOT NULL
CONSTRAINT DF_UserAvatar_ValidFrom DEFAULT SYSUTCDATETIME(),
ValidTo DATETIME2(3) NULL, -- NULL = currently active
RowVersion ROWVERSION,
CONSTRAINT PK_UserAvatar PRIMARY KEY (UserAvatarID),
CONSTRAINT FK_UserAvatar_UserProfile
FOREIGN KEY (UserProfileID)
REFERENCES dbo.UserProfile (UserProfileID)
ON DELETE CASCADE,
CONSTRAINT FK_UserAvatar_Photo
FOREIGN KEY (PhotoID)
REFERENCES dbo.Photo (PhotoID)
ON DELETE NO ACTION
ON UPDATE NO ACTION,
CONSTRAINT CK_UserAvatar_ValidRange
CHECK (ValidTo IS NULL OR ValidTo > ValidFrom)
);
-- At most one active avatar per profile
CREATE UNIQUE NONCLUSTERED INDEX UX_UserAvatar_ActiveOne
ON dbo.UserAvatar (UserProfileID)
WHERE ValidTo IS NULL;
CREATE NONCLUSTERED INDEX IX_UserAvatar_Photo
ON dbo.UserAvatar (PhotoID);
CREATE NONCLUSTERED INDEX ix_UserAvatar_Profile
ON dbo.UserAvatar (UserProfileID)

View File

@@ -9,8 +9,11 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="idunno.Password.Generator" Version="2.0.0" /> <PackageReference Include="idunno.Password.Generator" Version="2.0.0" />
<PackageReference Include="MediatR" Version="12.4.1" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" /> <PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" /> <PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.11" />
<PackageReference Include="SkiaSharp" Version="4.151.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="4.151.1" />
<PackageReference Include="Spectre.Console" Version="0.57.2" /> <PackageReference Include="Spectre.Console" Version="0.57.2" />
</ItemGroup> </ItemGroup>
@@ -18,13 +21,12 @@
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" /> <ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" /> <ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" /> <ProjectReference Include="..\..\Features\Features.Breweries\Features.Breweries.csproj" />
<ProjectReference Include="..\..\Features\Features.Auth\Features.Auth.csproj" /> <ProjectReference Include="..\..\Features\Features.Users\Features.Users.csproj" />
<ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" /> <ProjectReference Include="..\..\Features\Features.Locations\Features.Locations.csproj" />
<ProjectReference Include="..\..\Features\Features.PhotoUpload\Features.PhotoUpload.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.FileUpload\Infrastructure.FileUpload.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<None <None CopyToOutputDirectory="PreserveNewest" Include="SeedData\biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite" />
CopyToOutputDirectory="PreserveNewest"
Include="SeedData\biergarten_seed_2026-08-25T20-45-50.697244Z.sqlite"
/>
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,27 @@
using SkiaSharp;
namespace Database.Seed.DatabaseHelpers;
/// <summary>Generates minimal solid-color square avatars for seeded users.</summary>
public static class AvatarGenerator
{
private const int SizePx = 128;
/// <summary>
/// Generates a deterministic solid-color PNG avatar, with the color derived from
/// <paramref name="userId" />.
/// </summary>
public static byte[] GeneratePng(Guid userId)
{
byte[] idBytes = userId.ToByteArray();
SKColor color = new(idBytes[0], idBytes[1], idBytes[2]);
using SKBitmap bitmap = new(SizePx, SizePx);
bitmap.Erase(color);
using SKImage image = SKImage.FromBitmap(bitmap);
using SKData data = image.Encode(SKEncodedImageFormat.Png, quality: 100);
return data.ToArray();
}
}

View File

@@ -0,0 +1,21 @@
using Features.Auth.Services;
namespace Database.Seed.DatabaseHelpers;
public class NoOpTokenService : ITokenService
{
public string GenerateAccessToken(Guid userId, string username) => string.Empty;
public string GenerateRefreshToken(Guid userId, string username) => string.Empty;
public string GenerateConfirmationToken(Guid userId, string username) => string.Empty;
public Task<ValidatedToken> ValidateRefreshTokenAsync(string token) =>
throw new NotSupportedException("Token validation is not supported during seeding.");
public Task<ValidatedToken> ValidateConfirmationTokenAsync(string token) =>
throw new NotSupportedException("Token validation is not supported during seeding.");
public Task<RefreshTokenResult> RefreshTokenAsync(string refreshTokenString) =>
throw new NotSupportedException("Token validation is not supported during seeding.");
}

View File

@@ -11,6 +11,8 @@ ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./Database/Database.Seed/Database.Seed.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false RUN dotnet publish "./Database/Database.Seed/Database.Seed.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
RUN apt-get update && apt-get install -y --no-install-recommends libfontconfig1 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY --from=publish /app/publish . COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "Database.Seed.dll"] ENTRYPOINT ["dotnet", "Database.Seed.dll"]

View File

@@ -3,15 +3,23 @@ using Database.Seed.DatabaseHelpers;
using Database.Seed.PipelineData; using Database.Seed.PipelineData;
using Database.Seed.Sqlite; using Database.Seed.Sqlite;
using Domain.Entities; using Domain.Entities;
using Features.Auth.DependencyInjection; using Features.Auth.Commands.Authentication.RegisterUser;
using Features.Auth.Identity; using Features.Auth.Commands.Profile.UpdateBiography;
using Features.Auth.Commands.Profile.UploadAvatar;
using Features.Users.DependencyInjection;
using Features.Auth.Dtos;
using Features.Auth.Services;
using Features.Breweries.Commands.CreateBrewery;
using Features.Breweries.DependencyInjection; using Features.Breweries.DependencyInjection;
using Features.Breweries.Repository; using Features.ImageUploads.Commands.UploadPhoto;
using Features.ImageUploads.DependencyInjection;
using Features.Locations.Commands.GetOrCreateCity;
using Features.Locations.DependencyInjection; using Features.Locations.DependencyInjection;
using Features.Locations.Dtos; using Features.Locations.Dtos;
using Features.Locations.Repository;
using idunno.Password; using idunno.Password;
using Microsoft.AspNetCore.Identity; using Infrastructure.FileUpload;
using MediatR;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Spectre.Console; using Spectre.Console;
@@ -24,20 +32,24 @@ static async Task<int> RunAsync()
{ {
IConfiguration configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build(); IConfiguration configuration = new ConfigurationBuilder().AddEnvironmentVariables().Build();
ServiceCollection services = []; IServiceCollection services = new ServiceCollection()
services.AddSingleton(configuration); .AddSingleton(configuration)
services.AddDatabaseConnection(); .AddDatabaseConnection()
services.AddFeaturesBreweries(); .AddFeaturesBreweries()
services.AddFeaturesLocations(); .AddFeaturesLocations()
services.AddFeaturesAuth(); .AddFeaturesUsers()
.AddFeaturesPhotoUpload()
.AddSingleton<IFileStorageProvider, S3FileStorageProvider>()
.AddScoped<ITokenService, NoOpTokenService>()
.AddMediatR(cfg =>
cfg.RegisterServicesFromAssemblyContaining<CreateBreweryCommand>()
.RegisterServicesFromAssemblyContaining<GetOrCreateCityCommand>()
.RegisterServicesFromAssemblyContaining<UploadAvatarCommand>()
.RegisterServicesFromAssemblyContaining<UploadPhotoCommand>()
);
await using ServiceProvider provider = services.BuildServiceProvider(); await using ServiceProvider provider = services.BuildServiceProvider();
IMediator mediator = provider.GetRequiredService<IMediator>();
IBreweryRepository breweryRepository = provider.GetRequiredService<IBreweryRepository>();
ILocationRepository locationRepository = provider.GetRequiredService<ILocationRepository>();
UserManager<ApplicationUser> userManager = provider.GetRequiredService<
UserManager<ApplicationUser>
>();
AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]").LeftJustified()); AnsiConsole.Write(new Rule("[bold green]Database Seeder[/]").LeftJustified());
AnsiConsole.MarkupLine("[grey]Connecting to SQLite source and loading seed data...[/]"); AnsiConsole.MarkupLine("[grey]Connecting to SQLite source and loading seed data...[/]");
@@ -46,7 +58,7 @@ static async Task<int> RunAsync()
PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString); PipelineSeedDataReader reader = new(ConnectionStrings.SqliteConnectionString);
SeedData seedData = null!; SeedData seedData = null!;
IReadOnlyList<Guid> postedByIds = []; IReadOnlyList<Guid> userIds = [];
await AnsiConsole await AnsiConsole
.Status() .Status()
@@ -87,11 +99,24 @@ static async Task<int> RunAsync()
"Loading user data into target database...", "Loading user data into target database...",
async ctx => async ctx =>
{ {
postedByIds = await LoadUsersIntoDatabaseAsync(userManager, seedData.Users); userIds = await LoadUsersIntoDatabaseAsync(mediator, seedData.Users);
ctx.Status("User data loaded into target database."); ctx.Status("User data loaded into target database.");
} }
); );
await AnsiConsole
.Status()
.Spinner(Spinner.Known.Dots)
.SpinnerStyle(Style.Parse("green"))
.StartAsync(
"Loading avatars into target database...",
async ctx =>
{
await LoadAvatarsIntoDatabaseAsync(mediator, userIds, seedData.Users, ctx);
ctx.Status("Avatar data loaded into target database.");
}
);
await AnsiConsole await AnsiConsole
.Status() .Status()
.Spinner(Spinner.Known.Dots) .Spinner(Spinner.Known.Dots)
@@ -101,10 +126,9 @@ static async Task<int> RunAsync()
async ctx => async ctx =>
{ {
await LoadBreweriesIntoDatabaseAsync( await LoadBreweriesIntoDatabaseAsync(
breweryRepository, mediator,
locationRepository,
seedData.Breweries, seedData.Breweries,
postedByIds, userIds,
ctx ctx
); );
ctx.Status("Brewery data loaded into target database."); ctx.Status("Brewery data loaded into target database.");
@@ -122,7 +146,7 @@ static async Task<int> RunAsync()
} }
static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync( static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
UserManager<ApplicationUser> userManager, IMediator mediator,
IReadOnlyList<UserRecord> users IReadOnlyList<UserRecord> users
) )
{ {
@@ -130,37 +154,57 @@ static async Task<IReadOnlyList<Guid>> LoadUsersIntoDatabaseAsync(
foreach (UserRecord userRecord in users) foreach (UserRecord userRecord in users)
{ {
ApplicationUser user = new()
{
FirstName = userRecord.User.FirstName,
LastName = userRecord.User.LastName,
DateOfBirth = DateTime.Parse(userRecord.DateOfBirth),
UserName = userRecord.User.Username,
Email = userRecord.Email,
};
// allowRepeatedCharacters: true -- 12 unique digits was requested from only 10 possible // allowRepeatedCharacters: true -- 12 unique digits was requested from only 10 possible
// digit characters (0-9), which idunno.Password rejects outright. // digit characters (0-9), which idunno.Password rejects outright.
IdentityResult result = await userManager.CreateAsync( string password = PasswordGenerator.Generate(64, 12, 12, allowRepeatedCharacters: true);
user,
PasswordGenerator.Generate(64, 12, 12, allowRepeatedCharacters: true) RegistrationPayload registration = await mediator.Send(
new RegisterUserCommand(
userRecord.User.Username,
userRecord.User.FirstName,
userRecord.User.LastName,
userRecord.Email,
DateTime.Parse(userRecord.DateOfBirth),
password
)
); );
if (!result.Succeeded) userAccountIds.Add(registration.UserAccountId);
throw new InvalidOperationException(
$"Failed to seed user '{userRecord.User.Username}': "
+ string.Join("; ", result.Errors.Select(e => e.Description))
);
userAccountIds.Add(user.Id);
} }
return userAccountIds; return userAccountIds;
} }
static async Task LoadAvatarsIntoDatabaseAsync(
IMediator mediator,
IReadOnlyList<Guid> userIds,
IReadOnlyList<UserRecord> users,
StatusContext ctx
)
{
for (int i = 0; i < userIds.Count; i++)
{
ctx.Status($"Loading avatar {i + 1}/{userIds.Count} into target database...");
Guid userId = userIds[i];
await mediator.Send(new UpdateBiographyCommand(userId, users[i].User.Bio));
byte[] avatarPng = AvatarGenerator.GeneratePng(userId);
await using MemoryStream stream = new(avatarPng);
IFormFile file = new FormFile(stream, 0, stream.Length, "file", $"{userId}.png")
{
Headers = new HeaderDictionary(),
ContentType = "image/png",
};
await mediator.Send(new UploadAvatarCommand(userId, file));
}
}
static async Task LoadBreweriesIntoDatabaseAsync( static async Task LoadBreweriesIntoDatabaseAsync(
IBreweryRepository breweryRepository, IMediator mediator,
ILocationRepository locationRepository,
IReadOnlyList<BreweryRecord> breweries, IReadOnlyList<BreweryRecord> breweries,
IReadOnlyList<Guid> posterUserIds, IReadOnlyList<Guid> posterUserIds,
StatusContext ctx StatusContext ctx
@@ -175,35 +219,34 @@ static async Task LoadBreweriesIntoDatabaseAsync(
ctx.Status($"Loading brewery {i + 1}/{breweries.Count} into target database..."); ctx.Status($"Loading brewery {i + 1}/{breweries.Count} into target database...");
Guid cityId = await locationRepository.GetOrCreateCityIdAsync( Guid cityId = await mediator.Send(
new CityLocation( new GetOrCreateCityCommand(
breweryRecord.Address.City.CityName, new CityLocation(
breweryRecord.Address.City.StateProvince, breweryRecord.Address.City.CityName,
breweryRecord.Address.City.Iso31662, breweryRecord.Address.City.StateProvince,
breweryRecord.Address.City.Country, breweryRecord.Address.City.Iso31662,
breweryRecord.Address.City.Iso31661 breweryRecord.Address.City.Country,
breweryRecord.Address.City.Iso31661
)
) )
); );
await breweryRepository.CreateAsync( await mediator.Send(
new BreweryPost new CreateBreweryCommand(
{ posterUserIds[i % posterUserIds.Count],
BreweryPostId = Guid.NewGuid(), breweryRecord.Brewery.NameEn,
BreweryName = breweryRecord.Brewery.NameEn, breweryRecord.Brewery.DescriptionEn,
Description = breweryRecord.Brewery.DescriptionEn, new CreateBreweryLocation(
PostedById = posterUserIds[i % posterUserIds.Count], cityId,
Location = new BreweryPostLocation breweryRecord.Address.AddressLine1,
{ null,
BreweryPostLocationId = Guid.NewGuid(), breweryRecord.Address.PostalCode,
CityId = cityId, new CoordinateData(
AddressLine1 = breweryRecord.Address.AddressLine1,
PostalCode = breweryRecord.Address.PostalCode,
Coordinates = new CoordinateData(
breweryRecord.Address.Latitude, breweryRecord.Address.Latitude,
breweryRecord.Address.Longitude breweryRecord.Address.Longitude
), )
}, )
} )
); );
} }
} }

View File

@@ -0,0 +1,20 @@
namespace Domain.Entities;
public sealed class UserAvatar
{
// PK
public Guid UserAvatarId { get; set; }
// FK References
public Guid UserProfileId { get; set; }
public Guid PhotoId { get; set; }
// Attributes
public DateTime ValidFrom { get; set; }
public DateTime? ValidTo { get; set; }
// Navigation Field
public Photo? Photo { get; set; }
// Audit Fields
public byte[]? RowVersion { get; set; }
}

View File

@@ -0,0 +1,16 @@
namespace Domain.Entities;
public sealed class UserProfile
{
// PK
public Guid UserProfileId { get; set; }
// FK References
public Guid UserAccountId { get; set; }
// Attributes
public string Biography { get; set; } = string.Empty;
// Audit Fields
public byte[]? RowVersion { get; set; }
}

View File

@@ -5,7 +5,7 @@ using Shared.Application.Emails;
namespace Features.Emails.Commands.SendRegistrationEmail; namespace Features.Emails.Commands.SendRegistrationEmail;
/// <summary> /// <summary>
/// Handles <see cref="SendRegistrationEmailCommand" />, the cross-slice command sent by Features.Auth /// Handles <see cref="SendRegistrationEmailCommand" />, the cross-slice command sent by Features.Users
/// after a new user registers. /// after a new user registers.
/// </summary> /// </summary>
public class SendRegistrationEmailHandler(IEmailDispatcher emailDispatcher) public class SendRegistrationEmailHandler(IEmailDispatcher emailDispatcher)

View File

@@ -5,7 +5,7 @@ using Shared.Application.Emails;
namespace Features.Emails.Commands.SendResendConfirmationEmail; namespace Features.Emails.Commands.SendResendConfirmationEmail;
/// <summary> /// <summary>
/// Handles <see cref="SendResendConfirmationEmailCommand" />, the cross-slice command sent by Features.Auth /// Handles <see cref="SendResendConfirmationEmailCommand" />, the cross-slice command sent by Features.Users
/// when a user requests a fresh confirmation link. /// when a user requests a fresh confirmation link.
/// </summary> /// </summary>
public class SendResendConfirmationEmailHandler(IEmailDispatcher emailDispatcher) public class SendResendConfirmationEmailHandler(IEmailDispatcher emailDispatcher)

View File

@@ -0,0 +1,12 @@
using Features.Locations.Dtos;
using MediatR;
namespace Features.Locations.Commands.GetOrCreateCity;
/// <summary>
/// Resolves <see cref="City" /> to a City ID, creating the Country, StateProvince, and/or City rows
/// if any part of the chain doesn't exist yet. Sent by other features' handlers (e.g. brewery
/// creation) rather than bound from a request directly, since this feature exposes no controller of
/// its own.
/// </summary>
public record GetOrCreateCityCommand(CityLocation City) : IRequest<Guid>;

View File

@@ -0,0 +1,12 @@
using Features.Locations.Repository;
using MediatR;
namespace Features.Locations.Commands.GetOrCreateCity;
/// <summary>Handles <see cref="GetOrCreateCityCommand" /> by delegating to <see cref="ILocationRepository" />.</summary>
public class GetOrCreateCityHandler(ILocationRepository repository)
: IRequestHandler<GetOrCreateCityCommand, Guid>
{
public Task<Guid> Handle(GetOrCreateCityCommand request, CancellationToken cancellationToken) =>
repository.GetOrCreateCityIdAsync(request.City);
}

View File

@@ -25,7 +25,12 @@ public class UploadPhotoHandler(
cancellationToken cancellationToken
); );
Photo photo = new() { Hyperlink = key, UploadedById = request.UploadedById }; Photo photo = new()
{
PhotoId = photoId,
Hyperlink = key,
UploadedById = request.UploadedById,
};
await photoUploadRepository.CreateAsync(photo, cancellationToken); await photoUploadRepository.CreateAsync(photo, cancellationToken);

View File

@@ -18,7 +18,7 @@ public class PhotoUploadRepository(ISqlConnectionFactory connectionFactory)
public async Task CreateAsync(Photo photo, CancellationToken cancellationToken) public async Task CreateAsync(Photo photo, CancellationToken cancellationToken)
{ {
await using DbConnection connection = await CreateConnection(); await using DbConnection connection = await CreateConnection();
await using DbTransaction transaction = await connection.BeginTransactionAsync(); await using DbTransaction transaction = await connection.BeginTransactionAsync(cancellationToken);
try try
{ {
@@ -44,10 +44,10 @@ public class PhotoUploadRepository(ISqlConnectionFactory connectionFactory)
await connection.ExecuteAsync( await connection.ExecuteAsync(
new CommandDefinition( new CommandDefinition(
""" """
INSERT INTO dbo.Photo (Hyperlink, UploadedByID) INSERT INTO dbo.Photo (PhotoID, Hyperlink, UploadedByID)
VALUES (@Hyperlink, @UploadedById) VALUES (@PhotoId, @Hyperlink, @UploadedById)
""", """,
new { photo.Hyperlink, photo.UploadedById }, new { photo.PhotoId, photo.Hyperlink, photo.UploadedById },
transaction transaction
) )
); );

View File

@@ -1,22 +0,0 @@
using Domain.Entities;
using Features.UserManagement.Commands.UpdateUser;
using Features.UserManagement.Repository;
using Moq;
namespace Features.UserManagement.Tests.Commands;
public class UpdateUserHandlerTests
{
[Fact]
public async Task Handle_DelegatesToRepository()
{
Mock<IUserAccountRepository> repoMock = new();
UpdateUserHandler handler = new(repoMock.Object);
UserAccount user = new() { UserAccountId = Guid.NewGuid() };
repoMock.Setup(r => r.UpdateAsync(user)).Returns(Task.CompletedTask);
await handler.Handle(new UpdateUserCommand(user), CancellationToken.None);
repoMock.Verify(r => r.UpdateAsync(user), Times.Once);
}
}

View File

@@ -1,25 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Features.UserManagement.Tests</RootNamespace>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="FluentAssertions" Version="7.2.0" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Features.UserManagement\Features.UserManagement.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,10 +0,0 @@
using Domain.Entities;
using MediatR;
namespace Features.UserManagement.Commands.UpdateUser;
/// <summary>
/// Updates an existing user account. Not currently exposed via any HTTP route, carried forward
/// from <c>IUserService.UpdateAsync</c> as-is.
/// </summary>
public record UpdateUserCommand(UserAccount UserAccount) : IRequest;

View File

@@ -1,13 +0,0 @@
using Features.UserManagement.Repository;
using MediatR;
namespace Features.UserManagement.Commands.UpdateUser;
public class UpdateUserHandler(IUserAccountRepository repository)
: IRequestHandler<UpdateUserCommand>
{
public Task Handle(UpdateUserCommand request, CancellationToken cancellationToken)
{
return repository.UpdateAsync(request.UserAccount);
}
}

View File

@@ -1,20 +0,0 @@
using Features.UserManagement.Repository;
using Microsoft.Extensions.DependencyInjection;
namespace Features.UserManagement.DependencyInjection;
/// <summary>
/// Registers the services used by the Features.UserManagement slice.
/// </summary>
public static class FeaturesUserManagementServiceCollectionExtensions
{
/// <summary>
/// Registers <see cref="IUserAccountRepository" /> and its Dapper-based implementation.
/// </summary>
/// <returns>The same <paramref name="services" /> instance, for chaining.</returns>
public static IServiceCollection AddFeaturesUserManagement(this IServiceCollection services)
{
services.AddScoped<IUserAccountRepository, UserAccountRepository>();
return services;
}
}

View File

@@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Features.UserManagement</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Domain\Domain.Entities\Domain.Entities.csproj" />
<ProjectReference Include="..\..\Domain\Domain.Exceptions\Domain.Exceptions.csproj" />
<ProjectReference Include="..\..\Database\Database.Connection\Database.Connection.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Contracts\Shared.Contracts.csproj" />
<ProjectReference Include="..\..\Shared\Shared.Application\Shared.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,33 +0,0 @@
using Domain.Entities;
using Domain.Exceptions;
namespace Features.UserManagement.Repository;
/// <summary>
/// Repository for CRUD operations on user account records.
/// </summary>
public interface IUserAccountRepository
{
/// <summary>Returns <c>null</c> if no user account exists with the given ID.</summary>
Task<UserAccount?> GetByIdAsync(Guid id);
/// <summary>Returns user accounts ordered by creation date descending.</summary>
/// <param name="limit"><c>null</c> for no limit.</param>
/// <param name="offset"><c>null</c> for no offset.</param>
Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset);
/// <summary>Updates the mutable fields of an existing user account.</summary>
/// <param name="userAccount">Must have a valid <c>UserAccountId</c>.</param>
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
Task UpdateAsync(UserAccount userAccount);
/// <summary>Deletes the user account with the given ID.</summary>
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
Task DeleteAsync(Guid id);
/// <summary>Returns <c>null</c> if no user account exists with the given username.</summary>
Task<UserAccount?> GetByUsernameAsync(string username);
/// <summary>Returns <c>null</c> if no user account exists with the given email address.</summary>
Task<UserAccount?> GetByEmailAsync(string email);
}

View File

@@ -1,118 +0,0 @@
using System.Data.Common;
using Dapper;
using Database.Connection;
using Domain.Entities;
using Domain.Exceptions;
namespace Features.UserManagement.Repository;
/// <summary>
/// Dapper-based implementation of <see cref="IUserAccountRepository" />.
/// </summary>
public class UserAccountRepository(ISqlConnectionFactory connectionFactory)
: DapperRepository(connectionFactory),
IUserAccountRepository
{
/// <inheritdoc />
public async Task<UserAccount?> GetByIdAsync(Guid id)
{
await using DbConnection connection = await CreateConnection();
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
"""
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
FROM dbo.UserAccount
WHERE UserAccountID = @UserAccountId
""",
new { UserAccountId = id }
);
}
/// <inheritdoc />
/// <remarks>
/// Always applies pagination via <c>OFFSET</c>/<c>FETCH</c>, ordered by creation date descending.
/// A <c>null</c> <paramref name="limit" />/<paramref name="offset" /> is treated as "no limit"/"no offset".
/// </remarks>
public async Task<IEnumerable<UserAccount>> GetAllAsync(int? limit, int? offset)
{
await using DbConnection connection = await CreateConnection();
return await connection.QueryAsync<UserAccount>(
"""
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
FROM dbo.UserAccount
ORDER BY CreatedAt DESC
OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY
""",
new { Offset = offset ?? 0, Limit = limit ?? int.MaxValue }
);
}
/// <inheritdoc />
public async Task UpdateAsync(UserAccount userAccount)
{
await using DbConnection connection = await CreateConnection();
int rows = await connection.ExecuteAsync(
"""
UPDATE dbo.UserAccount
SET Username = @Username, FirstName = @FirstName, LastName = @LastName,
DateOfBirth = @DateOfBirth, Email = @Email
WHERE UserAccountId = @UserAccountId
""",
new
{
userAccount.UserAccountId,
userAccount.Username,
userAccount.FirstName,
userAccount.LastName,
userAccount.DateOfBirth,
userAccount.Email,
}
);
if (rows == 0)
throw new NotFoundException("User account not found.");
}
/// <inheritdoc />
public async Task DeleteAsync(Guid id)
{
await using DbConnection connection = await CreateConnection();
int rows = await connection.ExecuteAsync(
"""
DELETE FROM dbo.UserAccount
WHERE UserAccountId = @UserAccountId
""",
new { UserAccountId = id }
);
if (rows == 0)
throw new NotFoundException("User account not found.");
}
/// <inheritdoc />
public async Task<UserAccount?> GetByUsernameAsync(string username)
{
await using DbConnection connection = await CreateConnection();
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
"""
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
FROM dbo.UserAccount
WHERE Username = @Username
""",
new { Username = username }
);
}
/// <inheritdoc />
public async Task<UserAccount?> GetByEmailAsync(string email)
{
await using DbConnection connection = await CreateConnection();
return await connection.QueryFirstOrDefaultAsync<UserAccount>(
"""
SELECT UserAccountID, Username, FirstName, LastName, Email, CreatedAt, UpdatedAt, DateOfBirth, RowVersion
FROM dbo.UserAccount
WHERE Email = @Email
""",
new { Email = email }
);
}
}

View File

@@ -1,7 +1,7 @@
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims; using System.Security.Claims;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Commands.ConfirmUser; using Features.Auth.Commands.Authentication.ConfirmUser;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Identity; using Features.Auth.Identity;
using Features.Auth.Services; using Features.Auth.Services;

View File

@@ -1,5 +1,5 @@
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Commands.DeleteAccount; using Features.Auth.Commands.Account.DeleteAccount;
using Features.Auth.Identity; using Features.Auth.Identity;
using Features.Auth.Tests.TestSupport; using Features.Auth.Tests.TestSupport;
using FluentAssertions; using FluentAssertions;

View File

@@ -1,4 +1,4 @@
using Features.Auth.Commands.RefreshToken; using Features.Auth.Commands.Authentication.RefreshToken;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Services; using Features.Auth.Services;
using FluentAssertions; using FluentAssertions;

View File

@@ -1,5 +1,5 @@
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Commands.RegisterUser; using Features.Auth.Commands.Authentication.RegisterUser;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Identity; using Features.Auth.Identity;
using Features.Auth.Services; using Features.Auth.Services;

View File

@@ -1,4 +1,4 @@
using Features.Auth.Commands.ResendConfirmationEmail; using Features.Auth.Commands.Authentication.ResendConfirmationEmail;
using Features.Auth.Identity; using Features.Auth.Identity;
using Features.Auth.Services; using Features.Auth.Services;
using Features.Auth.Tests.TestSupport; using Features.Auth.Tests.TestSupport;

View File

@@ -1,5 +1,5 @@
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Commands.UpdateEmail; using Features.Auth.Commands.Account.UpdateEmail;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Identity; using Features.Auth.Identity;
using Features.Auth.Tests.TestSupport; using Features.Auth.Tests.TestSupport;

View File

@@ -1,5 +1,5 @@
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Commands.UpdatePassword; using Features.Auth.Commands.Account.UpdatePassword;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Identity; using Features.Auth.Identity;
using Features.Auth.Tests.TestSupport; using Features.Auth.Tests.TestSupport;

View File

@@ -1,5 +1,5 @@
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Commands.UpdateProfile; using Features.Auth.Commands.Profile.UpdateProfile;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Identity; using Features.Auth.Identity;
using Features.Auth.Tests.TestSupport; using Features.Auth.Tests.TestSupport;

View File

@@ -1,5 +1,5 @@
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Commands.UpdateUsername; using Features.Auth.Commands.Account.UpdateUsername;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Identity; using Features.Auth.Identity;
using Features.Auth.Tests.TestSupport; using Features.Auth.Tests.TestSupport;

View File

@@ -20,7 +20,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Features.Auth\Features.Auth.csproj" /> <ProjectReference Include="..\Features.Users\Features.Users.csproj" />
<ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" /> <ProjectReference Include="..\..\Infrastructure\Infrastructure.Configuration\Infrastructure.Configuration.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View File

@@ -1,17 +1,17 @@
using Domain.Entities; using Domain.Entities;
using Features.UserManagement.Queries.GetAllUsers; using Features.Auth.Queries.GetAllUsers;
using Features.UserManagement.Repository; using Features.Auth.Repository;
using FluentAssertions; using FluentAssertions;
using Moq; using Moq;
namespace Features.UserManagement.Tests.Queries; namespace Features.Auth.Tests.Queries;
public class GetAllUsersHandlerTests public class GetAllUsersHandlerTests
{ {
[Fact] [Fact]
public async Task Handle_PassesLimitAndOffset_ToRepository() public async Task Handle_PassesLimitAndOffset_ToRepository()
{ {
Mock<IUserAccountRepository> repoMock = new(); Mock<IUserListRepository> repoMock = new();
GetAllUsersHandler handler = new(repoMock.Object); GetAllUsersHandler handler = new(repoMock.Object);
repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>()); repoMock.Setup(r => r.GetAllAsync(10, 5)).ReturnsAsync(Array.Empty<UserAccount>());

View File

@@ -1,16 +1,16 @@
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.UserManagement.Queries.GetUserById; using Features.Auth.Queries.GetUserById;
using Features.UserManagement.Repository; using Features.Auth.Repository;
using FluentAssertions; using FluentAssertions;
using Moq; using Moq;
namespace Features.UserManagement.Tests.Queries; namespace Features.Auth.Tests.Queries;
public class GetUserByIdHandlerTests public class GetUserByIdHandlerTests
{ {
private readonly GetUserByIdHandler _handler; private readonly GetUserByIdHandler _handler;
private readonly Mock<IUserAccountRepository> _repoMock = new(); private readonly Mock<IUserListRepository> _repoMock = new();
public GetUserByIdHandlerTests() public GetUserByIdHandlerTests()
{ {

View File

@@ -1,5 +1,5 @@
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Commands.Login; using Features.Auth.Commands.Authentication.Login;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Identity; using Features.Auth.Identity;
using Features.Auth.Services; using Features.Auth.Services;

View File

@@ -1,6 +1,6 @@
using MediatR; using MediatR;
namespace Features.Auth.Commands.DeleteAccount; namespace Features.Auth.Commands.Account.DeleteAccount;
/// <summary>Permanently deletes the given user account.</summary> /// <summary>Permanently deletes the given user account.</summary>
/// <param name="UserAccountId"> /// <param name="UserAccountId">

View File

@@ -3,7 +3,7 @@ using Features.Auth.Identity;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
namespace Features.Auth.Commands.DeleteAccount; namespace Features.Auth.Commands.Account.DeleteAccount;
/// <summary>Handles <see cref="DeleteAccountCommand" /> by deleting the user via <see cref="UserManager{TUser}" />.</summary> /// <summary>Handles <see cref="DeleteAccountCommand" /> by deleting the user via <see cref="UserManager{TUser}" />.</summary>
public class DeleteAccountHandler(UserManager<ApplicationUser> userManager) public class DeleteAccountHandler(UserManager<ApplicationUser> userManager)

View File

@@ -1,7 +1,7 @@
using Features.Auth.Dtos; using Features.Auth.Dtos;
using MediatR; using MediatR;
namespace Features.Auth.Commands.UpdateEmail; namespace Features.Auth.Commands.Account.UpdateEmail;
/// <summary> /// <summary>
/// Changes the email address of the given user account. The account's <c>EmailConfirmed</c> status /// Changes the email address of the given user account. The account's <c>EmailConfirmed</c> status

View File

@@ -4,13 +4,13 @@ using Features.Auth.Identity;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
namespace Features.Auth.Commands.UpdateEmail; namespace Features.Auth.Commands.Account.UpdateEmail;
/// <summary> /// <summary>
/// Handles <see cref="UpdateEmailCommand" /> via <see cref="UserManager{TUser}" />. /// Handles <see cref="UpdateEmailCommand" /> via <see cref="UserManager{TUser}" />.
/// <see cref="UserManager{TUser}.SetEmailAsync" /> also resets <c>EmailConfirmed</c> to /// <see cref="UserManager{TUser}.SetEmailAsync" /> also resets <c>EmailConfirmed</c> to
/// <see langword="false" />, so the user must re-confirm the new address (see /// <see langword="false" />, so the user must re-confirm the new address (see
/// <c>Features.Auth.Commands.ResendConfirmationEmail</c>). /// <c>Features.Users.Commands.ResendConfirmationEmail</c>).
/// </summary> /// </summary>
public class UpdateEmailHandler(UserManager<ApplicationUser> userManager) public class UpdateEmailHandler(UserManager<ApplicationUser> userManager)
: IRequestHandler<UpdateEmailCommand, UpdateEmailPayload> : IRequestHandler<UpdateEmailCommand, UpdateEmailPayload>

View File

@@ -1,6 +1,6 @@
using FluentValidation; using FluentValidation;
namespace Features.Auth.Commands.UpdateEmail; namespace Features.Auth.Commands.Account.UpdateEmail;
/// <summary>Validates <see cref="UpdateEmailCommand" />, mirroring the email rules in <c>RegisterUserValidator</c>.</summary> /// <summary>Validates <see cref="UpdateEmailCommand" />, mirroring the email rules in <c>RegisterUserValidator</c>.</summary>
public class UpdateEmailValidator : AbstractValidator<UpdateEmailCommand> public class UpdateEmailValidator : AbstractValidator<UpdateEmailCommand>

View File

@@ -1,7 +1,7 @@
using Features.Auth.Dtos; using Features.Auth.Dtos;
using MediatR; using MediatR;
namespace Features.Auth.Commands.UpdatePassword; namespace Features.Auth.Commands.Account.UpdatePassword;
/// <summary>Changes the password of the given user account, verifying the current password first.</summary> /// <summary>Changes the password of the given user account, verifying the current password first.</summary>
/// <param name="UserAccountId"> /// <param name="UserAccountId">

View File

@@ -4,7 +4,7 @@ using Features.Auth.Identity;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
namespace Features.Auth.Commands.UpdatePassword; namespace Features.Auth.Commands.Account.UpdatePassword;
/// <summary> /// <summary>
/// Handles <see cref="UpdatePasswordCommand" /> via <see cref="UserManager{TUser}.ChangePasswordAsync" />, /// Handles <see cref="UpdatePasswordCommand" /> via <see cref="UserManager{TUser}.ChangePasswordAsync" />,

View File

@@ -1,6 +1,6 @@
using FluentValidation; using FluentValidation;
namespace Features.Auth.Commands.UpdatePassword; namespace Features.Auth.Commands.Account.UpdatePassword;
/// <summary>Validates <see cref="UpdatePasswordCommand" />, mirroring the password rules in <c>RegisterUserValidator</c>.</summary> /// <summary>Validates <see cref="UpdatePasswordCommand" />, mirroring the password rules in <c>RegisterUserValidator</c>.</summary>
public class UpdatePasswordValidator : AbstractValidator<UpdatePasswordCommand> public class UpdatePasswordValidator : AbstractValidator<UpdatePasswordCommand>

View File

@@ -1,7 +1,7 @@
using Features.Auth.Dtos; using Features.Auth.Dtos;
using MediatR; using MediatR;
namespace Features.Auth.Commands.UpdateUsername; namespace Features.Auth.Commands.Account.UpdateUsername;
/// <summary>Changes the username of the given user account.</summary> /// <summary>Changes the username of the given user account.</summary>
/// <param name="UserAccountId"> /// <param name="UserAccountId">

View File

@@ -4,7 +4,7 @@ using Features.Auth.Identity;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
namespace Features.Auth.Commands.UpdateUsername; namespace Features.Auth.Commands.Account.UpdateUsername;
/// <summary>Handles <see cref="UpdateUsernameCommand" /> via <see cref="UserManager{TUser}" />.</summary> /// <summary>Handles <see cref="UpdateUsernameCommand" /> via <see cref="UserManager{TUser}" />.</summary>
public class UpdateUsernameHandler(UserManager<ApplicationUser> userManager) public class UpdateUsernameHandler(UserManager<ApplicationUser> userManager)

View File

@@ -1,6 +1,6 @@
using FluentValidation; using FluentValidation;
namespace Features.Auth.Commands.UpdateUsername; namespace Features.Auth.Commands.Account.UpdateUsername;
/// <summary>Validates <see cref="UpdateUsernameCommand" />, mirroring the username rules in <c>RegisterUserValidator</c>.</summary> /// <summary>Validates <see cref="UpdateUsernameCommand" />, mirroring the username rules in <c>RegisterUserValidator</c>.</summary>
public class UpdateUsernameValidator : AbstractValidator<UpdateUsernameCommand> public class UpdateUsernameValidator : AbstractValidator<UpdateUsernameCommand>

View File

@@ -1,7 +1,7 @@
using Features.Auth.Dtos; using Features.Auth.Dtos;
using MediatR; using MediatR;
namespace Features.Auth.Commands.ConfirmUser; namespace Features.Auth.Commands.Authentication.ConfirmUser;
/// <summary>Validates a confirmation token and confirms the corresponding user account.</summary> /// <summary>Validates a confirmation token and confirms the corresponding user account.</summary>
public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>; public record ConfirmUserCommand(string Token) : IRequest<ConfirmationPayload>;

View File

@@ -5,7 +5,7 @@ using Features.Auth.Services;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
namespace Features.Auth.Commands.ConfirmUser; namespace Features.Auth.Commands.Authentication.ConfirmUser;
/// <summary> /// <summary>
/// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the /// Handles <see cref="ConfirmUserCommand" /> by validating the confirmation token and marking the

View File

@@ -1,7 +1,7 @@
using Features.Auth.Dtos; using Features.Auth.Dtos;
using MediatR; using MediatR;
namespace Features.Auth.Commands.Login; namespace Features.Auth.Commands.Authentication.Login;
/// <summary> /// <summary>
/// Authenticates a user using their username and password and issues new tokens. Bound directly /// Authenticates a user using their username and password and issues new tokens. Bound directly

View File

@@ -5,7 +5,7 @@ using Features.Auth.Services;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
namespace Features.Auth.Commands.Login; namespace Features.Auth.Commands.Authentication.Login;
/// <summary> /// <summary>
/// Handles <see cref="LoginCommand" /> by verifying credentials via /// Handles <see cref="LoginCommand" /> by verifying credentials via

View File

@@ -1,6 +1,6 @@
using FluentValidation; using FluentValidation;
namespace Features.Auth.Commands.Login; namespace Features.Auth.Commands.Authentication.Login;
/// <summary>Validates that <see cref="LoginCommand.Username" /> and <see cref="LoginCommand.Password" /> are present.</summary> /// <summary>Validates that <see cref="LoginCommand.Username" /> and <see cref="LoginCommand.Password" /> are present.</summary>
public class LoginValidator : AbstractValidator<LoginCommand> public class LoginValidator : AbstractValidator<LoginCommand>

View File

@@ -1,7 +1,7 @@
using Features.Auth.Dtos; using Features.Auth.Dtos;
using MediatR; using MediatR;
namespace Features.Auth.Commands.RefreshToken; namespace Features.Auth.Commands.Authentication.RefreshToken;
/// <summary> /// <summary>
/// Exchanges a valid refresh token for a new access/refresh token pair. Constructed from the /// Exchanges a valid refresh token for a new access/refresh token pair. Constructed from the

View File

@@ -2,7 +2,7 @@ using Features.Auth.Dtos;
using Features.Auth.Services; using Features.Auth.Services;
using MediatR; using MediatR;
namespace Features.Auth.Commands.RefreshToken; namespace Features.Auth.Commands.Authentication.RefreshToken;
/// <summary> /// <summary>
/// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new /// Handles <see cref="RefreshTokenCommand" /> by validating the refresh token and issuing a new

View File

@@ -1,6 +1,6 @@
using FluentValidation; using FluentValidation;
namespace Features.Auth.Commands.RefreshToken; namespace Features.Auth.Commands.Authentication.RefreshToken;
/// <summary>Validates that <see cref="RefreshTokenCommand.RefreshToken" /> is present.</summary> /// <summary>Validates that <see cref="RefreshTokenCommand.RefreshToken" /> is present.</summary>
public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand> public class RefreshTokenValidator : AbstractValidator<RefreshTokenCommand>

View File

@@ -1,7 +1,7 @@
using Features.Auth.Dtos; using Features.Auth.Dtos;
using MediatR; using MediatR;
namespace Features.Auth.Commands.RegisterUser; namespace Features.Auth.Commands.Authentication.RegisterUser;
/// <summary> /// <summary>
/// Registers a new user account. Bound directly from the request body of <c>POST /api/auth/register</c>. /// Registers a new user account. Bound directly from the request body of <c>POST /api/auth/register</c>.

View File

@@ -1,5 +1,6 @@
using Domain.Entities; using Domain.Entities;
using Domain.Exceptions; using Domain.Exceptions;
using Features.Auth.Commands.Profile.CreateUserProfile;
using Features.Auth.Dtos; using Features.Auth.Dtos;
using Features.Auth.Identity; using Features.Auth.Identity;
using Features.Auth.Services; using Features.Auth.Services;
@@ -7,7 +8,7 @@ using MediatR;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Shared.Application.Emails; using Shared.Application.Emails;
namespace Features.Auth.Commands.RegisterUser; namespace Features.Auth.Commands.Authentication.RegisterUser;
/// <summary> /// <summary>
/// Handles <see cref="RegisterUserCommand" />: creates the user via <see cref="UserManager{TUser}" /> /// Handles <see cref="RegisterUserCommand" />: creates the user via <see cref="UserManager{TUser}" />
@@ -48,6 +49,8 @@ public class RegisterUserHandler(
); );
} }
await mediator.Send(new CreateUserProfileCommand(user.Id, string.Empty), cancellationToken);
string accessToken = tokenService.GenerateAccessToken(user.Id, user.UserName); string accessToken = tokenService.GenerateAccessToken(user.Id, user.UserName);
string refreshToken = tokenService.GenerateRefreshToken(user.Id, user.UserName); string refreshToken = tokenService.GenerateRefreshToken(user.Id, user.UserName);
string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName); string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName);

View File

@@ -1,6 +1,6 @@
using FluentValidation; using FluentValidation;
namespace Features.Auth.Commands.RegisterUser; namespace Features.Auth.Commands.Authentication.RegisterUser;
/// <summary> /// <summary>
/// Validates the field-level constraints on <see cref="RegisterUserCommand" />: username format and /// Validates the field-level constraints on <see cref="RegisterUserCommand" />: username format and

View File

@@ -1,6 +1,6 @@
using MediatR; using MediatR;
namespace Features.Auth.Commands.ResendConfirmationEmail; namespace Features.Auth.Commands.Authentication.ResendConfirmationEmail;
/// <summary>Resends the account confirmation email to a user, generating a fresh confirmation token.</summary> /// <summary>Resends the account confirmation email to a user, generating a fresh confirmation token.</summary>
public record ResendConfirmationEmailCommand(Guid UserId) : IRequest; public record ResendConfirmationEmailCommand(Guid UserId) : IRequest;

View File

@@ -4,7 +4,7 @@ using MediatR;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Shared.Application.Emails; using Shared.Application.Emails;
namespace Features.Auth.Commands.ResendConfirmationEmail; namespace Features.Auth.Commands.Authentication.ResendConfirmationEmail;
/// <summary> /// <summary>
/// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and /// Handles <see cref="ResendConfirmationEmailCommand" /> by generating a fresh confirmation token and

View File

@@ -0,0 +1,6 @@
using MediatR;
namespace Features.Auth.Commands.Profile.CreateUserProfile;
/// <summary>Creates a new user profile for a user account.</summary>
public record CreateUserProfileCommand(Guid UserAccountId, string Biography) : IRequest<Guid>;

View File

@@ -0,0 +1,19 @@
using Features.Auth.Repository;
using MediatR;
namespace Features.Auth.Commands.Profile.CreateUserProfile;
/// <summary>Handles <see cref="CreateUserProfileCommand" /> by inserting the new profile record.</summary>
public class CreateUserProfileHandler(IUserProfileRepository userProfileRepository)
: IRequestHandler<CreateUserProfileCommand, Guid>
{
public Task<Guid> Handle(
CreateUserProfileCommand request,
CancellationToken cancellationToken
) =>
userProfileRepository.CreateProfileAsync(
request.UserAccountId,
request.Biography,
cancellationToken
);
}

View File

@@ -0,0 +1,10 @@
using MediatR;
namespace Features.Auth.Commands.Profile.UpdateBiography;
/// <summary>Updates the biography of the given user account's profile.</summary>
/// <param name="UserAccountId">
/// The authenticated caller's own ID, extracted from the access token -- never bind this from
/// client-supplied input.
/// </param>
public record UpdateBiographyCommand(Guid UserAccountId, string Biography) : IRequest;

View File

@@ -0,0 +1,19 @@
using Features.Auth.Repository;
using MediatR;
namespace Features.Auth.Commands.Profile.UpdateBiography;
/// <summary>Handles <see cref="UpdateBiographyCommand" /> by updating the stored biography.</summary>
public class UpdateBiographyHandler(IUserProfileRepository userProfileRepository)
: IRequestHandler<UpdateBiographyCommand>
{
/// <exception cref="Domain.Exceptions.NotFoundException">
/// Thrown when no user profile exists for the given user account.
/// </exception>
public Task Handle(UpdateBiographyCommand request, CancellationToken cancellationToken) =>
userProfileRepository.UpdateBiographyAsync(
request.UserAccountId,
request.Biography,
cancellationToken
);
}

View File

@@ -1,7 +1,7 @@
using Features.Auth.Dtos; using Features.Auth.Dtos;
using MediatR; using MediatR;
namespace Features.Auth.Commands.UpdateProfile; namespace Features.Auth.Commands.Profile.UpdateProfile;
/// <summary> /// <summary>
/// Updates the non-credential profile fields (first name, last name, date of birth) of the given /// Updates the non-credential profile fields (first name, last name, date of birth) of the given

Some files were not shown because too many files have changed in this diff Show More