mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 18:14:44 +00:00
extract mermaid docs
This commit is contained in:
59
docs/diagrams/website/architecture/host-wiring.mmd
Normal file
59
docs/diagrams/website/architecture/host-wiring.mmd
Normal file
@@ -0,0 +1,59 @@
|
||||
---
|
||||
|
||||
config:
|
||||
|
||||
flowchart:
|
||||
|
||||
curve: stepAfter
|
||||
|
||||
layout: elk
|
||||
|
||||
---
|
||||
|
||||
flowchart TB
|
||||
|
||||
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"]
|
||||
|
||||
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
|
||||
77
docs/diagrams/website/architecture/project-references.mmd
Normal file
77
docs/diagrams/website/architecture/project-references.mmd
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
|
||||
config:
|
||||
|
||||
flowchart: {}
|
||||
|
||||
layout: fixed
|
||||
|
||||
---
|
||||
|
||||
flowchart TB
|
||||
|
||||
subgraph SLICES["Feature Slices"]
|
||||
|
||||
direction LR
|
||||
|
||||
BrewerySlice["Features.Breweries"]
|
||||
|
||||
LocationsSlice["Features.Locations"]
|
||||
|
||||
BeersSlice["Features.Beers<br>(scaffolded)"]
|
||||
|
||||
PhotoSlice["Features.PhotoUpload"]
|
||||
|
||||
UsersSlice["Features.Users"]
|
||||
|
||||
EmailsSlice["Features.Emails"]
|
||||
|
||||
end
|
||||
|
||||
subgraph FOUNDATION["Foundation (every DB-backed slice depends on all three)"]
|
||||
|
||||
direction LR
|
||||
|
||||
SharedContracts["Shared.Contracts"]
|
||||
|
||||
Domain["Domain.Entities"]
|
||||
|
||||
DBConn["Database.Connection"]
|
||||
|
||||
end
|
||||
|
||||
subgraph INFRA["Slice-specific Infrastructure"]
|
||||
|
||||
direction LR
|
||||
|
||||
JWT["Infrastructure.Jwt"]
|
||||
|
||||
PwdHash["Infrastructure.PasswordHashing"]
|
||||
|
||||
FileUpload["Infrastructure.FileUpload"]
|
||||
|
||||
Email["Infrastructure.Email"]
|
||||
|
||||
EmailTemplates["Infrastructure.Email.Templates"]
|
||||
|
||||
end
|
||||
|
||||
UsersSlice == project ref, sends UploadPhotoCommand ==> PhotoSlice
|
||||
|
||||
UsersSlice -. sends email commands .-> SharedApp["Shared.Application<br>(email command contract)"]
|
||||
|
||||
EmailsSlice -. handles email commands .-> SharedApp
|
||||
|
||||
BrewerySlice --> FOUNDATION
|
||||
|
||||
LocationsSlice --> FOUNDATION
|
||||
|
||||
BeersSlice --> FOUNDATION
|
||||
|
||||
PhotoSlice --> FOUNDATION & FileUpload
|
||||
|
||||
UsersSlice --> FOUNDATION & JWT & PwdHash
|
||||
|
||||
EmailsSlice --> Email & EmailTemplates
|
||||
|
||||
DBConn -- Dapper SQL, no ORM --> Tables[("SQL Server<br>Tables")]
|
||||
81
docs/diagrams/website/auth/confirm-user.mmd
Normal file
81
docs/diagrams/website/auth/confirm-user.mmd
Normal file
@@ -0,0 +1,81 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant ConfirmHandler as ConfirmUserHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box rgb(235,236,227) Infrastructure Layer
|
||||
participant JWT as JWT Infrastructure
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: POST /api/auth/confirm?token={token} (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 token 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 ... FROM UserAccount WHERE 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 "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
|
||||
|
||||
ConfirmHandler->>UserMgr: UpdateAsync(user)
|
||||
activate UserMgr
|
||||
UserMgr->>UserStore: UpdateAsync(user)
|
||||
activate UserStore
|
||||
UserStore->>DB: UPDATE UserAccount SET ...
|
||||
UserStore->>DB: INSERT INTO UserVerification (UserAccountId, VerificationDateTime)
|
||||
Note right of UserStore: Idempotent — skipped (duplicate-key exception swallowed) if a verification row already exists
|
||||
UserStore-->>UserMgr: IdentityResult.Success
|
||||
deactivate UserStore
|
||||
UserMgr-->>ConfirmHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
ConfirmHandler-->>Mediator: ConfirmationPayload(user.Id, DateTime.UtcNow)
|
||||
deactivate ConfirmHandler
|
||||
Mediator-->>API: ConfirmationPayload
|
||||
deactivate Mediator
|
||||
API->>User: 200 OK {message: "User with ID {id} is confirmed.", payload: {...}}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
67
docs/diagrams/website/auth/delete-account.mmd
Normal file
67
docs/diagrams/website/auth/delete-account.mmd
Normal file
@@ -0,0 +1,67 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant DeleteAccountHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: DELETE /api/auth/account (requires Bearer access token)
|
||||
activate API
|
||||
API->>Mediator: Send(DeleteAccountCommand(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 UserAccount WHERE 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)
|
||||
|
||||
alt Foreign key violation (dependent records exist)
|
||||
UserStore-->>UserMgr: IdentityResult.Failed (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 "Account cannot be deleted while it still has associated posts, comments, photos, or 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 {message: "Account deleted successfully."}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
12
docs/diagrams/website/auth/error-handling.mmd
Normal file
12
docs/diagrams/website/auth/error-handling.mmd
Normal file
@@ -0,0 +1,12 @@
|
||||
sequenceDiagram
|
||||
participant API as API Controller
|
||||
|
||||
Note over API: GlobalExceptionFilter catches:
|
||||
Note over API: FluentValidation.ValidationException → 400 Bad Request {message, errors} (grouped by property)
|
||||
Note over API: ConflictException → 409 Conflict
|
||||
Note over API: NotFoundException → 404 Not Found
|
||||
Note over API: UnauthorizedException → 401 Unauthorized
|
||||
Note over API: ForbiddenException → 403 Forbidden
|
||||
Note over API: Domain.Exceptions.ValidationException → 400 Bad Request
|
||||
Note over API: Microsoft.Data.SqlClient.SqlException → 503 Service Unavailable
|
||||
Note over API: All others → 500 Internal Server Error
|
||||
95
docs/diagrams/website/auth/login.mmd
Normal file
95
docs/diagrams/website/auth/login.mmd
Normal file
@@ -0,0 +1,95 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant LoginHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box rgb(235,236,227) Infrastructure Layer
|
||||
participant Argon2 as Argon2PasswordHasher
|
||||
participant JWT as JWT Infrastructure
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: POST /api/auth/login {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
|
||||
|
||||
alt Validation fails
|
||||
Mediator->>API: throws FluentValidation.ValidationException
|
||||
API->>User: 400 Bad Request {message, 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 ... FROM UserAccount WHERE Username = @Username
|
||||
activate DB
|
||||
DB-->>UserStore: UserAccount row or null
|
||||
deactivate DB
|
||||
UserStore->>UserStore: Hydrate (active UserCredential.Hash, UserVerification → EmailConfirmed)
|
||||
UserStore-->>UserMgr: ApplicationUser or null
|
||||
deactivate UserStore
|
||||
UserMgr-->>LoginHandler: ApplicationUser or null
|
||||
deactivate UserMgr
|
||||
|
||||
alt User not found
|
||||
LoginHandler->>Mediator: throw UnauthorizedException "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 credential hydrated) without calling the hasher — otherwise delegates to Argon2PasswordHasher
|
||||
UserMgr->>Argon2: VerifyHashedPassword(user, user.PasswordHash, password)
|
||||
activate Argon2
|
||||
Note right of Argon2: Split stored "salt:hash", rehash password with same salt, constant-time compare (CryptographicOperations.FixedTimeEquals)
|
||||
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(userAccountId, username, refreshToken, accessToken)
|
||||
deactivate LoginHandler
|
||||
Mediator-->>API: LoginPayload
|
||||
deactivate Mediator
|
||||
API->>User: 200 OK {message: "Logged in successfully.", payload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
79
docs/diagrams/website/auth/refresh-token.mmd
Normal file
79
docs/diagrams/website/auth/refresh-token.mmd
Normal file
@@ -0,0 +1,79 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant RefreshHandler as RefreshTokenHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box rgb(235,236,227) Infrastructure Layer
|
||||
participant JWT as JWT Infrastructure
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: POST /api/auth/refresh<br/>Header: 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.
|
||||
|
||||
alt Validation fails
|
||||
Mediator->>API: throws FluentValidation.ValidationException
|
||||
API->>User: 400 Bad Request {message, 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
|
||||
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 ... FROM UserAccount WHERE 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 "User account not found"
|
||||
RefreshHandler->>Mediator: propagate
|
||||
Mediator->>API: propagate
|
||||
API->>User: 401 Unauthorized
|
||||
else User found
|
||||
TokenSvc->>JWT: GenerateAccessToken(user) + GenerateRefreshToken(user)
|
||||
JWT-->>TokenSvc: new access + refresh tokens
|
||||
TokenSvc-->>RefreshHandler: RefreshTokenResult
|
||||
deactivate TokenSvc
|
||||
|
||||
RefreshHandler-->>Mediator: LoginPayload(userId, username, refreshToken, accessToken)
|
||||
deactivate RefreshHandler
|
||||
Mediator-->>API: LoginPayload
|
||||
deactivate Mediator
|
||||
API->>User: 200 OK {message: "Token refreshed successfully.", payload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
153
docs/diagrams/website/auth/registration.mmd
Normal file
153
docs/diagrams/website/auth/registration.mmd
Normal file
@@ -0,0 +1,153 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant RegHandler as RegisterUserHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box rgb(235,236,227) Infrastructure Layer
|
||||
participant Argon2 as Argon2PasswordHasher
|
||||
participant JWT as JWT Infrastructure
|
||||
participant SMTP as Email Provider
|
||||
participant Template as Template Provider
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: POST /api/auth/register {username, firstName, lastName, email, dateOfBirth, password}
|
||||
activate API
|
||||
API->>Mediator: Send(RegisterUserCommand)
|
||||
activate Mediator
|
||||
Note right of Mediator: ValidationBehavior runs RegisterUserValidator — username 3-64 chars alphanumeric+[._-], valid email ≤128 chars, password min 8 chars with upper/lower/number/special, DateOfBirth 19+ years old
|
||||
|
||||
alt Validation fails
|
||||
Mediator->>API: throws FluentValidation.ValidationException
|
||||
API->>User: 400 Bad Request {message, errors}
|
||||
else Validation succeeds
|
||||
Mediator->>RegHandler: Handle(command)
|
||||
activate RegHandler
|
||||
RegHandler->>RegHandler: user = new UserAccount{...}.ToApplicationUser()
|
||||
|
||||
RegHandler->>UserMgr: CreateAsync(user, password)
|
||||
activate UserMgr
|
||||
UserMgr->>Argon2: HashPassword(user, password)
|
||||
activate Argon2
|
||||
Note right of Argon2: Argon2id — salt 16 bytes, memory 64MB, iterations 4, parallelism = CPU count, hash output 32 bytes
|
||||
Argon2-->>UserMgr: "base64(salt):base64(hash)"
|
||||
deactivate Argon2
|
||||
|
||||
UserMgr->>UserStore: SetPasswordHashAsync(user, hash)
|
||||
Note right of UserStore: user.Id is still empty here — only sets the in-memory hash — CreateAsync writes it to UserCredential itself
|
||||
|
||||
UserMgr->>UserStore: FindByNameAsync(username)
|
||||
activate UserStore
|
||||
UserStore->>DB: SELECT ... FROM UserAccount WHERE Username = @Username
|
||||
activate DB
|
||||
DB-->>UserStore: null (user doesn't exist)
|
||||
deactivate DB
|
||||
deactivate UserStore
|
||||
|
||||
UserMgr->>UserStore: FindByEmailAsync(email)
|
||||
activate UserStore
|
||||
UserStore->>DB: SELECT ... FROM UserAccount WHERE 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 — DapperUserStore.CreateAsync also guards a genuine race via a caught duplicate-key SqlException (2601/2627)
|
||||
|
||||
UserMgr->>UserStore: CreateAsync(user)
|
||||
activate UserStore
|
||||
UserStore->>DB: BEGIN TRANSACTION
|
||||
activate DB
|
||||
UserStore->>DB: INSERT INTO UserAccount (...) OUTPUT INSERTED.UserAccountID
|
||||
DB-->>UserStore: UserAccountId (GUID)
|
||||
UserStore->>DB: INSERT INTO UserCredential (UserAccountId, Hash)
|
||||
UserStore->>DB: COMMIT TRANSACTION
|
||||
deactivate DB
|
||||
Note right of UserStore: Both statements run via Dapper against a single DbTransaction, not a stored procedure
|
||||
|
||||
UserStore-->>UserMgr: IdentityResult.Success (user.Id set)
|
||||
deactivate UserStore
|
||||
UserMgr-->>RegHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
RegHandler->>Mediator: throw ConflictException (DuplicateUserName/DuplicateEmail → "Username or email already exists", otherwise joined error descriptions)
|
||||
Mediator->>API: propagate
|
||||
API->>User: 409 Conflict "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: HS256, expires 1 hour, claims sub/unique_name/iat/jti
|
||||
JWT->>JWT: Create JWT with claims, 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: Expires 21 days (504h), same structure as access token, signed with a separate secret
|
||||
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: Expires 30 minutes, signed with a third, separate secret
|
||||
JWT-->>TokenSvc: Confirmation Token
|
||||
deactivate JWT
|
||||
TokenSvc-->>RegHandler: Confirmation Token
|
||||
deactivate TokenSvc
|
||||
|
||||
RegHandler->>Mediator: Send(SendRegistrationEmailCommand) (Shared.Application cross-slice contract)
|
||||
activate Mediator
|
||||
Note right of Mediator: Routed to Features.Emails' SendRegistrationEmailHandler — the only cross-slice call in the backend
|
||||
Mediator->>EmailDispatcher: SendRegistrationEmailAsync(firstName, email, confirmationToken)
|
||||
activate EmailDispatcher
|
||||
|
||||
EmailDispatcher->>Template: RenderUserRegisteredEmailAsync(firstName, confirmationLink)
|
||||
activate Template
|
||||
Note right of Template: Razor component — header/branding, welcome message, confirmation button, footer
|
||||
Template->>Template: Render Razor component to HTML
|
||||
Template-->>EmailDispatcher: HTML email content
|
||||
deactivate Template
|
||||
|
||||
EmailDispatcher->>SMTP: SendAsync(email, subject, body)
|
||||
activate SMTP
|
||||
Note right of SMTP: MailKit — host/port from SMTP_HOST/SMTP_PORT, StartTLS when SMTP_USE_SSL=true, optional username/password auth
|
||||
SMTP->>SMTP: Build MIME message, connect, authenticate, send, 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), log to stderr, emailSent = false
|
||||
end
|
||||
|
||||
RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken, confirmationEmailSent)
|
||||
deactivate RegHandler
|
||||
Mediator-->>API: RegistrationPayload
|
||||
deactivate Mediator
|
||||
API->>User: 201 Created {message: "User registered successfully.", payload: {...}}
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
73
docs/diagrams/website/auth/resend-confirmation.mmd
Normal file
73
docs/diagrams/website/auth/resend-confirmation.mmd
Normal file
@@ -0,0 +1,73 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant ResendHandler as ResendConfirmationEmailHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box rgb(235,236,227) Infrastructure Layer
|
||||
participant SMTP as Email Provider
|
||||
participant Template as Template Provider
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: POST /api/auth/confirm/resend?userId={id} (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 ... FROM UserAccount WHERE 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 (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(firstName, email, confirmationToken)
|
||||
activate EmailDispatcher
|
||||
EmailDispatcher->>Template: Render email (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 {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
|
||||
deactivate API
|
||||
66
docs/diagrams/website/auth/update-email.mmd
Normal file
66
docs/diagrams/website/auth/update-email.mmd
Normal file
@@ -0,0 +1,66 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant UpdateEmailHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: PATCH /api/auth/email {newEmail} (requires Bearer access token)
|
||||
activate API
|
||||
API->>Mediator: Send(UpdateEmailCommand(GetAuthenticatedUserId(), newEmail))
|
||||
activate Mediator
|
||||
Note right of Mediator: ValidationBehavior runs UpdateEmailValidator (same format rules as registration)
|
||||
|
||||
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 — caller must re-confirm via POST /api/auth/confirm/resend
|
||||
UserMgr->>UserStore: UpdateAsync(user)
|
||||
UserStore->>DB: UPDATE UserAccount SET Email = @Email, ...
|
||||
UserStore->>DB: DELETE FROM UserVerification WHERE UserAccountID = @Id
|
||||
UserStore-->>UserMgr: IdentityResult
|
||||
UserMgr-->>UpdateEmailHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
UpdateEmailHandler->>Mediator: throw ConflictException (DuplicateEmail → "Email address already exists")
|
||||
Mediator->>API: propagate
|
||||
API->>User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdateEmailHandler-->>Mediator: UpdateEmailPayload(user.Id, user.Email, user.EmailConfirmed = false)
|
||||
deactivate UpdateEmailHandler
|
||||
Mediator-->>API: UpdateEmailPayload
|
||||
deactivate Mediator
|
||||
API->>User: 200 OK {message: "Email updated successfully.", payload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
86
docs/diagrams/website/auth/update-password.mmd
Normal file
86
docs/diagrams/website/auth/update-password.mmd
Normal file
@@ -0,0 +1,86 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant UpdatePasswordHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box rgb(235,236,227) Infrastructure Layer
|
||||
participant Argon2 as Argon2PasswordHasher
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: PATCH /api/auth/password {currentPassword, newPassword} (requires Bearer access token)
|
||||
activate API
|
||||
API->>Mediator: Send(UpdatePasswordCommand(GetAuthenticatedUserId(), currentPassword, newPassword))
|
||||
activate Mediator
|
||||
Note right of Mediator: ValidationBehavior runs UpdatePasswordValidator (same password strength rules as registration)
|
||||
|
||||
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(user, currentPassword, newPassword)
|
||||
activate UserMgr
|
||||
UserMgr->>Argon2: VerifyHashedPassword(user, user.PasswordHash, currentPassword)
|
||||
activate Argon2
|
||||
Argon2-->>UserMgr: Success/Failed
|
||||
deactivate Argon2
|
||||
|
||||
alt Current password mismatch
|
||||
UserMgr-->>UpdatePasswordHandler: IdentityResult.Failed (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 UserCredential SET IsRevoked = 1, RevokedAt = GETDATE() WHERE UserAccountId = @Id AND IsRevoked = 0
|
||||
UserStore->>DB: INSERT INTO UserCredential (UserAccountId, Hash)
|
||||
Note right of UserStore: Revoke-then-insert runs inside one DbTransaction (credential rotation)
|
||||
UserStore-->>UserMgr: (void)
|
||||
deactivate UserStore
|
||||
UserMgr-->>UpdatePasswordHandler: IdentityResult.Success
|
||||
end
|
||||
deactivate UserMgr
|
||||
|
||||
alt Current password is incorrect
|
||||
UpdatePasswordHandler->>Mediator: throw UnauthorizedException "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(user.Id, DateTime.UtcNow)
|
||||
deactivate UpdatePasswordHandler
|
||||
Mediator-->>API: UpdatePasswordPayload
|
||||
deactivate Mediator
|
||||
API->>User: 200 OK {message: "Password updated successfully.", payload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
65
docs/diagrams/website/auth/update-profile.mmd
Normal file
65
docs/diagrams/website/auth/update-profile.mmd
Normal file
@@ -0,0 +1,65 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant UpdateProfileHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: PATCH /api/auth/profile {firstName, lastName, dateOfBirth} (requires Bearer access token)
|
||||
activate API
|
||||
API->>Mediator: Send(UpdateProfileCommand(GetAuthenticatedUserId(), firstName, lastName, dateOfBirth))
|
||||
activate Mediator
|
||||
Note right of Mediator: ValidationBehavior runs UpdateProfileValidator (same name/DateOfBirth rules as registration)
|
||||
|
||||
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 = ...<br/>user.LastName = ...<br/>user.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
|
||||
|
||||
UpdateProfileHandler->>UserMgr: UpdateAsync(user)
|
||||
activate UserMgr
|
||||
UserMgr->>UserStore: UpdateAsync(user)
|
||||
UserStore->>DB: UPDATE UserAccount SET FirstName = @FirstName, LastName = @LastName, 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(user.Id, firstName, lastName, dateOfBirth)
|
||||
deactivate UpdateProfileHandler
|
||||
Mediator-->>API: UpdateProfilePayload
|
||||
deactivate Mediator
|
||||
API->>User: 200 OK {message: "Profile updated successfully.", payload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
70
docs/diagrams/website/auth/update-username.mmd
Normal file
70
docs/diagrams/website/auth/update-username.mmd
Normal file
@@ -0,0 +1,70 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box rgb(241,243,234) Handler Layer
|
||||
participant UpdateUsernameHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box rgb(219,238,221) Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
Note over UserMgr: ASP.NET Core Identity's UserManager replaces the deleted AuthRepository — handlers delegate to UserManager, which calls DapperUserStore (persistence) and Argon2PasswordHasher.
|
||||
|
||||
User->>API: PATCH /api/auth/username {newUsername} (requires Bearer access token)
|
||||
activate API
|
||||
API->>Mediator: Send(UpdateUsernameCommand(GetAuthenticatedUserId(), newUsername))
|
||||
activate Mediator
|
||||
Note right of Mediator: ValidationBehavior runs UpdateUsernameValidator (same format rules as registration)
|
||||
|
||||
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 ... FROM UserAccount WHERE 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
|
||||
UserMgr->>UserStore: UpdateAsync(user)
|
||||
UserStore->>DB: UPDATE UserAccount SET Username = @UserName, ...
|
||||
UserStore-->>UserMgr: IdentityResult
|
||||
UserMgr-->>UpdateUsernameHandler: IdentityResult
|
||||
deactivate UserMgr
|
||||
|
||||
alt !result.Succeeded
|
||||
UpdateUsernameHandler->>Mediator: throw ConflictException (DuplicateUserName → "Username already exists")
|
||||
Mediator->>API: propagate
|
||||
API->>User: 409 Conflict
|
||||
else result.Succeeded
|
||||
UpdateUsernameHandler-->>Mediator: UpdateUsernamePayload(user.Id, user.UserName)
|
||||
deactivate UpdateUsernameHandler
|
||||
Mediator-->>API: UpdateUsernamePayload
|
||||
deactivate Mediator
|
||||
API->>User: 200 OK {message: "Username updated successfully.", payload: {...}}
|
||||
end
|
||||
end
|
||||
end
|
||||
deactivate API
|
||||
162
docs/diagrams/website/database-schema.mmd
Normal file
162
docs/diagrams/website/database-schema.mmd
Normal file
@@ -0,0 +1,162 @@
|
||||
erDiagram
|
||||
User ||--o{ Cred : "has credentials"
|
||||
User ||--o| Verify : "has verification"
|
||||
User ||--o| Profile : "has profile"
|
||||
Profile ||--o{ Avatar : "has avatar history"
|
||||
Avatar }o--|| Photo : "uses photo"
|
||||
User ||--o{ Follow : "follows"
|
||||
User ||--o{ Follow : "followed by"
|
||||
User ||--o{ Photo : "uploads"
|
||||
|
||||
Country ||--o{ StateProvince : "has"
|
||||
StateProvince ||--o{ City : "has"
|
||||
|
||||
User ||--o{ Brewery : "posts"
|
||||
Brewery ||--o| BreweryLoc : "has location"
|
||||
City ||--o{ BreweryLoc : "located in"
|
||||
Brewery ||--o{ BreweryPhoto : "has photos"
|
||||
Photo ||--o{ BreweryPhoto : "used in"
|
||||
|
||||
User ||--o{ Beer : "posts"
|
||||
BeerStyle ||--o{ Beer : "categorizes"
|
||||
Brewery ||--o{ Beer : "brews"
|
||||
Beer ||--o{ BeerPhoto : "has photos"
|
||||
Photo ||--o{ BeerPhoto : "used in"
|
||||
Beer ||--o{ BeerComment : "has comments"
|
||||
User ||--o{ BeerComment : "comments"
|
||||
|
||||
User {
|
||||
uniqueidentifier UserAccountID PK
|
||||
varchar Username UK
|
||||
nvarchar FirstName
|
||||
nvarchar LastName
|
||||
varchar Email UK
|
||||
datetime CreatedAt
|
||||
datetime UpdatedAt "nullable"
|
||||
date DateOfBirth
|
||||
}
|
||||
|
||||
Cred {
|
||||
uniqueidentifier UserCredentialID PK
|
||||
uniqueidentifier UserAccountID FK
|
||||
datetime CreatedAt
|
||||
datetime Expiry
|
||||
nvarchar Hash
|
||||
bit IsRevoked
|
||||
datetime RevokedAt "nullable"
|
||||
}
|
||||
|
||||
Verify {
|
||||
uniqueidentifier UserVerificationID PK
|
||||
uniqueidentifier UserAccountID "FK, UK"
|
||||
datetime VerificationDateTime
|
||||
}
|
||||
|
||||
Profile {
|
||||
uniqueidentifier UserProfileID PK
|
||||
uniqueidentifier UserAccountID "FK, UK"
|
||||
nvarchar Biography
|
||||
}
|
||||
|
||||
Avatar {
|
||||
uniqueidentifier UserAvatarID PK
|
||||
uniqueidentifier UserProfileID FK
|
||||
uniqueidentifier PhotoID FK
|
||||
datetime2 ValidFrom
|
||||
datetime2 ValidTo "nullable; at most one row per UserProfileID has ValidTo IS NULL (active avatar)"
|
||||
}
|
||||
|
||||
Follow {
|
||||
uniqueidentifier UserFollowID PK
|
||||
uniqueidentifier UserAccountID FK
|
||||
uniqueidentifier FollowingID FK
|
||||
datetime CreatedAt
|
||||
}
|
||||
|
||||
Photo {
|
||||
uniqueidentifier PhotoID PK
|
||||
nvarchar Hyperlink "nullable"
|
||||
uniqueidentifier UploadedByID FK
|
||||
datetime UploadedAt
|
||||
}
|
||||
|
||||
Country {
|
||||
uniqueidentifier CountryID PK
|
||||
nvarchar CountryName
|
||||
char ISO3166_1 UK
|
||||
}
|
||||
|
||||
StateProvince {
|
||||
uniqueidentifier StateProvinceID PK
|
||||
nvarchar StateProvinceName
|
||||
char ISO3166_2 UK
|
||||
uniqueidentifier CountryID FK
|
||||
}
|
||||
|
||||
City {
|
||||
uniqueidentifier CityID PK
|
||||
nvarchar CityName
|
||||
uniqueidentifier StateProvinceID FK
|
||||
}
|
||||
|
||||
Brewery {
|
||||
uniqueidentifier BreweryPostID PK
|
||||
nvarchar BreweryName
|
||||
uniqueidentifier PostedByID FK
|
||||
nvarchar Description
|
||||
datetime CreatedAt
|
||||
datetime UpdatedAt "nullable"
|
||||
}
|
||||
|
||||
BreweryLoc {
|
||||
uniqueidentifier BreweryPostLocationID PK
|
||||
uniqueidentifier BreweryPostID "FK, UK"
|
||||
nvarchar AddressLine1
|
||||
nvarchar AddressLine2 "nullable"
|
||||
nvarchar PostalCode
|
||||
uniqueidentifier CityID FK
|
||||
geography Coordinates "nullable"
|
||||
}
|
||||
|
||||
BreweryPhoto {
|
||||
uniqueidentifier BreweryPostPhotoID PK
|
||||
uniqueidentifier BreweryPostID FK
|
||||
uniqueidentifier PhotoID FK
|
||||
datetime LinkedAt
|
||||
}
|
||||
|
||||
BeerStyle {
|
||||
uniqueidentifier BeerStyleID PK
|
||||
nvarchar StyleName UK
|
||||
nvarchar Description "nullable"
|
||||
}
|
||||
|
||||
Beer {
|
||||
uniqueidentifier BeerPostID PK
|
||||
nvarchar Name
|
||||
nvarchar Description
|
||||
decimal ABV
|
||||
int IBU
|
||||
uniqueidentifier PostedByID FK
|
||||
uniqueidentifier BeerStyleID FK
|
||||
uniqueidentifier BrewedByID FK
|
||||
datetime CreatedAt
|
||||
datetime UpdatedAt "nullable"
|
||||
}
|
||||
|
||||
BeerPhoto {
|
||||
uniqueidentifier BeerPostPhotoID PK
|
||||
uniqueidentifier BeerPostID FK
|
||||
uniqueidentifier PhotoID FK
|
||||
datetime LinkedAt
|
||||
}
|
||||
|
||||
BeerComment {
|
||||
uniqueidentifier BeerPostCommentID PK
|
||||
nvarchar Comment
|
||||
uniqueidentifier BeerPostID FK
|
||||
uniqueidentifier CommentedByID FK
|
||||
int Rating
|
||||
datetime CreatedAt
|
||||
datetime UpdatedAt "nullable"
|
||||
}
|
||||
76
docs/diagrams/website/deployment.mmd
Normal file
76
docs/diagrams/website/deployment.mmd
Normal file
@@ -0,0 +1,76 @@
|
||||
flowchart TB
|
||||
Developer(["Developer"])
|
||||
Host[["Docker Host"]]
|
||||
Developer -->|docker compose up| Host
|
||||
Host -->|http://localhost:8080| DevAPI
|
||||
|
||||
subgraph DEV["Development Environment (docker-compose.dev.yaml)"]
|
||||
DevDB[("SQL Server 2022<br/>Biergarten Database<br/>:1433")]
|
||||
DevAPI["API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS"]
|
||||
DevMig["Migrations (run-once)<br/>Database.Migrations"]
|
||||
DevSeed["Seed (run-once)<br/>Database.Seed"]
|
||||
DevMailpit["Mailpit<br/>:8025 Web UI / :1025 SMTP"]
|
||||
end
|
||||
|
||||
DevMig -->|1. Run migrations| DevDB
|
||||
DevSeed -->|2. Seed data| DevDB
|
||||
DevAPI -->|3. Connect and serve| DevDB
|
||||
DevAPI -.->|4. SMTP_HOST/PORT| DevMailpit
|
||||
DevMig -.->|depends_on| DevDB
|
||||
DevSeed -.->|depends_on| DevMig
|
||||
DevAPI -.->|depends_on| DevSeed
|
||||
|
||||
subgraph TEST["Test Environment (docker-compose.test.yaml)"]
|
||||
TestDB[("SQL Server, isolated instance<br/>Biergarten Test Database<br/>:1434")]
|
||||
TestMig["Migrations (test)"]
|
||||
TestSeed["Seed (test)<br/>minimal: test.user only"]
|
||||
Specs["API.Specs<br/>Reqnroll + xUnit<br/>(integration tests)"]
|
||||
UnitTests["unit.tests<br/>xUnit + Moq<br/>Features.*.Tests, one container"]
|
||||
end
|
||||
|
||||
TestMig -->|1. Migrate| TestDB
|
||||
TestSeed -->|2. Seed| TestDB
|
||||
Specs -->|3. Integration test| TestDB
|
||||
UnitTests -.->|depends_on for startup ordering only, no real connection| TestDB
|
||||
TestMig -.->|depends_on| TestDB
|
||||
TestSeed -.->|depends_on| TestMig
|
||||
Specs -.->|depends_on| TestSeed
|
||||
UnitTests -.->|depends_on| TestSeed
|
||||
|
||||
Results[["test-results/ (mounted volume)<br/>api-specs/results.trx<br/>Features.Users.Tests.trx<br/>Features.Breweries.Tests.trx<br/>Features.Emails.Tests.trx"]]
|
||||
Specs -->|Export TRX| Results
|
||||
UnitTests -->|Export TRX, one per slice| Results
|
||||
|
||||
subgraph PROD["Production Environment (docker-compose.prod.yaml)"]
|
||||
ProdDB[("SQL Server 2022<br/>Biergarten Database<br/>no host port published")]
|
||||
ProdMig["Migrations (run-once)"]
|
||||
ProdAPI["API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS"]
|
||||
end
|
||||
|
||||
ProdMig -->|1. Run migrations| ProdDB
|
||||
ProdAPI -->|2. Connect and serve| ProdDB
|
||||
ProdMig -.->|depends_on| ProdDB
|
||||
ProdAPI -.->|"depends_on: sql-server healthy (not migrations/seed)"| ProdDB
|
||||
|
||||
subgraph DBONLY["Database-only Environment (docker-compose.db.yaml)"]
|
||||
DbOnlyDB[("SQL Server<br/>:1434")]
|
||||
DbOnlyMig["Migrations (run-once)"]
|
||||
DbOnlySeed["Seed (run-once)"]
|
||||
end
|
||||
|
||||
DbOnlyMig -->|1. Run migrations| DbOnlyDB
|
||||
DbOnlySeed -->|2. Seed data| DbOnlyDB
|
||||
DbOnlyMig -.->|depends_on| DbOnlyDB
|
||||
DbOnlySeed -.->|depends_on| DbOnlyMig
|
||||
|
||||
subgraph MIN["Minimal Environment (docker-compose.min.yaml)"]
|
||||
MinDB[("SQL Server<br/>:1433")]
|
||||
MinMig["Migrations (run-once)"]
|
||||
MinMailpit["Mailpit<br/>:8025 / :1025"]
|
||||
MinSeed["Seed (run-once)"]
|
||||
end
|
||||
|
||||
MinMig -->|1. Run migrations| MinDB
|
||||
MinSeed -->|2. Seed data| MinDB
|
||||
MinMig -.->|depends_on| MinDB
|
||||
MinSeed -.->|depends_on| MinMig
|
||||
Reference in New Issue
Block a user