mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Add user avatar and profile feature, consolidate Auth and UserManagement features (#257)
This commit is contained in:
@@ -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)
|
||||||
└──────────────┘
|
└──────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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/"]
|
||||||
|
|||||||
@@ -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>();
|
||||||
@@ -152,4 +129,4 @@ lifetime.ApplicationStopping.Register(() =>
|
|||||||
app.Logger.LogInformation("Application is shutting down gracefully...");
|
app.Logger.LogInformation("Application is shutting down gracefully...");
|
||||||
});
|
});
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -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/"]
|
||||||
|
|||||||
@@ -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/">
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -29,3 +29,5 @@ CREATE TABLE dbo.UserAccount
|
|||||||
CONSTRAINT AK_Email
|
CONSTRAINT AK_Email
|
||||||
UNIQUE (Email)
|
UNIQUE (Email)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
),
|
)
|
||||||
},
|
)
|
||||||
}
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20
web/backend/Domain/Domain.Entities/Entities/UserAvatar.cs
Normal file
20
web/backend/Domain/Domain.Entities/Entities/UserAvatar.cs
Normal 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; }
|
||||||
|
}
|
||||||
16
web/backend/Domain/Domain.Entities/Entities/UserProfile.cs
Normal file
16
web/backend/Domain/Domain.Entities/Entities/UserProfile.cs
Normal 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; }
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>;
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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;
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
@@ -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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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>
|
||||||
@@ -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>());
|
||||||
|
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
@@ -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;
|
||||||
@@ -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">
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
@@ -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" />,
|
||||||
@@ -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>
|
||||||
@@ -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">
|
||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
@@ -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>;
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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>
|
||||||
@@ -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>.
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
@@ -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>;
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
Reference in New Issue
Block a user