mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 10:04:44 +00:00
remove puml
This commit is contained in:
@@ -1,84 +1,120 @@
|
||||
# The Biergarten App — Vertical Slice Architecture
|
||||
|
||||
Two diagrams, split by concern: which host wires up which slice at startup, and
|
||||
which projects each slice actually compiles against. Mixing DI wiring and
|
||||
compile-time references in one graph is what made the old diagram unreadable.
|
||||
|
||||
## Host wiring
|
||||
|
||||
Each host (`API.Core`, `Database.Seed`) decides for itself which `Features.*`
|
||||
assemblies to register — there's no shared bootstrap. `Database.Migrations`
|
||||
doesn't wire up any slice; it applies schema directly.
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
flowchart:
|
||||
curve: stepAfter
|
||||
---
|
||||
flowchart TB
|
||||
subgraph HOST["API.Core (thin host)"]
|
||||
API["API.Core<br/>ASP.NET Core Web API host"]
|
||||
end
|
||||
API["API.Core<br/>(ASP.NET Core Web API)"]
|
||||
Seed["Database.Seed<br/>(console app)"]
|
||||
Migrations["Database.Migrations<br/>(console app)"]
|
||||
|
||||
subgraph SLICES["Feature Slices"]
|
||||
UsersSlice["Features.Users"]
|
||||
direction LR
|
||||
UsersSlice["Features.Users<br/>(controller)"]
|
||||
BrewerySlice["Features.Breweries<br/>(controller)"]
|
||||
PhotoSlice["Features.PhotoUpload<br/>(MediatR only)"]
|
||||
EmailsSlice["Features.Emails<br/>(MediatR only)"]
|
||||
LocationsSlice["Features.Locations<br/>(plain DI service)"]
|
||||
BeersSlice["Features.Beers<br/>(scaffolded, no code yet)"]
|
||||
end
|
||||
|
||||
API -->|AddApplicationPart + MediatR scan| UsersSlice
|
||||
API -->|AddApplicationPart + MediatR scan| BrewerySlice
|
||||
API -->|MediatR scan only| PhotoSlice
|
||||
API -->|MediatR scan only| EmailsSlice
|
||||
|
||||
Seed -->|AddFeaturesUsers| UsersSlice
|
||||
Seed -->|AddFeaturesBreweries| BrewerySlice
|
||||
Seed -->|AddFeaturesPhotoUpload| PhotoSlice
|
||||
Seed -->|AddFeaturesLocations| LocationsSlice
|
||||
```
|
||||
|
||||
## Project reference dependencies
|
||||
|
||||
Solid arrows are compile-time project references. Dashed arrows are the two
|
||||
places slices talk to each other without a direct reference: the email
|
||||
MediatR contract routed through `Shared.Application`, and the one place that
|
||||
pattern is broken outright. Every DB-backed slice depends on the same three
|
||||
foundation projects (`Shared.Contracts`, `Domain.Entities`,
|
||||
`Database.Connection`) — drawn as one box instead of fifteen separate arrows;
|
||||
see the notes for what else each of those foundation projects pulls in.
|
||||
|
||||
```mermaid
|
||||
---
|
||||
config:
|
||||
flowchart:
|
||||
curve: stepAfter
|
||||
---
|
||||
flowchart TB
|
||||
subgraph SLICES["Feature Slices"]
|
||||
direction LR
|
||||
BrewerySlice["Features.Breweries"]
|
||||
EmailsSlice["Features.Emails<br/>(no controller)"]
|
||||
LocationsSlice["Features.Locations<br/>(no controller, no MediatR)"]
|
||||
LocationsSlice["Features.Locations"]
|
||||
BeersSlice["Features.Beers<br/>(scaffolded)"]
|
||||
PhotoSlice["Features.PhotoUpload"]
|
||||
UsersSlice["Features.Users"]
|
||||
EmailsSlice["Features.Emails"]
|
||||
end
|
||||
|
||||
subgraph SHARED["Shared"]
|
||||
subgraph FOUNDATION["Foundation (every DB-backed slice depends on all three)"]
|
||||
direction LR
|
||||
SharedContracts["Shared.Contracts"]
|
||||
SharedApp["Shared.Application"]
|
||||
Domain["Domain.Entities"]
|
||||
DBConn["Database.Connection"]
|
||||
end
|
||||
|
||||
subgraph INFRA["Infrastructure Layer"]
|
||||
Sql["Infrastructure.Sql"]
|
||||
SharedApp["Shared.Application<br/>(email command contract)"]
|
||||
|
||||
subgraph INFRA["Slice-specific Infrastructure"]
|
||||
direction LR
|
||||
JWT["Infrastructure.Jwt"]
|
||||
PwdHash["Infrastructure.PasswordHashing"]
|
||||
FileUpload["Infrastructure.FileUpload"]
|
||||
Email["Infrastructure.Email"]
|
||||
EmailTemplates["Infrastructure.Email.Templates"]
|
||||
end
|
||||
|
||||
subgraph DOMAIN["Domain Layer"]
|
||||
Domain["Domain.Entities"]
|
||||
DomainExceptions["Domain.Exceptions"]
|
||||
end
|
||||
|
||||
Tables[("SQL Server<br/>Tables")]
|
||||
Seed["Database.Seed<br/>(separate console app)"]
|
||||
|
||||
API -.->|AddApplicationPart| UsersSlice
|
||||
API -.->|AddApplicationPart| BrewerySlice
|
||||
API -.->|MediatR scan only| EmailsSlice
|
||||
%% the one feature-to-feature reference, breaking slice isolation
|
||||
UsersSlice ==>|"project ref, sends UploadPhotoCommand"| PhotoSlice
|
||||
|
||||
Seed -.->|AddFeaturesUsers| UsersSlice
|
||||
Seed -.->|AddFeaturesBreweries| BrewerySlice
|
||||
Seed -.->|AddFeaturesLocations| LocationsSlice
|
||||
%% cross-slice MediatR contract, routed through Shared.Application instead
|
||||
UsersSlice -.->|sends email commands| SharedApp
|
||||
EmailsSlice -.->|handles email commands| SharedApp
|
||||
|
||||
UsersSlice --> Sql
|
||||
UsersSlice --> JWT
|
||||
UsersSlice --> PwdHash
|
||||
UsersSlice --> SharedContracts
|
||||
UsersSlice --> SharedApp
|
||||
%% every DB-backed slice depends on the same foundation
|
||||
BrewerySlice & LocationsSlice & BeersSlice & PhotoSlice & UsersSlice --> FOUNDATION
|
||||
|
||||
BrewerySlice --> Sql
|
||||
BrewerySlice --> SharedContracts
|
||||
BrewerySlice --> SharedApp
|
||||
%% slice-specific infra, one edge per slice
|
||||
UsersSlice --> JWT & PwdHash
|
||||
PhotoSlice --> FileUpload
|
||||
EmailsSlice --> Email & EmailTemplates
|
||||
|
||||
LocationsSlice --> Sql
|
||||
|
||||
EmailsSlice --> Email
|
||||
EmailsSlice --> EmailTemplates
|
||||
EmailsSlice --> SharedApp
|
||||
|
||||
UsersSlice -.->|sends SendRegistrationEmailCommand| SharedApp
|
||||
EmailsSlice -.->|handles SendRegistrationEmailCommand| SharedApp
|
||||
|
||||
Sql --> Domain
|
||||
|
||||
UsersSlice --> Tables
|
||||
BrewerySlice --> Tables
|
||||
LocationsSlice --> Tables
|
||||
|
||||
UsersSlice --> Domain
|
||||
BrewerySlice --> Domain
|
||||
LocationsSlice --> Domain
|
||||
UsersSlice --> DomainExceptions
|
||||
DBConn -->|Dapper SQL, no ORM| Tables
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **API.Core**: Program.cs wiring only — MediatR + `AddApplicationPart` per `Features.*` assembly, Swagger/OpenAPI, health checks, JWT auth middleware, global exception filter.
|
||||
- **Feature slices**: each owns its own controller (HTTP endpoints), commands/queries + handlers, validators, and Dapper repository. `Features.Users` covers auth (`Commands/Authentication`), account management (`Commands/Account`), and profile/avatar (`Commands/Profile`). `Features.Emails` has no controller — invoked only via MediatR commands sent from other slices. `Features.Locations` has no controller and no MediatR handlers — it exposes `ILocationRepository` directly as a plain DI service, not registered by `API.Core` at all; only `Database.Seed` depends on it today.
|
||||
- **Shared.Application**: `ValidationBehavior` (MediatR pipeline) plus the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) — the one cross-slice interaction is a MediatR command contract, not a project reference.
|
||||
- **Domain.Entities**: `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`, `UserAvatar`, `BreweryPost`, `BreweryPostLocation`, `City`, `StateProvince`, `Country`, `BeerPost`, `BeerStyle` (schema tables exist for the last two; no repository yet).
|
||||
- **Infrastructure.Sql**: SQL-first approach — each slice's own repository issues inline SQL via Dapper, no ORM, no stored procedures.
|
||||
- **Database.Seed**: calls each slice's `AddFeatures*()` DI extension directly — no API host, no MediatR pipeline.
|
||||
- No `Features.*` project ever references another `Features.*` project directly.
|
||||
- **API.Core**: Program.cs wiring only — MediatR + `AddApplicationPart` per HTTP-exposed `Features.*` assembly, Swagger/OpenAPI, health checks, JWT auth middleware, global exception filter. Only `Features.Users` and `Features.Breweries` get `AddApplicationPart` (controllers); `Features.PhotoUpload` and `Features.Emails` are scanned for MediatR handlers only. `Features.Locations` and `Features.Beers` aren't referenced by `API.Core` at all.
|
||||
- **Feature slices**: each owns its own controller (where present), commands/queries + handlers, validators, and Dapper repository. `Features.Users` covers auth (`Commands/Authentication`), account management (`Commands/Account`), and profile/avatar (`Commands/Profile`) — internally namespaced `Features.Auth`, despite the project being named `Features.Users`. `Features.Emails` and `Features.PhotoUpload` have no controller — invoked only via MediatR commands sent from other slices. `Features.Locations` has no controller either; it now has one MediatR handler (`GetOrCreateCityHandler`), but that's only registered by `Database.Seed`'s own MediatR pipeline — `API.Core` never scans it. `Features.Beers` is scaffolded (project + references only, no source files yet).
|
||||
- **The one exception to slice isolation**: `Features.Users` takes a direct project reference to `Features.PhotoUpload` and sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler` — unlike the email flow, the command type lives in the target slice itself, not in `Shared.Application`.
|
||||
- **Shared.Application**: `ValidationBehavior` (MediatR pipeline) plus the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) — that interaction is a MediatR command contract, not a project reference, and every slice (including `Features.Emails`) references it for the pipeline behavior alone.
|
||||
- **Domain.Entities**: `UserAccount`, `UserCredential`, `UserVerification`, `UserProfile`, `UserAvatar`, `BreweryPost`, `BreweryPostLocation`, `City`, `StateProvince`, `Country`, `BeerPost`, `BeerStyle`, `Photo` (schema tables exist for `BeerPost`/`BeerStyle`; no repository yet since `Features.Beers` has no code).
|
||||
- **Database.Connection**: the shared connection-factory project — each slice's own repository issues inline SQL via Dapper against it, no ORM, no stored procedures. Replaces the old `Infrastructure.Sql` project.
|
||||
- **Database.Migrations**: separate console app (`schema.dbml` + SQL scripts) that applies DDL directly — distinct from `Database.Seed`, which loads data through the feature slices' own DI extensions.
|
||||
- **Database.Seed**: calls each slice's `AddFeatures*()` DI extension directly and builds its own MediatR registration — no API host, no shared pipeline with `API.Core`.
|
||||
- **Omitted from the diagram**: `Domain.Exceptions` (referenced by nearly every project directly, including all of `Shared.Contracts`, `Infrastructure.Jwt`, and each feature slice) and `Infrastructure.Configuration` (the shared options-binding project referenced by `Database.Connection`, `Infrastructure.Email`, `Infrastructure.FileUpload`, and `Features.Emails` directly) — both are plumbing-level dependencies of nearly everything above, and drawing them would just add a second copy of the same fan-in.
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
@startuml architecture_v6
|
||||
skinparam linetype ortho
|
||||
skinparam nodesep 90
|
||||
skinparam ranksep 110
|
||||
|
||||
title The Biergarten App - Vertical Slice Architecture
|
||||
|
||||
package "API.Core (thin host)" #F1F3EA {
|
||||
[API.Core\nASP.NET Core Web API host] as API
|
||||
note right of API
|
||||
- Program.cs wiring only
|
||||
- MediatR + AddApplicationPart
|
||||
per Features.* assembly
|
||||
- Swagger/OpenAPI, health checks
|
||||
- JWT auth middleware
|
||||
- Global exception filter
|
||||
end note
|
||||
}
|
||||
|
||||
package "Feature Slices" #DBEEDD {
|
||||
[Features.Users] as UsersSlice
|
||||
[Features.Breweries] as BrewerySlice
|
||||
[Features.Emails] as EmailsSlice
|
||||
[Features.Locations] as LocationsSlice
|
||||
note right of UsersSlice
|
||||
Each slice owns its own:
|
||||
- Controller (HTTP endpoints)
|
||||
- Commands/Queries + Handlers
|
||||
- Validators
|
||||
- Repository (Dapper)
|
||||
|
||||
Features.Users covers auth
|
||||
(Commands/Authentication),
|
||||
account management
|
||||
(Commands/Account), and
|
||||
profile/avatar (Commands/Profile)
|
||||
end note
|
||||
note right of EmailsSlice
|
||||
No controller - invoked only
|
||||
via MediatR commands sent
|
||||
from other slices
|
||||
end note
|
||||
note right of LocationsSlice
|
||||
No controller and no MediatR
|
||||
handlers - exposes ILocationRepository
|
||||
directly as a plain DI service.
|
||||
Not registered by API.Core at all;
|
||||
only Database.Seed depends on it today
|
||||
end note
|
||||
}
|
||||
|
||||
package "Shared" #E4E6D8 {
|
||||
[Shared.Contracts] as SharedContracts
|
||||
[Shared.Application] as SharedApp
|
||||
note right of SharedApp
|
||||
- ValidationBehavior
|
||||
(MediatR pipeline)
|
||||
- Cross-slice email commands
|
||||
(SendRegistrationEmailCommand,
|
||||
SendResendConfirmationEmailCommand)
|
||||
end note
|
||||
}
|
||||
|
||||
package "Infrastructure Layer" #EBECE3 {
|
||||
[Infrastructure.Sql] as Sql
|
||||
[Infrastructure.Jwt] as JWT
|
||||
[Infrastructure.PasswordHashing] as PwdHash
|
||||
[Infrastructure.Email] as Email
|
||||
[Infrastructure.Email.Templates] as EmailTemplates
|
||||
}
|
||||
|
||||
package "Domain Layer" #CBD2B5 {
|
||||
[Domain.Entities] as Domain
|
||||
[Domain.Exceptions] as DomainExceptions
|
||||
note right of Domain
|
||||
- UserAccount, UserCredential, UserVerification
|
||||
- UserProfile, UserAvatar
|
||||
- BreweryPost, BreweryPostLocation
|
||||
- City, StateProvince, Country
|
||||
- BeerPost, BeerStyle
|
||||
(schema tables exist; no repository yet)
|
||||
end note
|
||||
}
|
||||
|
||||
database "SQL Server" {
|
||||
[Tables] as Tables
|
||||
}
|
||||
|
||||
node "Database.Seed\n(separate console app)" as Seed
|
||||
note right of Seed
|
||||
Seed calls each slice's
|
||||
AddFeatures*() DI extension
|
||||
directly - no API host,
|
||||
no MediatR pipeline
|
||||
end note
|
||||
|
||||
' Relationships
|
||||
API ..> UsersSlice : AddApplicationPart
|
||||
API ..> BrewerySlice : AddApplicationPart
|
||||
API ..> EmailsSlice : MediatR scan only
|
||||
|
||||
Seed ..> UsersSlice : AddFeaturesUsers()
|
||||
Seed ..> BrewerySlice : AddFeaturesBreweries()
|
||||
Seed ..> LocationsSlice : AddFeaturesLocations()
|
||||
|
||||
UsersSlice --> Sql
|
||||
UsersSlice --> JWT
|
||||
UsersSlice --> PwdHash
|
||||
UsersSlice --> SharedContracts
|
||||
UsersSlice --> SharedApp
|
||||
|
||||
BrewerySlice --> Sql
|
||||
BrewerySlice --> SharedContracts
|
||||
BrewerySlice --> SharedApp
|
||||
|
||||
LocationsSlice --> Sql
|
||||
|
||||
EmailsSlice --> Email
|
||||
EmailsSlice --> EmailTemplates
|
||||
EmailsSlice --> SharedApp
|
||||
|
||||
' The one cross-slice interaction: a MediatR command contract, not a project reference
|
||||
UsersSlice ..> SharedApp : sends\nSendRegistrationEmailCommand
|
||||
EmailsSlice ..> SharedApp : handles\nSendRegistrationEmailCommand
|
||||
|
||||
Sql --> Domain
|
||||
|
||||
UsersSlice --> Tables : Dapper SQL
|
||||
BrewerySlice --> Tables
|
||||
LocationsSlice --> Tables
|
||||
|
||||
UsersSlice --> Domain
|
||||
BrewerySlice --> Domain
|
||||
LocationsSlice --> Domain
|
||||
UsersSlice --> DomainExceptions
|
||||
|
||||
' Notes
|
||||
note left of Sql
|
||||
SQL-first approach
|
||||
Each slice's own repository issues
|
||||
inline SQL via Dapper - no ORM,
|
||||
no stored procedures
|
||||
end note
|
||||
|
||||
note bottom of UsersSlice
|
||||
No Features.* project
|
||||
ever references another
|
||||
Features.* project directly
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -1,109 +0,0 @@
|
||||
@startuml confirm-user
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Confirm User
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "ConfirmUserHandler" as ConfirmHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
participant "TokenService" as TokenSvc
|
||||
end box
|
||||
box "Infrastructure Layer" #EBECE3
|
||||
participant "JWT\nInfrastructure" as JWT
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: POST /api/auth/confirm?token=<token>\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(ConfirmUserCommand(token))
|
||||
activate Mediator
|
||||
|
||||
Mediator -> ConfirmHandler: Handle(command)
|
||||
activate ConfirmHandler
|
||||
|
||||
ConfirmHandler -> TokenSvc: ValidateConfirmationTokenAsync(token)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: Validate against confirmation\ntoken secret, extract userId
|
||||
activate JWT
|
||||
JWT --> TokenSvc: ValidatedToken or throws
|
||||
deactivate JWT
|
||||
TokenSvc --> ConfirmHandler: ValidatedToken
|
||||
deactivate TokenSvc
|
||||
|
||||
alt Token invalid or expired
|
||||
ConfirmHandler -> Mediator: throw UnauthorizedException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else Token valid
|
||||
|
||||
ConfirmHandler -> UserMgr: FindByIdAsync(validatedToken.UserId)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByIdAsync(userId)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
|
||||
activate DB
|
||||
DB --> UserStore: row or null
|
||||
deactivate DB
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr --> ConfirmHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt User not found
|
||||
ConfirmHandler -> Mediator: throw UnauthorizedException\n"User account not found"
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else User found
|
||||
|
||||
ConfirmHandler -> UserStore: SetEmailConfirmedAsync(user, true)
|
||||
note right of UserStore
|
||||
IUserEmailStore<ApplicationUser> is injected
|
||||
directly into the handler (not via UserManager),
|
||||
setting the confirmed flag in-memory here.
|
||||
end note
|
||||
|
||||
ConfirmHandler -> UserMgr: UpdateAsync(user)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: UpdateAsync(user)
|
||||
activate UserStore
|
||||
UserStore -> DB: UPDATE dbo.UserAccount SET ...
|
||||
UserStore -> DB: INSERT INTO dbo.UserVerification\n(UserAccountId, VerificationDateTime)
|
||||
note right of UserStore
|
||||
Idempotent: skipped (duplicate-key
|
||||
exception swallowed) if a verification
|
||||
row already exists.
|
||||
end note
|
||||
UserStore --> UserMgr: IdentityResult.Success
|
||||
deactivate UserStore
|
||||
UserMgr --> ConfirmHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
ConfirmHandler --> Mediator: ConfirmationPayload(\n user.Id, DateTime.UtcNow)
|
||||
deactivate ConfirmHandler
|
||||
Mediator --> API: ConfirmationPayload
|
||||
deactivate Mediator
|
||||
|
||||
API -> User: 200 OK\n{message: "User with ID {id} is confirmed.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
@@ -1,91 +0,0 @@
|
||||
@startuml delete-account
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Delete Account
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "DeleteAccountHandler" as DeleteAccountHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: DELETE /api/auth/account\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(DeleteAccountCommand(\n GetAuthenticatedUserId()))
|
||||
activate Mediator
|
||||
|
||||
Mediator -> DeleteAccountHandler: Handle(command)
|
||||
activate DeleteAccountHandler
|
||||
|
||||
DeleteAccountHandler -> UserMgr: FindByIdAsync(userAccountId)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByIdAsync(userAccountId)
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
UserMgr --> DeleteAccountHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account not found
|
||||
DeleteAccountHandler -> Mediator: throw NotFoundException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 404 Not Found
|
||||
else Account found
|
||||
|
||||
DeleteAccountHandler -> UserMgr: DeleteAsync(user)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: DeleteAsync(user)
|
||||
activate UserStore
|
||||
UserStore -> DB: DELETE FROM dbo.UserAccount\nWHERE UserAccountID = @Id
|
||||
note right of UserStore
|
||||
UserCredential/UserVerification/UserAvatar
|
||||
cascade on delete. Posts, comments, photos,
|
||||
and follows use ON DELETE NO ACTION - if any
|
||||
reference this account, the DELETE throws a
|
||||
foreign key violation (SQL error 547).
|
||||
end note
|
||||
|
||||
alt Foreign key violation (dependent records exist)
|
||||
UserStore --> UserMgr: IdentityResult.Failed\n(AccountHasDependentRecords)
|
||||
else Delete succeeds
|
||||
UserStore --> UserMgr: IdentityResult.Success
|
||||
end
|
||||
deactivate UserStore
|
||||
UserMgr --> DeleteAccountHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account has dependent records
|
||||
DeleteAccountHandler -> Mediator: throw ConflictException\n"Account cannot be deleted while it still\nhas associated posts, comments, photos,\nor follows."
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict
|
||||
else !result.Succeeded (other failure)
|
||||
DeleteAccountHandler -> Mediator: throw ConflictException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict
|
||||
else result.Succeeded
|
||||
DeleteAccountHandler --> Mediator: (void)
|
||||
deactivate DeleteAccountHandler
|
||||
Mediator --> API: (void)
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Account deleted successfully."}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
@@ -1,30 +0,0 @@
|
||||
@startuml error-handling
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Error Handling (Global Exception Filter)
|
||||
|
||||
participant "API\nController" as API
|
||||
|
||||
note over API
|
||||
API.Core's GlobalExceptionFilter catches:
|
||||
- FluentValidation.ValidationException -> 400 Bad Request\n {message, errors} (grouped by property)
|
||||
- ConflictException -> 409 Conflict
|
||||
- NotFoundException -> 404 Not Found
|
||||
- UnauthorizedException -> 401 Unauthorized
|
||||
- ForbiddenException -> 403 Forbidden
|
||||
- Domain.Exceptions.ValidationException -> 400 Bad Request
|
||||
- Microsoft.Data.SqlClient.SqlException -> 503 Service Unavailable
|
||||
- All others -> 500 Internal Server Error
|
||||
end note
|
||||
|
||||
note over API
|
||||
Applies uniformly across every authentication
|
||||
endpoint (registration, login, refresh, confirm,
|
||||
resend, update-username/email/password/profile,
|
||||
delete-account) - see the per-flow diagrams for
|
||||
where each exception type is thrown.
|
||||
end note
|
||||
|
||||
@enduml
|
||||
@@ -1,129 +0,0 @@
|
||||
@startuml login
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Login
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "LoginHandler" as LoginHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
participant "TokenService" as TokenSvc
|
||||
end box
|
||||
box "Infrastructure Layer" #EBECE3
|
||||
participant "Argon2PasswordHasher" as Argon2
|
||||
participant "JWT\nInfrastructure" as JWT
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: POST /api/auth/login\n{username, password}
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(LoginCommand)
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs LoginValidator:
|
||||
username and password must both be non-empty
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
|
||||
else Validation succeeds
|
||||
|
||||
Mediator -> LoginHandler: Handle(command)
|
||||
activate LoginHandler
|
||||
|
||||
LoginHandler -> UserMgr: FindByNameAsync(username)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByNameAsync(username)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
|
||||
activate DB
|
||||
DB --> UserStore: UserAccount row or null
|
||||
deactivate DB
|
||||
UserStore -> UserStore: HydrateAsync\n(load active UserCredential.Hash,\nUserVerification -> EmailConfirmed)
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr --> LoginHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt User not found
|
||||
LoginHandler -> Mediator: throw UnauthorizedException\n"Invalid username or password."
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else User found
|
||||
|
||||
LoginHandler -> UserMgr: CheckPasswordAsync(user, password)
|
||||
activate UserMgr
|
||||
note right of UserMgr
|
||||
HasPasswordAsync short-circuits to false
|
||||
(no active UserCredential was hydrated)
|
||||
without calling the hasher; otherwise
|
||||
delegates to Argon2PasswordHasher.
|
||||
end note
|
||||
UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, password)
|
||||
activate Argon2
|
||||
note right of Argon2
|
||||
Delegates to IPasswordInfrastructure.Verify:
|
||||
1. Split stored hash: "salt:hash"
|
||||
2. Hash provided password with the same salt
|
||||
3. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)
|
||||
end note
|
||||
Argon2 --> UserMgr: Success/Failed
|
||||
deactivate Argon2
|
||||
UserMgr --> LoginHandler: true/false
|
||||
deactivate UserMgr
|
||||
|
||||
alt Password invalid
|
||||
LoginHandler -> Mediator: throw UnauthorizedException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else Password valid
|
||||
|
||||
LoginHandler -> TokenSvc: GenerateAccessToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(...)
|
||||
activate JWT
|
||||
JWT --> TokenSvc: Access Token
|
||||
deactivate JWT
|
||||
TokenSvc --> LoginHandler: Access Token
|
||||
deactivate TokenSvc
|
||||
|
||||
LoginHandler -> TokenSvc: GenerateRefreshToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(...)
|
||||
activate JWT
|
||||
JWT --> TokenSvc: Refresh Token
|
||||
deactivate JWT
|
||||
TokenSvc --> LoginHandler: Refresh Token
|
||||
deactivate TokenSvc
|
||||
|
||||
LoginHandler --> Mediator: LoginPayload(\n userAccountId, username,\n refreshToken, accessToken)
|
||||
deactivate LoginHandler
|
||||
Mediator --> API: LoginPayload
|
||||
deactivate Mediator
|
||||
|
||||
API -> User: 200 OK\n{\n message: "Logged in successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken\n }\n}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
@@ -1,108 +0,0 @@
|
||||
@startuml refresh-token
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Refresh Token
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "RefreshTokenHandler" as RefreshHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
participant "TokenService" as TokenSvc
|
||||
end box
|
||||
box #EBECE3
|
||||
participant "JWT\nInfrastructure" as JWT
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: POST /api/auth/refresh\nHeader: X-Refresh-Token: <token>
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(RefreshTokenCommand(refreshToken))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs RefreshTokenValidator:
|
||||
refreshToken must be non-empty. Anonymous
|
||||
endpoint - no Bearer token required.
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
|
||||
else Validation succeeds
|
||||
|
||||
Mediator -> RefreshHandler: Handle(command)
|
||||
activate RefreshHandler
|
||||
|
||||
RefreshHandler -> TokenSvc: RefreshTokenAsync(refreshToken)
|
||||
activate TokenSvc
|
||||
|
||||
TokenSvc -> JWT: ValidateRefreshTokenAsync(refreshToken)
|
||||
activate JWT
|
||||
note right of JWT
|
||||
Validates signature/expiry against the
|
||||
refresh token secret, extracts userId/
|
||||
username claims.
|
||||
end note
|
||||
JWT --> TokenSvc: ValidatedToken or throws
|
||||
deactivate JWT
|
||||
|
||||
alt Token invalid or expired
|
||||
TokenSvc -> RefreshHandler: throw UnauthorizedException
|
||||
RefreshHandler -> Mediator: propagate
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else Token valid
|
||||
|
||||
TokenSvc -> UserMgr: FindByIdAsync(validated.UserId)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByIdAsync(userId)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
|
||||
activate DB
|
||||
DB --> UserStore: row or null
|
||||
deactivate DB
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr --> TokenSvc: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt User not found
|
||||
TokenSvc -> RefreshHandler: throw UnauthorizedException\n"User account not found"
|
||||
RefreshHandler -> Mediator: propagate
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else User found
|
||||
TokenSvc -> JWT: GenerateAccessToken(user)\nGenerateRefreshToken(user)
|
||||
JWT --> TokenSvc: new access + refresh tokens
|
||||
TokenSvc --> RefreshHandler: RefreshTokenResult
|
||||
deactivate TokenSvc
|
||||
|
||||
RefreshHandler --> Mediator: LoginPayload(\n userId, username,\n refreshToken, accessToken)
|
||||
deactivate RefreshHandler
|
||||
Mediator --> API: LoginPayload
|
||||
deactivate Mediator
|
||||
|
||||
API -> User: 200 OK\n{message: "Token refreshed successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
@@ -1,241 +0,0 @@
|
||||
@startuml registration
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Registration
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "RegisterUserHandler" as RegHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
participant "TokenService" as TokenSvc
|
||||
end box
|
||||
box "Infrastructure Layer" #EBECE3
|
||||
participant "Argon2PasswordHasher" as Argon2
|
||||
participant "JWT\nInfrastructure" as JWT
|
||||
participant "Email\nProvider" as SMTP
|
||||
participant "Template\nProvider" as Template
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(RegisterUserCommand)
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs RegisterUserValidator
|
||||
(FluentValidation) before the handler:
|
||||
- Username: 3-64 chars, alphanumeric + [._-]
|
||||
- Email: valid format, max 128 chars
|
||||
- Password: min 8 chars, uppercase,\n lowercase, number, special char
|
||||
- DateOfBirth: must be 19+ years old
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
|
||||
else Validation succeeds
|
||||
Mediator -> RegHandler: Handle(command)
|
||||
activate RegHandler
|
||||
|
||||
RegHandler -> RegHandler: user = new UserAccount{...}\n.ToApplicationUser()
|
||||
|
||||
RegHandler -> UserMgr: CreateAsync(user, password)
|
||||
activate UserMgr
|
||||
|
||||
UserMgr -> Argon2: HashPassword(user, password)
|
||||
activate Argon2
|
||||
note right of Argon2
|
||||
Delegates to IPasswordInfrastructure.Hash:
|
||||
Argon2id parameters:
|
||||
- Salt: 16 bytes (128-bit)
|
||||
- Memory: 64MB
|
||||
- Iterations: 4
|
||||
- Parallelism: CPU count
|
||||
- Hash output: 32 bytes
|
||||
end note
|
||||
Argon2 --> UserMgr: "base64(salt):base64(hash)"
|
||||
deactivate Argon2
|
||||
|
||||
UserMgr -> UserStore: SetPasswordHashAsync(user, hash)
|
||||
note right of UserStore
|
||||
user.Id is still empty at this point -
|
||||
only sets the in-memory hash. CreateAsync
|
||||
writes it to dbo.UserCredential itself.
|
||||
end note
|
||||
|
||||
UserMgr -> UserStore: FindByNameAsync(username)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
|
||||
activate DB
|
||||
DB --> UserStore: null (user doesn't exist)
|
||||
deactivate DB
|
||||
deactivate UserStore
|
||||
|
||||
UserMgr -> UserStore: FindByEmailAsync(email)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Email = @Email
|
||||
activate DB
|
||||
DB --> UserStore: null (email doesn't exist)
|
||||
deactivate DB
|
||||
deactivate UserStore
|
||||
note right of UserMgr
|
||||
Default UserManager validators check
|
||||
uniqueness before CreateAsync is called;
|
||||
DapperUserStore.CreateAsync also guards
|
||||
against a genuine race via a caught
|
||||
duplicate-key SqlException (2601/2627).
|
||||
end note
|
||||
|
||||
UserMgr -> UserStore: CreateAsync(user)
|
||||
activate UserStore
|
||||
|
||||
UserStore -> DB: BEGIN TRANSACTION
|
||||
activate DB
|
||||
UserStore -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID
|
||||
DB --> UserStore: UserAccountId (GUID)
|
||||
UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
|
||||
UserStore -> DB: COMMIT TRANSACTION
|
||||
deactivate DB
|
||||
note right of UserStore
|
||||
Both statements run via Dapper's
|
||||
ExecuteScalarAsync/ExecuteAsync against
|
||||
a single DbTransaction, not a stored
|
||||
procedure - no manual parameter binding
|
||||
end note
|
||||
|
||||
UserStore --> UserMgr: IdentityResult.Success\n(user.Id set)
|
||||
deactivate UserStore
|
||||
|
||||
UserMgr --> RegHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
RegHandler -> Mediator: throw ConflictException\n(DuplicateUserName/DuplicateEmail codes ->\n"Username or email already exists";\notherwise joined error descriptions)
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict\n"Username or email already exists"
|
||||
else result.Succeeded
|
||||
|
||||
RegHandler -> TokenSvc: GenerateAccessToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
||||
activate JWT
|
||||
note right of JWT
|
||||
JWT Configuration:
|
||||
- Algorithm: HS256
|
||||
- Expires: 1 hour
|
||||
- Claims:
|
||||
* sub: userId
|
||||
* unique_name: username
|
||||
* iat / jti
|
||||
end note
|
||||
JWT -> JWT: Create JWT with claims
|
||||
JWT -> JWT: Sign with secret key
|
||||
JWT --> TokenSvc: Access Token
|
||||
deactivate JWT
|
||||
TokenSvc --> RegHandler: Access Token
|
||||
deactivate TokenSvc
|
||||
|
||||
RegHandler -> TokenSvc: GenerateRefreshToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
||||
activate JWT
|
||||
note right of JWT
|
||||
Refresh Token:
|
||||
- Expires: 21 days (504 hours)
|
||||
- Same structure as access token,\n signed with a separate secret
|
||||
end note
|
||||
JWT --> TokenSvc: Refresh Token
|
||||
deactivate JWT
|
||||
TokenSvc --> RegHandler: Refresh Token
|
||||
deactivate TokenSvc
|
||||
|
||||
RegHandler -> TokenSvc: GenerateConfirmationToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc -> JWT: GenerateJwt(userId, username, expiry)
|
||||
activate JWT
|
||||
note right of JWT
|
||||
Confirmation Token:
|
||||
- Expires: 30 minutes (0.5 hours)
|
||||
- Signed with a third, separate secret
|
||||
end note
|
||||
JWT --> TokenSvc: Confirmation Token
|
||||
deactivate JWT
|
||||
TokenSvc --> RegHandler: Confirmation Token
|
||||
deactivate TokenSvc
|
||||
|
||||
RegHandler -> Mediator: Send(SendRegistrationEmailCommand)\n(Shared.Application cross-slice contract)
|
||||
activate Mediator
|
||||
Mediator -> EmailDispatcher: SendRegistrationEmailAsync(\n firstName, email, confirmationToken)
|
||||
note right of Mediator
|
||||
Routed to Features.Emails'
|
||||
SendRegistrationEmailHandler,
|
||||
the only cross-slice call in
|
||||
the backend
|
||||
end note
|
||||
activate EmailDispatcher
|
||||
|
||||
EmailDispatcher -> Template: RenderUserRegisteredEmailAsync(\n firstName, confirmationLink)
|
||||
activate Template
|
||||
note right of Template
|
||||
Razor Component:
|
||||
- Header with branding
|
||||
- Welcome message
|
||||
- Confirmation button
|
||||
- Footer
|
||||
end note
|
||||
Template -> Template: Render Razor component\nto HTML
|
||||
Template --> EmailDispatcher: HTML email content
|
||||
deactivate Template
|
||||
|
||||
EmailDispatcher -> SMTP: SendAsync(email, subject, body)
|
||||
activate SMTP
|
||||
note right of SMTP
|
||||
SMTP Configuration (MailKit):
|
||||
- Host/port: from env (SMTP_HOST/SMTP_PORT)
|
||||
- TLS: StartTLS when SMTP_USE_SSL=true
|
||||
- Auth: username/password, if configured
|
||||
end note
|
||||
SMTP -> SMTP: Build MIME message
|
||||
SMTP -> SMTP: Connect, authenticate,\nsend, disconnect
|
||||
SMTP --> EmailDispatcher: Success / throws
|
||||
deactivate SMTP
|
||||
|
||||
deactivate EmailDispatcher
|
||||
deactivate Mediator
|
||||
|
||||
alt Email sent successfully
|
||||
RegHandler -> RegHandler: emailSent = true
|
||||
else mediator.Send throws
|
||||
RegHandler -> RegHandler: catch(Exception),\nlog to stderr,\nemailSent = false
|
||||
end
|
||||
|
||||
RegHandler --> Mediator: RegistrationPayload(\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent)
|
||||
deactivate RegHandler
|
||||
Mediator --> API: RegistrationPayload
|
||||
deactivate Mediator
|
||||
|
||||
API -> User: 201 Created\n{\n message: "User registered successfully.",\n payload: {\n userAccountId, username,\n refreshToken, accessToken,\n confirmationEmailSent\n }\n}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
@@ -1,97 +0,0 @@
|
||||
@startuml resend-confirmation
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Resend Confirmation Email
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "ResendConfirmationEmailHandler" as ResendHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
participant "TokenService" as TokenSvc
|
||||
end box
|
||||
box "Infrastructure Layer" #EBECE3
|
||||
participant "Email\nProvider" as SMTP
|
||||
participant "Template\nProvider" as Template
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: POST /api/auth/confirm/resend?userId=<id>\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(ResendConfirmationEmailCommand(userId))
|
||||
activate Mediator
|
||||
|
||||
Mediator -> ResendHandler: Handle(command)
|
||||
activate ResendHandler
|
||||
|
||||
ResendHandler -> UserMgr: FindByIdAsync(userId)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByIdAsync(userId)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
|
||||
activate DB
|
||||
DB --> UserStore: row or null
|
||||
deactivate DB
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr --> ResendHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt User not found
|
||||
ResendHandler -> ResendHandler: return silently\n(prevents user enumeration)
|
||||
else User found
|
||||
|
||||
ResendHandler -> UserMgr: IsEmailConfirmedAsync(user)
|
||||
UserMgr --> ResendHandler: bool
|
||||
|
||||
alt Already confirmed
|
||||
ResendHandler -> ResendHandler: return silently (no-op)
|
||||
else Not yet confirmed
|
||||
|
||||
ResendHandler -> TokenSvc: GenerateConfirmationToken(user)
|
||||
activate TokenSvc
|
||||
TokenSvc --> ResendHandler: confirmation token
|
||||
deactivate TokenSvc
|
||||
|
||||
ResendHandler -> Mediator: Send(SendResendConfirmationEmailCommand)
|
||||
activate Mediator
|
||||
Mediator -> EmailDispatcher: SendResendConfirmationEmailAsync(\n firstName, email, confirmationToken)
|
||||
activate EmailDispatcher
|
||||
EmailDispatcher -> Template: Render email\n(same Razor pipeline as registration)
|
||||
EmailDispatcher -> SMTP: SendAsync(email, subject, body)
|
||||
deactivate EmailDispatcher
|
||||
deactivate Mediator
|
||||
end
|
||||
end
|
||||
|
||||
ResendHandler --> Mediator: (void)
|
||||
deactivate ResendHandler
|
||||
Mediator --> API: (void)
|
||||
deactivate Mediator
|
||||
|
||||
API -> User: 200 OK\n{message: "confirmation email has been resent"}
|
||||
note right of API
|
||||
Always 200 OK regardless of outcome above -
|
||||
same user-enumeration precaution as a not-found
|
||||
user or an already-confirmed account.
|
||||
end note
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
@@ -1,93 +0,0 @@
|
||||
@startuml update-email
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Update Email
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "UpdateEmailHandler" as UpdateEmailHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: PATCH /api/auth/email\n{newEmail}\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(UpdateEmailCommand(\n GetAuthenticatedUserId(), newEmail))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs UpdateEmailValidator
|
||||
(same format rules as registration)
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request
|
||||
else Validation succeeds
|
||||
|
||||
Mediator -> UpdateEmailHandler: Handle(command)
|
||||
activate UpdateEmailHandler
|
||||
|
||||
UpdateEmailHandler -> UserMgr: FindByIdAsync(userAccountId)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByIdAsync(userAccountId)
|
||||
UserStore -> DB: SELECT ...
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
UserMgr --> UpdateEmailHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account not found
|
||||
UpdateEmailHandler -> Mediator: throw NotFoundException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 404 Not Found
|
||||
else Account found
|
||||
|
||||
UpdateEmailHandler -> UserMgr: SetEmailAsync(user, newEmail)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByEmailAsync(newEmail)
|
||||
note right of UserMgr
|
||||
Uniqueness check. Also resets
|
||||
user.EmailConfirmed = false -
|
||||
the caller must re-confirm via
|
||||
POST /api/auth/confirm/resend.
|
||||
end note
|
||||
UserMgr -> UserStore: UpdateAsync(user)
|
||||
UserStore -> DB: UPDATE dbo.UserAccount\nSET Email = @Email, ...
|
||||
UserStore -> DB: DELETE FROM dbo.UserVerification\nWHERE UserAccountID = @Id
|
||||
UserStore --> UserMgr: IdentityResult
|
||||
UserMgr --> UpdateEmailHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
UpdateEmailHandler -> Mediator: throw ConflictException\n(DuplicateEmail code ->\n"Email address already exists")
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdateEmailHandler --> Mediator: UpdateEmailPayload(\n user.Id, user.Email,\n user.EmailConfirmed = false)
|
||||
deactivate UpdateEmailHandler
|
||||
Mediator --> API: UpdateEmailPayload
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Email updated successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
@@ -1,111 +0,0 @@
|
||||
@startuml update-password
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Update Password
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "UpdatePasswordHandler" as UpdatePasswordHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
end box
|
||||
box "Infrastructure Layer" #EBECE3
|
||||
participant "Argon2PasswordHasher" as Argon2
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: PATCH /api/auth/password\n{currentPassword, newPassword}\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(UpdatePasswordCommand(\n GetAuthenticatedUserId(),\n currentPassword, newPassword))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs UpdatePasswordValidator
|
||||
(same password strength rules as registration)
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request
|
||||
else Validation succeeds
|
||||
|
||||
Mediator -> UpdatePasswordHandler: Handle(command)
|
||||
activate UpdatePasswordHandler
|
||||
|
||||
UpdatePasswordHandler -> UserMgr: FindByIdAsync(userAccountId)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByIdAsync(userAccountId)
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
UserMgr --> UpdatePasswordHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account not found
|
||||
UpdatePasswordHandler -> Mediator: throw NotFoundException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 404 Not Found
|
||||
else Account found
|
||||
|
||||
UpdatePasswordHandler -> UserMgr: ChangePasswordAsync(\n user, currentPassword, newPassword)
|
||||
activate UserMgr
|
||||
UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, currentPassword)
|
||||
activate Argon2
|
||||
Argon2 --> UserMgr: Success/Failed
|
||||
deactivate Argon2
|
||||
|
||||
alt Current password mismatch
|
||||
UserMgr --> UpdatePasswordHandler: IdentityResult.Failed\n(PasswordMismatch)
|
||||
else Current password matches
|
||||
UserMgr -> Argon2: HashPassword(user, newPassword)
|
||||
activate Argon2
|
||||
Argon2 --> UserMgr: new hash
|
||||
deactivate Argon2
|
||||
UserMgr -> UserStore: SetPasswordHashAsync(user, newHash)
|
||||
activate UserStore
|
||||
UserStore -> DB: UPDATE dbo.UserCredential\nSET IsRevoked = 1, RevokedAt = GETDATE()\nWHERE UserAccountId = @Id AND IsRevoked = 0
|
||||
UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
|
||||
note right of UserStore
|
||||
Revoke-then-insert runs inside one
|
||||
DbTransaction (credential rotation).
|
||||
end note
|
||||
UserStore --> UserMgr: (void)
|
||||
deactivate UserStore
|
||||
UserMgr --> UpdatePasswordHandler: IdentityResult.Success
|
||||
end
|
||||
deactivate UserMgr
|
||||
|
||||
alt Current password is incorrect
|
||||
UpdatePasswordHandler -> Mediator: throw UnauthorizedException\n"Current password is incorrect"
|
||||
Mediator -> API: propagate
|
||||
API -> User: 401 Unauthorized
|
||||
else !result.Succeeded (other failure)
|
||||
UpdatePasswordHandler -> Mediator: throw ConflictException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdatePasswordHandler --> Mediator: UpdatePasswordPayload(\n user.Id, DateTime.UtcNow)
|
||||
deactivate UpdatePasswordHandler
|
||||
Mediator --> API: UpdatePasswordPayload
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Password updated successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
@@ -1,92 +0,0 @@
|
||||
@startuml update-profile
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Update Profile
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "UpdateProfileHandler" as UpdateProfileHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: PATCH /api/auth/profile\n{firstName, lastName, dateOfBirth}\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(UpdateProfileCommand(\n GetAuthenticatedUserId(),\n firstName, lastName, dateOfBirth))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs UpdateProfileValidator
|
||||
(same name/DateOfBirth rules as registration)
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request
|
||||
else Validation succeeds
|
||||
|
||||
Mediator -> UpdateProfileHandler: Handle(command)
|
||||
activate UpdateProfileHandler
|
||||
|
||||
UpdateProfileHandler -> UserMgr: FindByIdAsync(userAccountId)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByIdAsync(userAccountId)
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
UserMgr --> UpdateProfileHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account not found
|
||||
UpdateProfileHandler -> Mediator: throw NotFoundException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 404 Not Found
|
||||
else Account found
|
||||
|
||||
UpdateProfileHandler -> UpdateProfileHandler: user.FirstName = ...\nuser.LastName = ...\nuser.DateOfBirth = ...
|
||||
note right of UpdateProfileHandler
|
||||
First/last name and date of birth aren't
|
||||
tracked by any Identity store interface -
|
||||
mutated directly on the ApplicationUser,
|
||||
then saved via UserManager.UpdateAsync.
|
||||
end note
|
||||
|
||||
UpdateProfileHandler -> UserMgr: UpdateAsync(user)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: UpdateAsync(user)
|
||||
UserStore -> DB: UPDATE dbo.UserAccount\nSET FirstName = @FirstName,\n LastName = @LastName,\n DateOfBirth = @DateOfBirth, ...
|
||||
UserStore --> UserMgr: IdentityResult
|
||||
UserMgr --> UpdateProfileHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
UpdateProfileHandler -> Mediator: throw ConflictException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdateProfileHandler --> Mediator: UpdateProfilePayload(\n user.Id, firstName,\n lastName, dateOfBirth)
|
||||
deactivate UpdateProfileHandler
|
||||
Mediator --> API: UpdateProfilePayload
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Profile updated successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
@@ -1,94 +0,0 @@
|
||||
@startuml update-username
|
||||
!include ../../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam sequenceMessageAlign center
|
||||
skinparam maxMessageSize 200
|
||||
|
||||
title User Authentication Flow - Update Username
|
||||
|
||||
actor User
|
||||
participant "API\nController" as API
|
||||
participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
|
||||
box "Handler Layer" #F1F3EA
|
||||
participant "UpdateUsernameHandler" as UpdateUsernameHandler
|
||||
participant "UserManager<ApplicationUser>" as UserMgr
|
||||
end box
|
||||
box "Repository / Cross-slice" #DBEEDD
|
||||
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||
end box
|
||||
database "SQL Server" as DB
|
||||
|
||||
note over UserMgr
|
||||
ASP.NET Core Identity's UserManager<TUser>
|
||||
replaces the deleted AuthRepository: handlers
|
||||
no longer issue SQL or call Argon2 directly,
|
||||
they delegate to UserManager, which in turn
|
||||
calls DapperUserStore (persistence) and
|
||||
Argon2PasswordHasher (IPasswordHasher<TUser>).
|
||||
end note
|
||||
|
||||
User -> API: PATCH /api/auth/username\n{newUsername}\n(requires Bearer access token)
|
||||
activate API
|
||||
|
||||
API -> Mediator: Send(UpdateUsernameCommand(\n GetAuthenticatedUserId(), newUsername))
|
||||
activate Mediator
|
||||
|
||||
note right of Mediator
|
||||
ValidationBehavior runs UpdateUsernameValidator
|
||||
(same format rules as registration)
|
||||
end note
|
||||
|
||||
alt Validation fails
|
||||
Mediator -> API: throws FluentValidation.ValidationException
|
||||
API -> User: 400 Bad Request
|
||||
else Validation succeeds
|
||||
|
||||
Mediator -> UpdateUsernameHandler: Handle(command)
|
||||
activate UpdateUsernameHandler
|
||||
|
||||
UpdateUsernameHandler -> UserMgr: FindByIdAsync(userAccountId)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByIdAsync(userAccountId)
|
||||
activate UserStore
|
||||
UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
|
||||
activate DB
|
||||
DB --> UserStore: row or null
|
||||
deactivate DB
|
||||
UserStore --> UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr --> UpdateUsernameHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt Account not found
|
||||
UpdateUsernameHandler -> Mediator: throw NotFoundException
|
||||
Mediator -> API: propagate
|
||||
API -> User: 404 Not Found
|
||||
else Account found
|
||||
|
||||
UpdateUsernameHandler -> UserMgr: SetUserNameAsync(user, newUsername)
|
||||
activate UserMgr
|
||||
UserMgr -> UserStore: FindByNameAsync(newUsername)
|
||||
note right of UserMgr
|
||||
Uniqueness check before the UPDATE.
|
||||
end note
|
||||
UserMgr -> UserStore: UpdateAsync(user)
|
||||
UserStore -> DB: UPDATE dbo.UserAccount\nSET Username = @UserName, ...
|
||||
UserStore --> UserMgr: IdentityResult
|
||||
UserMgr --> UpdateUsernameHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
UpdateUsernameHandler -> Mediator: throw ConflictException\n(DuplicateUserName code ->\n"Username already exists")
|
||||
Mediator -> API: propagate
|
||||
API -> User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdateUsernameHandler --> Mediator: UpdateUsernamePayload(\n user.Id, user.UserName)
|
||||
deactivate UpdateUsernameHandler
|
||||
Mediator --> API: UpdateUsernamePayload
|
||||
deactivate Mediator
|
||||
API -> User: 200 OK\n{message: "Username updated successfully.",\npayload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
|
||||
@enduml
|
||||
@@ -1,202 +0,0 @@
|
||||
@startuml database-schema
|
||||
!include ../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam linetype ortho
|
||||
|
||||
title Database Schema
|
||||
caption Every table also has a RowVersion ROWVERSION column (optimistic concurrency), omitted below for brevity.\nTables marked (*) have no repository/feature slice yet.
|
||||
|
||||
package "User & Auth" {
|
||||
entity "UserAccount" as User {
|
||||
* UserAccountID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* Username: VARCHAR(64) <<UNIQUE>>
|
||||
* FirstName: NVARCHAR(128)
|
||||
* LastName: NVARCHAR(128)
|
||||
* Email: VARCHAR(128) <<UNIQUE>>
|
||||
* CreatedAt: DATETIME
|
||||
UpdatedAt: DATETIME
|
||||
* DateOfBirth: DATE
|
||||
}
|
||||
|
||||
entity "UserCredential" as Cred {
|
||||
* UserCredentialID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* UserAccountID: UNIQUEIDENTIFIER <<FK>>
|
||||
* CreatedAt: DATETIME
|
||||
* Expiry: DATETIME
|
||||
* Hash: NVARCHAR(256)
|
||||
* IsRevoked: BIT
|
||||
RevokedAt: DATETIME
|
||||
}
|
||||
|
||||
entity "UserVerification" as Verify {
|
||||
* UserVerificationID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* UserAccountID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
|
||||
* VerificationDateTime: DATETIME
|
||||
}
|
||||
|
||||
entity "UserProfile" as Profile {
|
||||
* UserProfileID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* UserAccountID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
|
||||
* Biography: NVARCHAR(MAX)
|
||||
}
|
||||
|
||||
entity "UserAvatar" as Avatar {
|
||||
* UserAvatarID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* UserProfileID: UNIQUEIDENTIFIER <<FK>>
|
||||
* PhotoID: UNIQUEIDENTIFIER <<FK>>
|
||||
* ValidFrom: DATETIME2(3)
|
||||
ValidTo: DATETIME2(3)
|
||||
..
|
||||
At most one row per UserProfileID
|
||||
has ValidTo IS NULL (active avatar)
|
||||
}
|
||||
|
||||
entity "UserFollow (*)" as Follow {
|
||||
* UserFollowID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* UserAccountID: UNIQUEIDENTIFIER <<FK>>
|
||||
* FollowingID: UNIQUEIDENTIFIER <<FK>>
|
||||
* CreatedAt: DATETIME
|
||||
}
|
||||
|
||||
entity "Photo (*)" as Photo {
|
||||
* PhotoID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
Hyperlink: NVARCHAR(256)
|
||||
* UploadedByID: UNIQUEIDENTIFIER <<FK>>
|
||||
* UploadedAt: DATETIME
|
||||
}
|
||||
}
|
||||
|
||||
package "Location" {
|
||||
entity "Country" as Country {
|
||||
* CountryID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* CountryName: NVARCHAR(100)
|
||||
* ISO3166_1: CHAR(2) <<UNIQUE>>
|
||||
}
|
||||
|
||||
entity "StateProvince" as StateProvince {
|
||||
* StateProvinceID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* StateProvinceName: NVARCHAR(100)
|
||||
* ISO3166_2: CHAR(6) <<UNIQUE>>
|
||||
* CountryID: UNIQUEIDENTIFIER <<FK>>
|
||||
}
|
||||
|
||||
entity "City" as City {
|
||||
* CityID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* CityName: NVARCHAR(100)
|
||||
* StateProvinceID: UNIQUEIDENTIFIER <<FK>>
|
||||
}
|
||||
}
|
||||
|
||||
package "Brewery" {
|
||||
entity "BreweryPost" as Brewery {
|
||||
* BreweryPostID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* BreweryName: NVARCHAR(256)
|
||||
* PostedByID: UNIQUEIDENTIFIER <<FK>>
|
||||
* Description: NVARCHAR(512)
|
||||
* CreatedAt: DATETIME
|
||||
UpdatedAt: DATETIME
|
||||
}
|
||||
|
||||
entity "BreweryPostLocation" as BreweryLoc {
|
||||
* BreweryPostLocationID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* BreweryPostID: UNIQUEIDENTIFIER <<FK, UNIQUE>>
|
||||
* AddressLine1: NVARCHAR(256)
|
||||
AddressLine2: NVARCHAR(256)
|
||||
* PostalCode: NVARCHAR(20)
|
||||
* CityID: UNIQUEIDENTIFIER <<FK>>
|
||||
Coordinates: GEOGRAPHY
|
||||
}
|
||||
|
||||
entity "BreweryPostPhoto (*)" as BreweryPhoto {
|
||||
* BreweryPostPhotoID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* BreweryPostID: UNIQUEIDENTIFIER <<FK>>
|
||||
* PhotoID: UNIQUEIDENTIFIER <<FK>>
|
||||
* LinkedAt: DATETIME
|
||||
}
|
||||
}
|
||||
|
||||
package "Beer (*)" {
|
||||
entity "BeerStyle (*)" as BeerStyle {
|
||||
* BeerStyleID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* StyleName: NVARCHAR(100) <<UNIQUE>>
|
||||
Description: NVARCHAR(MAX)
|
||||
}
|
||||
|
||||
entity "BeerPost (*)" as Beer {
|
||||
* BeerPostID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* Name: NVARCHAR(100)
|
||||
* Description: NVARCHAR(MAX)
|
||||
* ABV: DECIMAL(4,2)
|
||||
* IBU: INT
|
||||
* PostedByID: UNIQUEIDENTIFIER <<FK>>
|
||||
* BeerStyleID: UNIQUEIDENTIFIER <<FK>>
|
||||
* BrewedByID: UNIQUEIDENTIFIER <<FK>>
|
||||
* CreatedAt: DATETIME
|
||||
UpdatedAt: DATETIME
|
||||
}
|
||||
|
||||
entity "BeerPostPhoto (*)" as BeerPhoto {
|
||||
* BeerPostPhotoID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* BeerPostID: UNIQUEIDENTIFIER <<FK>>
|
||||
* PhotoID: UNIQUEIDENTIFIER <<FK>>
|
||||
* LinkedAt: DATETIME
|
||||
}
|
||||
|
||||
entity "BeerPostComment (*)" as BeerComment {
|
||||
* BeerPostCommentID: UNIQUEIDENTIFIER <<PK>>
|
||||
--
|
||||
* Comment: NVARCHAR(250)
|
||||
* BeerPostID: UNIQUEIDENTIFIER <<FK>>
|
||||
* CommentedByID: UNIQUEIDENTIFIER <<FK>>
|
||||
* Rating: INT
|
||||
* CreatedAt: DATETIME
|
||||
UpdatedAt: DATETIME
|
||||
}
|
||||
}
|
||||
|
||||
' Relationships: User & Auth
|
||||
User ||--o{ Cred
|
||||
User ||--o| Verify
|
||||
User ||--o| Profile
|
||||
Profile ||--o{ Avatar
|
||||
Avatar }o--|| Photo
|
||||
User ||--o{ Follow
|
||||
User ||--o{ Follow
|
||||
User ||--o{ Photo
|
||||
|
||||
' Relationships: Location
|
||||
Country ||--o{ StateProvince
|
||||
StateProvince ||--o{ City
|
||||
|
||||
' Relationships: Brewery
|
||||
User ||--o{ Brewery
|
||||
Brewery ||--o| BreweryLoc
|
||||
City ||--o{ BreweryLoc
|
||||
Brewery ||--o{ BreweryPhoto
|
||||
Photo ||--o{ BreweryPhoto
|
||||
|
||||
' Relationships: Beer
|
||||
User ||--o{ Beer
|
||||
BeerStyle ||--o{ Beer
|
||||
Brewery ||--o{ Beer
|
||||
Beer ||--o{ BeerPhoto
|
||||
Photo ||--o{ BeerPhoto
|
||||
Beer ||--o{ BeerComment
|
||||
User ||--o{ BeerComment
|
||||
|
||||
@enduml
|
||||
@@ -1,330 +0,0 @@
|
||||
@startuml deployment
|
||||
!include ../../pipeline/diagrams/biergarten-weizen-theme.puml
|
||||
skinparam linetype ortho
|
||||
|
||||
title Docker Deployment Architecture
|
||||
|
||||
' External systems
|
||||
actor Developer
|
||||
cloud "Docker Host" as Host
|
||||
|
||||
package "Development Environment\n(docker-compose.dev.yaml)" #F1F3EA {
|
||||
|
||||
node "SQL Server\n(mcr.microsoft.com/mssql/server:2022-latest)" as DevDB {
|
||||
database "Biergarten\nDatabase" as DevDBInner {
|
||||
portin "1433"
|
||||
}
|
||||
note right
|
||||
Environment:
|
||||
- ACCEPT_EULA=Y
|
||||
- SA_PASSWORD=***
|
||||
- MSSQL_PID=Developer
|
||||
|
||||
Volumes:
|
||||
- biergarten-dev-data
|
||||
end note
|
||||
}
|
||||
|
||||
node "API Container\n(API.Core)" as DevAPI {
|
||||
component "ASP.NET Core 10" as API1
|
||||
portin "8080:8080 (HTTP)" as DevPort1
|
||||
portin "8081:8081 (HTTPS)" as DevPort2
|
||||
|
||||
note right
|
||||
Environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Development
|
||||
- DB_SERVER=sql-server
|
||||
- DB_NAME=Biergarten
|
||||
- DB_USER/PASSWORD
|
||||
- JWT_SECRET
|
||||
- SMTP_* (10+ variables)
|
||||
|
||||
Health Check:
|
||||
/health endpoint
|
||||
end note
|
||||
}
|
||||
|
||||
node "Migrations\n(run-once)" as DevMig {
|
||||
component "Database.Migrations" as Mig1
|
||||
note bottom
|
||||
Runs: DbUp migrations
|
||||
Environment:
|
||||
- CLEAR_DATABASE=false
|
||||
Depends on: sql-server
|
||||
end note
|
||||
}
|
||||
|
||||
node "Seed\n(run-once)" as DevSeed {
|
||||
component "Database.Seed" as Seed1
|
||||
note bottom
|
||||
Creates:
|
||||
- 100 test users
|
||||
- Location data (US/CA/MX)
|
||||
- test.user account
|
||||
Depends on: migrations
|
||||
end note
|
||||
}
|
||||
|
||||
node "Mailpit\n(axllent/mailpit)" as DevMailpit {
|
||||
portin "8025 (Web UI)" as DevMailpitWeb
|
||||
portin "1025 (SMTP)" as DevMailpitSmtp
|
||||
note right
|
||||
Local SMTP catcher for dev email testing.
|
||||
Environment:
|
||||
- MP_SMTP_AUTH_ACCEPT_ANY=1
|
||||
- MP_SMTP_AUTH_ALLOW_INSECURE=1
|
||||
end note
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
package "Test Environment\n(docker-compose.test.yaml)" #EBECE3 {
|
||||
|
||||
node "SQL Server\n(isolated instance)" as TestDB {
|
||||
database "Biergarten\nTest Database" as TestDBInner {
|
||||
portin "1434"
|
||||
}
|
||||
note right
|
||||
Fresh instance each run
|
||||
CLEAR_DATABASE=true
|
||||
|
||||
Volumes:
|
||||
- biergarten-test-data
|
||||
(ephemeral)
|
||||
end note
|
||||
}
|
||||
|
||||
node "Migrations\n(test)" as TestMig {
|
||||
component "Database.Migrations"
|
||||
}
|
||||
|
||||
node "Seed\n(test)" as TestSeed {
|
||||
component "Database.Seed"
|
||||
note bottom
|
||||
Minimal seed:
|
||||
- test.user only
|
||||
- Essential data
|
||||
end note
|
||||
}
|
||||
|
||||
node "API.Specs\n(Integration Tests)" as Specs {
|
||||
component "Reqnroll + xUnit" as SpecsComp
|
||||
note right
|
||||
Tests:
|
||||
- Registration flow
|
||||
- Login flow
|
||||
- Validation rules
|
||||
- 404 handling
|
||||
|
||||
Uses: TestApiFactory
|
||||
Mocks: Email services
|
||||
end note
|
||||
}
|
||||
|
||||
node "unit.tests\n(Features.*.Tests, one container)" as UnitTests {
|
||||
component "xUnit + Moq" as UnitComp
|
||||
note right
|
||||
Builds Dockerfile.tests, which
|
||||
restores/builds against Core.slnx
|
||||
then runs `dotnet test` for every
|
||||
Features/*.Tests project in turn:
|
||||
- Features.Users.Tests
|
||||
- Features.Breweries.Tests
|
||||
- Features.Emails.Tests
|
||||
|
||||
Uses: Moq to mock each handler's
|
||||
repository/service dependencies
|
||||
No real database needed
|
||||
end note
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
package "Production Environment\n(docker-compose.prod.yaml)" #CBD2B5 {
|
||||
|
||||
node "SQL Server\n(mcr.microsoft.com/mssql/server:2022-latest)" as ProdDB {
|
||||
database "Biergarten\nDatabase" as ProdDBInner
|
||||
note right
|
||||
Environment:
|
||||
- ACCEPT_EULA=Y
|
||||
- SA_PASSWORD=***
|
||||
- MSSQL_PID=Express
|
||||
|
||||
Volumes:
|
||||
- sqlserverdata-prod
|
||||
No host port published.
|
||||
end note
|
||||
}
|
||||
|
||||
node "Migrations\n(run-once)" as ProdMig {
|
||||
component "Database.Migrations" as ProdMig1
|
||||
note bottom
|
||||
Depends on: sql-server (healthy)
|
||||
No seed step in production.
|
||||
end note
|
||||
}
|
||||
|
||||
node "API Container\n(API.Core)" as ProdAPI {
|
||||
component "ASP.NET Core 10" as ProdAPI1
|
||||
portin "8080:8080 (HTTP)" as ProdPort1
|
||||
portin "8081:8081 (HTTPS)" as ProdPort2
|
||||
note right
|
||||
Environment:
|
||||
- ASPNETCORE_ENVIRONMENT=Production
|
||||
- DB_SERVER/NAME/USER/PASSWORD
|
||||
- ACCESS/REFRESH/CONFIRMATION_TOKEN_SECRET
|
||||
- WEBSITE_BASE_URL
|
||||
|
||||
Depends on: sql-server (healthy) -
|
||||
does not wait on migrations/seed.
|
||||
end note
|
||||
}
|
||||
|
||||
ProdMig --> ProdDB : 1. Run migrations
|
||||
ProdAPI --> ProdDB : 2. Connect & serve
|
||||
ProdMig .up.> ProdDB : depends_on
|
||||
ProdAPI .up.> ProdDB : depends_on
|
||||
|
||||
note bottom of ProdDB
|
||||
<b>Prod Network (bridge: prodnet)</b>
|
||||
Isolated from dev/test networks.
|
||||
Volumes: sqlserverdata-prod,
|
||||
nuget-cache-prod
|
||||
end note
|
||||
}
|
||||
|
||||
package "Database-only Environment\n(docker-compose.db.yaml)" #E4E6D8 {
|
||||
|
||||
node "SQL Server" as DbOnlyDB {
|
||||
database "Biergarten\nDatabase" as DbOnlyDBInner {
|
||||
portin "1434"
|
||||
}
|
||||
}
|
||||
|
||||
node "Migrations\n(run-once)" as DbOnlyMig {
|
||||
component "Database.Migrations"
|
||||
}
|
||||
|
||||
node "Seed\n(run-once)" as DbOnlySeed {
|
||||
component "Database.Seed"
|
||||
}
|
||||
|
||||
note bottom of DbOnlyDB
|
||||
Same devnet volumes/images as
|
||||
docker-compose.dev.yaml, minus
|
||||
api.core and mailpit - for working
|
||||
against the database without
|
||||
running the API.
|
||||
end note
|
||||
|
||||
DbOnlyMig --> DbOnlyDB : 1. Run migrations
|
||||
DbOnlySeed --> DbOnlyDB : 2. Seed data
|
||||
DbOnlyMig .up.> DbOnlyDB : depends_on
|
||||
DbOnlySeed .up.> DbOnlyMig : depends_on
|
||||
}
|
||||
|
||||
package "Minimal Environment\n(docker-compose.min.yaml)" #DBEEDD {
|
||||
|
||||
node "SQL Server" as MinDB {
|
||||
database "Biergarten\nDatabase" as MinDBInner {
|
||||
portin "1433"
|
||||
}
|
||||
}
|
||||
|
||||
node "Migrations\n(run-once)" as MinMig {
|
||||
component "Database.Migrations"
|
||||
}
|
||||
|
||||
node "Mailpit" as MinMailpit {
|
||||
portin "8025 / 1025"
|
||||
}
|
||||
|
||||
node "Seed\n(run-once)" as MinSeed {
|
||||
component "Database.Seed"
|
||||
}
|
||||
|
||||
note bottom of MinDB
|
||||
Database + mailpit only, no API
|
||||
container - for frontend development
|
||||
against an API.Core process run
|
||||
outside Docker.
|
||||
end note
|
||||
|
||||
MinMig --> MinDB : 1. Run migrations
|
||||
MinSeed --> MinDB : 2. Seed data
|
||||
MinMig .up.> MinDB : depends_on
|
||||
MinSeed .up.> MinMig : depends_on
|
||||
}
|
||||
|
||||
folder "test-results/\n(mounted volume)" as Results {
|
||||
file "api-specs/\n results.trx" as Result1
|
||||
file "Features.Users.Tests.trx" as Result2
|
||||
file "Features.Breweries.Tests.trx" as Result3
|
||||
file "Features.Emails.Tests.trx" as Result5
|
||||
|
||||
note bottom
|
||||
TRX format
|
||||
Readable by:
|
||||
- Visual Studio
|
||||
- Azure DevOps
|
||||
- GitHub Actions
|
||||
end note
|
||||
}
|
||||
|
||||
' External access
|
||||
Developer --> Host : docker compose up
|
||||
Host --> DevAPI : http://localhost:8080
|
||||
|
||||
' Development dependencies
|
||||
DevMig --> DevDB : 1. Run migrations
|
||||
DevSeed --> DevDB : 2. Seed data
|
||||
DevAPI --> DevDB : 3. Connect & serve
|
||||
DevAPI ..> DevMailpit : 4. SMTP_HOST/PORT
|
||||
DevMig .up.> DevDB : depends_on
|
||||
DevSeed .up.> DevMig : depends_on
|
||||
DevAPI .up.> DevSeed : depends_on
|
||||
|
||||
' Test dependencies
|
||||
TestMig --> TestDB : 1. Migrate
|
||||
TestSeed --> TestDB : 2. Seed
|
||||
Specs --> TestDB : 3. Integration test
|
||||
UnitTests ..> TestDB : depends_on for startup\nordering only (Moq mocks,\nno real connection)
|
||||
|
||||
TestMig .up.> TestDB : depends_on
|
||||
TestSeed .up.> TestMig : depends_on
|
||||
Specs .up.> TestSeed : depends_on
|
||||
UnitTests .up.> TestSeed : depends_on
|
||||
|
||||
' Test results export
|
||||
Specs --> Results : Export TRX
|
||||
UnitTests --> Results : Export TRX (one .trx per slice)
|
||||
|
||||
' Network notes
|
||||
note bottom of DevDB
|
||||
<b>Dev Network (bridge: biergarten-dev)</b>
|
||||
Internal DNS:
|
||||
- sql-server (resolves to SQL container)
|
||||
- api (resolves to API container)
|
||||
end note
|
||||
|
||||
note bottom of TestDB
|
||||
<b>Test Network (bridge: biergarten-test)</b>
|
||||
All test components isolated
|
||||
end note
|
||||
|
||||
' Startup sequence notes
|
||||
note top of DevMig
|
||||
Startup Order:
|
||||
1. SQL Server (health check)
|
||||
2. Migrations (run-once)
|
||||
3. Seed (run-once)
|
||||
4. API (long-running)
|
||||
end note
|
||||
|
||||
note top of Specs
|
||||
Test Execution:
|
||||
All tests run in parallel
|
||||
Results aggregated
|
||||
end note
|
||||
|
||||
@enduml
|
||||
Reference in New Issue
Block a user