mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Refactor documentation and diagrams for web/pipeline. (#272)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> for assistance with diagram creation and documentation prose updates.
This commit is contained in:
5
docs/web/diagrams/auth/confirm-user.md
Normal file
5
docs/web/diagrams/auth/confirm-user.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Confirm User
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/confirm-user.mmd"
|
||||
```
|
||||
79
docs/web/diagrams/auth/confirm-user.mmd
Normal file
79
docs/web/diagrams/auth/confirm-user.mmd
Normal file
@@ -0,0 +1,79 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant ConfirmHandler as ConfirmUserHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box Infrastructure Layer
|
||||
participant JWT as JWT Infrastructure
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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
|
||||
5
docs/web/diagrams/auth/delete-account.md
Normal file
5
docs/web/diagrams/auth/delete-account.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Delete Account
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/delete-account.mmd"
|
||||
```
|
||||
65
docs/web/diagrams/auth/delete-account.mmd
Normal file
65
docs/web/diagrams/auth/delete-account.mmd
Normal file
@@ -0,0 +1,65 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant DeleteAccountHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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, and UserProfile cascade on delete<br/>(UserAvatar cascades transitively through UserProfile).<br/>Posts, comments, photos, and follows use ON DELETE NO ACTION<br/>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<br>"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
|
||||
11
docs/web/diagrams/auth/error-handling.md
Normal file
11
docs/web/diagrams/auth/error-handling.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# User Authentication Flow — Error Handling
|
||||
|
||||
Global exception mapping performed by `API.Core`'s `GlobalExceptionFilter`.
|
||||
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.
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/error-handling.mmd"
|
||||
```
|
||||
12
docs/web/diagrams/auth/error-handling.mmd
Normal file
12
docs/web/diagrams/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
|
||||
5
docs/web/diagrams/auth/login.md
Normal file
5
docs/web/diagrams/auth/login.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Login
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/login.mmd"
|
||||
```
|
||||
93
docs/web/diagrams/auth/login.mmd
Normal file
93
docs/web/diagrams/auth/login.mmd
Normal file
@@ -0,0 +1,93 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant LoginHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box Infrastructure Layer
|
||||
participant Argon2 as Argon2PasswordHasher
|
||||
participant JWT as JWT Infrastructure
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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: Parse stored PHC string ($argon2id$v=19$m=...,t=...,p=...$salt$hash), rehash password with the embedded salt/parameters, 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
|
||||
5
docs/web/diagrams/auth/refresh-token.md
Normal file
5
docs/web/diagrams/auth/refresh-token.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Refresh Token
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/refresh-token.mmd"
|
||||
```
|
||||
77
docs/web/diagrams/auth/refresh-token.mmd
Normal file
77
docs/web/diagrams/auth/refresh-token.mmd
Normal file
@@ -0,0 +1,77 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant RefreshHandler as RefreshTokenHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box Infrastructure Layer
|
||||
participant JWT as JWT Infrastructure
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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
|
||||
5
docs/web/diagrams/auth/registration.md
Normal file
5
docs/web/diagrams/auth/registration.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Registration
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/registration.mmd"
|
||||
```
|
||||
165
docs/web/diagrams/auth/registration.mmd
Normal file
165
docs/web/diagrams/auth/registration.mmd
Normal file
@@ -0,0 +1,165 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant RegHandler as RegisterUserHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box Infrastructure Layer
|
||||
participant Argon2 as Argon2PasswordHasher
|
||||
participant JWT as JWT Infrastructure
|
||||
participant SMTP as Email Provider
|
||||
participant Template as Template Provider
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
participant ProfileHandler as CreateUserProfileHandler<br/>(Profile slice)
|
||||
participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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 2 (fixed, to avoid thread-pool exhaustion), hash output 32 bytes
|
||||
Argon2-->>UserMgr: PHC string in Argon2id format (algorithm, version, memory/time/parallelism params, salt, 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
|
||||
|
||||
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->>Mediator: Send(CreateUserProfileCommand(user.Id, ""))
|
||||
activate Mediator
|
||||
Mediator->>ProfileHandler: Handle(command)
|
||||
activate ProfileHandler
|
||||
ProfileHandler->>DB: INSERT INTO UserProfile (UserAccountId, Biography)
|
||||
activate DB
|
||||
DB-->>ProfileHandler: UserProfileId
|
||||
deactivate DB
|
||||
ProfileHandler-->>Mediator: UserProfileId
|
||||
deactivate ProfileHandler
|
||||
Mediator-->>RegHandler: UserProfileId
|
||||
deactivate Mediator
|
||||
|
||||
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 second of two cross-slice dispatches in this flow (profile creation is the first)
|
||||
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
|
||||
5
docs/web/diagrams/auth/resend-confirmation.md
Normal file
5
docs/web/diagrams/auth/resend-confirmation.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Resend Confirmation Email
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/resend-confirmation.mmd"
|
||||
```
|
||||
71
docs/web/diagrams/auth/resend-confirmation.mmd
Normal file
71
docs/web/diagrams/auth/resend-confirmation.mmd
Normal file
@@ -0,0 +1,71 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant ResendHandler as ResendConfirmationEmailHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
participant TokenSvc as TokenService
|
||||
end
|
||||
box Infrastructure Layer
|
||||
participant SMTP as Email Provider
|
||||
participant Template as Template Provider
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
participant EmailDispatcher as EmailDispatcher<br/>(Features.Emails)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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
|
||||
5
docs/web/diagrams/auth/update-email.md
Normal file
5
docs/web/diagrams/auth/update-email.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Update Email
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/update-email.mmd"
|
||||
```
|
||||
64
docs/web/diagrams/auth/update-email.mmd
Normal file
64
docs/web/diagrams/auth/update-email.mmd
Normal file
@@ -0,0 +1,64 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant UpdateEmailHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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
|
||||
5
docs/web/diagrams/auth/update-password.md
Normal file
5
docs/web/diagrams/auth/update-password.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Update Password
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/update-password.mmd"
|
||||
```
|
||||
84
docs/web/diagrams/auth/update-password.mmd
Normal file
84
docs/web/diagrams/auth/update-password.mmd
Normal file
@@ -0,0 +1,84 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant UpdatePasswordHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box Infrastructure Layer
|
||||
participant Argon2 as Argon2PasswordHasher
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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
|
||||
5
docs/web/diagrams/auth/update-profile.md
Normal file
5
docs/web/diagrams/auth/update-profile.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Update Profile
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/update-profile.mmd"
|
||||
```
|
||||
63
docs/web/diagrams/auth/update-profile.mmd
Normal file
63
docs/web/diagrams/auth/update-profile.mmd
Normal file
@@ -0,0 +1,63 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant UpdateProfileHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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
|
||||
5
docs/web/diagrams/auth/update-username.md
Normal file
5
docs/web/diagrams/auth/update-username.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# User Authentication Flow — Update Username
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/auth/update-username.mmd"
|
||||
```
|
||||
68
docs/web/diagrams/auth/update-username.mmd
Normal file
68
docs/web/diagrams/auth/update-username.mmd
Normal file
@@ -0,0 +1,68 @@
|
||||
sequenceDiagram
|
||||
actor User
|
||||
participant API as API Controller
|
||||
participant Mediator as MediatR pipeline<br/>(ValidationBehavior)
|
||||
box Handler Layer
|
||||
participant UpdateUsernameHandler
|
||||
participant UserMgr as UserManager(ApplicationUser)
|
||||
end
|
||||
box Repository / Cross-slice
|
||||
participant UserStore as DapperUserStore<br/>(IUserStore/IUserPasswordStore/IUserEmailStore)
|
||||
end
|
||||
participant DB as SQL Server
|
||||
|
||||
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
|
||||
40
docs/web/diagrams/database-schema.md
Normal file
40
docs/web/diagrams/database-schema.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Database Schema
|
||||
|
||||
Five diagrams, one per schema category — mirrors the `identity` / `media` /
|
||||
`location` / `brewery` / `beer` schema split. Every table also has a
|
||||
`RowVersion ROWVERSION` column (optimistic concurrency), omitted below for
|
||||
brevity. Tables marked `(*)` have no repository/feature slice yet.
|
||||
|
||||
Boxes with no attribute list (e.g. `Photo` in the identity diagram) are owned by
|
||||
a different schema and shown only to keep the relationship visible; see the
|
||||
diagram named after that schema for its full definition.
|
||||
|
||||
## Identity (`identity`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/database-schema/identity.mmd"
|
||||
```
|
||||
|
||||
## Media (`media`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/database-schema/media.mmd"
|
||||
```
|
||||
|
||||
## Location (`location`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/database-schema/location.mmd"
|
||||
```
|
||||
|
||||
## Brewery (`brewery`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/database-schema/brewery.mmd"
|
||||
```
|
||||
|
||||
## Beer (`beer`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/database-schema/beer.mmd"
|
||||
```
|
||||
38
docs/web/diagrams/database-schema/beer.mmd
Normal file
38
docs/web/diagrams/database-schema/beer.mmd
Normal file
@@ -0,0 +1,38 @@
|
||||
erDiagram
|
||||
BeerStyle ||--o{ Beer : "categorizes"
|
||||
Beer ||--o{ BeerPhoto : "has photos"
|
||||
Beer ||--o{ BeerComment : "has comments"
|
||||
|
||||
%% Cross-schema: defined in full in identity.mmd / brewery.mmd / media.mmd
|
||||
User ||--o{ Beer : "posts"
|
||||
Brewery ||--o{ Beer : "brews"
|
||||
User ||--o{ BeerComment : "comments"
|
||||
|
||||
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"
|
||||
}
|
||||
|
||||
BeerComment {
|
||||
uniqueidentifier BeerPostCommentID PK
|
||||
nvarchar Comment
|
||||
uniqueidentifier BeerPostID FK
|
||||
uniqueidentifier CommentedByID FK
|
||||
int Rating
|
||||
datetime CreatedAt
|
||||
datetime UpdatedAt "nullable"
|
||||
}
|
||||
16
docs/web/diagrams/database-schema/brewery.mmd
Normal file
16
docs/web/diagrams/database-schema/brewery.mmd
Normal file
@@ -0,0 +1,16 @@
|
||||
erDiagram
|
||||
Brewery ||--o| BreweryLoc : "has location"
|
||||
Brewery ||--o{ BreweryPhoto : "has photos"
|
||||
Brewery ||--o{ Beer : "brews"
|
||||
|
||||
%% Cross-schema: defined in full in identity.mmd / location.mmd / media.mmd / beer.mmd
|
||||
User ||--o{ Brewery : "posts"
|
||||
|
||||
Brewery {
|
||||
uniqueidentifier BreweryPostID PK
|
||||
nvarchar BreweryName
|
||||
uniqueidentifier PostedByID FK
|
||||
nvarchar Description
|
||||
datetime CreatedAt
|
||||
datetime UpdatedAt "nullable"
|
||||
}
|
||||
53
docs/web/diagrams/database-schema/identity.mmd
Normal file
53
docs/web/diagrams/database-schema/identity.mmd
Normal file
@@ -0,0 +1,53 @@
|
||||
erDiagram
|
||||
User ||--o{ Cred : "has credentials"
|
||||
User ||--o| Verify : "has verification"
|
||||
User ||--o| Profile : "has profile"
|
||||
User ||--o{ Follow : "follows"
|
||||
User ||--o{ Follow : "followed by"
|
||||
|
||||
%% Cross-schema: defined in full in media.mmd / brewery.mmd / beer.mmd
|
||||
Profile ||--o{ Avatar : "has avatar history"
|
||||
User ||--o{ Photo : "uploads"
|
||||
User ||--o{ Brewery : "posts"
|
||||
User ||--o{ Beer : "posts"
|
||||
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
|
||||
}
|
||||
|
||||
Follow {
|
||||
uniqueidentifier UserFollowID PK
|
||||
uniqueidentifier UserAccountID FK
|
||||
uniqueidentifier FollowingID FK
|
||||
datetime CreatedAt
|
||||
}
|
||||
36
docs/web/diagrams/database-schema/location.mmd
Normal file
36
docs/web/diagrams/database-schema/location.mmd
Normal file
@@ -0,0 +1,36 @@
|
||||
erDiagram
|
||||
Country ||--o{ StateProvince : "has"
|
||||
StateProvince ||--o{ City : "has"
|
||||
City ||--o{ BreweryLoc : "located in"
|
||||
|
||||
%% Cross-schema: defined in full in brewery.mmd
|
||||
Brewery ||--o| BreweryLoc : "has location"
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
BreweryLoc {
|
||||
uniqueidentifier BreweryPostLocationID PK
|
||||
uniqueidentifier BreweryPostID "FK, UK"
|
||||
nvarchar AddressLine1
|
||||
nvarchar AddressLine2 "nullable"
|
||||
nvarchar PostalCode
|
||||
uniqueidentifier CityID FK
|
||||
geography Coordinates "nullable"
|
||||
}
|
||||
39
docs/web/diagrams/database-schema/media.mmd
Normal file
39
docs/web/diagrams/database-schema/media.mmd
Normal file
@@ -0,0 +1,39 @@
|
||||
erDiagram
|
||||
Avatar }o--|| Photo : "uses photo"
|
||||
Photo ||--o{ BreweryPhoto : "used in"
|
||||
Photo ||--o{ BeerPhoto : "used in"
|
||||
|
||||
%% Cross-schema: defined in full in identity.mmd / brewery.mmd / beer.mmd
|
||||
Profile ||--o{ Avatar : "has avatar history"
|
||||
User ||--o{ Photo : "uploads"
|
||||
Brewery ||--o{ BreweryPhoto : "has photos"
|
||||
Beer ||--o{ BeerPhoto : "has photos"
|
||||
|
||||
Photo {
|
||||
uniqueidentifier PhotoID PK
|
||||
nvarchar Hyperlink "nullable"
|
||||
uniqueidentifier UploadedByID FK
|
||||
datetime UploadedAt
|
||||
}
|
||||
|
||||
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)"
|
||||
}
|
||||
|
||||
BreweryPhoto {
|
||||
uniqueidentifier BreweryPostPhotoID PK
|
||||
uniqueidentifier BreweryPostID FK
|
||||
uniqueidentifier PhotoID FK
|
||||
datetime LinkedAt
|
||||
}
|
||||
|
||||
BeerPhoto {
|
||||
uniqueidentifier BeerPostPhotoID PK
|
||||
uniqueidentifier BeerPostID FK
|
||||
uniqueidentifier PhotoID FK
|
||||
datetime LinkedAt
|
||||
}
|
||||
48
docs/web/diagrams/deployment.md
Normal file
48
docs/web/diagrams/deployment.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Docker Deployment Architecture
|
||||
|
||||
Five diagrams, one per Compose file — mixing all five networks in one graph made
|
||||
it impossible to tell which containers actually talk to each other. Solid arrows
|
||||
are real container traffic; dashed arrows are `depends_on` startup gating with
|
||||
no data flowing over them.
|
||||
|
||||
## Development (`docker-compose.dev.yaml`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/deployment/dev.mmd"
|
||||
```
|
||||
|
||||
## Test (`docker-compose.test.yaml`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/deployment/test.mmd"
|
||||
```
|
||||
|
||||
## Production (`docker-compose.prod.yaml`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/deployment/prod.mmd"
|
||||
```
|
||||
|
||||
## Database-only (`docker-compose.db.yaml`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/deployment/db-only.mmd"
|
||||
```
|
||||
|
||||
## Minimal (`docker-compose.min.yaml`)
|
||||
|
||||
```mermaid
|
||||
--8<-- "web/diagrams/deployment/minimal.mmd"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
| Environment | Details |
|
||||
| ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Dev network** (`devnet`, bridge) | Internal DNS resolves service names such as `sqlserver` and `api.core`. Volumes: `sqlserverdata-dev`, `nuget-cache-dev`, `seaweedfsdata-dev`. Startup order: SQL Server (health check) → migrations (run-once) → seed (run-once, depends on `seaweedfs`) → API (long-running, depends on seed). |
|
||||
| **Test network** (`testnet`, bridge) | All test components are isolated; fresh DB instance each run (`CLEAR_DATABASE=true`, ephemeral `sqlserverdata-test` / `seaweedfsdata-test` volumes). `api.specs`, `unit.tests`, and `frontend.tests` all run as separate containers, in parallel once seeding completes; results are aggregated. |
|
||||
| **Prod network** (`prodnet`, bridge) | Isolated from dev/test networks. Volumes: `sqlserverdata-prod`, `nuget-cache-prod`. No seed step in production. |
|
||||
| **Database-only environment** | Same devnet volumes/images as `docker-compose.dev.yaml`, minus `api.core`, `frontend`, and `mailpit` — for working against the database (and object storage, since `seaweedfs` is still included) without running the API or website. |
|
||||
| **Minimal environment** | Database + migrations + seed + `seaweedfs` + mailpit, no API or frontend container — for frontend development against an `API.Core` process run outside Docker. |
|
||||
| `unit.tests` | Builds `Dockerfile.tests`, which restores/builds against `Biergarten.API.slnx` and then runs `dotnet test` for every `Features/*.Tests` project in turn (`Features.Users.Tests`, `Features.Breweries.Tests`, `Features.Emails.Tests`, `Features.PhotoUpload.Tests`), mocking each handler's repository/service dependencies with Moq — no real database needed. |
|
||||
| `.trx` results | Readable by Visual Studio, Azure DevOps, and GitHub Actions. |
|
||||
13
docs/web/diagrams/deployment/db-only.mmd
Normal file
13
docs/web/diagrams/deployment/db-only.mmd
Normal file
@@ -0,0 +1,13 @@
|
||||
sequenceDiagram
|
||||
box Database-only Environment (docker-compose.db.yaml)
|
||||
participant DbOnlyDB as SQL Server<br/>:1434
|
||||
participant DbOnlySeaweedFS as seaweedfs<br/>S3-compatible object storage
|
||||
participant DbOnlyMig as Migrations (run-once)
|
||||
participant DbOnlySeed as Seed (run-once)
|
||||
end
|
||||
|
||||
DbOnlyMig-->>DbOnlyDB: depends_on
|
||||
DbOnlyMig->>DbOnlyDB: Run migrations
|
||||
DbOnlySeed-->>DbOnlyMig: depends_on
|
||||
DbOnlySeed-->>DbOnlySeaweedFS: depends_on
|
||||
DbOnlySeed->>DbOnlyDB: Seed data
|
||||
20
docs/web/diagrams/deployment/dev.mmd
Normal file
20
docs/web/diagrams/deployment/dev.mmd
Normal file
@@ -0,0 +1,20 @@
|
||||
sequenceDiagram
|
||||
actor Developer
|
||||
participant Host as Docker Host
|
||||
box Development Environment (docker-compose.dev.yaml)
|
||||
participant DevDB as SQL Server 2022<br/>Biergarten Database<br/>:1434
|
||||
participant DevMig as Migrations (run-once)<br/>Database.Migrations
|
||||
participant DevSeed as Seed (run-once)<br/>Database.Seed
|
||||
participant DevAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
|
||||
participant DevMailpit as Mailpit<br/>:8025 Web UI / :1025 SMTP
|
||||
end
|
||||
|
||||
Developer->>Host: docker compose up
|
||||
DevMig-->>DevDB: depends_on (healthy)
|
||||
DevMig->>DevDB: Run migrations
|
||||
DevSeed-->>DevMig: depends_on
|
||||
DevSeed->>DevDB: Seed data
|
||||
DevAPI-->>DevSeed: depends_on
|
||||
DevAPI->>DevDB: Connect and serve
|
||||
DevAPI-->>DevMailpit: SMTP_HOST/PORT
|
||||
Host->>DevAPI: http://localhost:8080
|
||||
15
docs/web/diagrams/deployment/minimal.mmd
Normal file
15
docs/web/diagrams/deployment/minimal.mmd
Normal file
@@ -0,0 +1,15 @@
|
||||
sequenceDiagram
|
||||
box Minimal Environment (docker-compose.min.yaml)
|
||||
participant MinDB as SQL Server<br/>:1433
|
||||
participant MinSeaweedFS as seaweedfs<br/>S3-compatible object storage
|
||||
participant MinMig as Migrations (run-once)
|
||||
participant MinSeed as Seed (run-once)
|
||||
participant MinMailpit as Mailpit<br/>:8025 / :1025
|
||||
end
|
||||
|
||||
MinMig-->>MinDB: depends_on
|
||||
MinMig->>MinDB: Run migrations
|
||||
MinSeed-->>MinMig: depends_on
|
||||
MinSeed-->>MinSeaweedFS: depends_on
|
||||
MinSeed->>MinDB: Seed data
|
||||
Note over MinDB,MinMailpit: consumed by an externally run API.Core process (not shown)
|
||||
11
docs/web/diagrams/deployment/prod.mmd
Normal file
11
docs/web/diagrams/deployment/prod.mmd
Normal file
@@ -0,0 +1,11 @@
|
||||
sequenceDiagram
|
||||
box Production Environment (docker-compose.prod.yaml)
|
||||
participant ProdDB as SQL Server 2022<br/>Biergarten Database<br/>no host port published
|
||||
participant ProdMig as Migrations (run-once)
|
||||
participant ProdAPI as API Container<br/>ASP.NET Core 10<br/>:8080 HTTP / :8081 HTTPS
|
||||
end
|
||||
|
||||
ProdMig-->>ProdDB: depends_on
|
||||
ProdMig->>ProdDB: Run migrations
|
||||
ProdAPI-->>ProdDB: depends_on: sql-server healthy (not migrations/seed)
|
||||
ProdAPI->>ProdDB: Connect and serve
|
||||
29
docs/web/diagrams/deployment/test.mmd
Normal file
29
docs/web/diagrams/deployment/test.mmd
Normal file
@@ -0,0 +1,29 @@
|
||||
sequenceDiagram
|
||||
box Test Environment (docker-compose.test.yaml)
|
||||
participant TestDB as SQL Server, isolated instance<br/>Biergarten Test Database<br/>no host port published
|
||||
participant TestSeaweedFS as seaweedfs<br/>S3-compatible object storage
|
||||
participant TestMig as Migrations (test)
|
||||
participant TestSeed as Seed (test)<br/>minimal: test.user only
|
||||
participant Specs as API.Specs<br/>Reqnroll + xUnit<br/>(integration tests)
|
||||
participant UnitTests as unit.tests<br/>xUnit + Moq<br/>Features.*.Tests, one container
|
||||
participant FrontendTests as frontend.tests<br/>Playwright/Vitest
|
||||
end
|
||||
participant Results as test-results/ (mounted volume)
|
||||
|
||||
TestMig-->>TestDB: depends_on
|
||||
TestMig->>TestDB: Migrate
|
||||
TestSeed-->>TestMig: depends_on
|
||||
TestSeed-->>TestSeaweedFS: depends_on
|
||||
TestSeed->>TestDB: Seed
|
||||
|
||||
par Specs
|
||||
Specs-->>TestSeed: depends_on
|
||||
Specs->>TestDB: Integration test
|
||||
Specs->>Results: Export TRX
|
||||
and unit.tests
|
||||
UnitTests-->>TestSeed: depends_on
|
||||
UnitTests-->>TestDB: depends_on (startup ordering only, no real connection)
|
||||
UnitTests->>Results: Export TRX, one per slice
|
||||
and frontend.tests
|
||||
FrontendTests->>Results: Export results
|
||||
end
|
||||
103
docs/web/diagrams/vertical-slice.md
Normal file
103
docs/web/diagrams/vertical-slice.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# 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
|
||||
--8<-- "web/diagrams/vertical-slice/host-wiring.mmd"
|
||||
```
|
||||
|
||||
## 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
|
||||
--8<-- "web/diagrams/vertical-slice/project-references.mmd"
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- **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` scanned for MediatR handlers
|
||||
only
|
||||
- `Features.Locations` and `Features.Beers` not 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 project name
|
||||
`Features.Users`
|
||||
- `Features.Emails` and `Features.PhotoUpload` have no controller — invoked
|
||||
only via MediatR commands sent from other slices
|
||||
- `Features.Locations` has no controller; one MediatR handler
|
||||
(`GetOrCreateCityHandler`) registered by `Database.Seed` only — `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`
|
||||
- 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)
|
||||
- Cross-slice email commands (`SendRegistrationEmailCommand`,
|
||||
`SendResendConfirmationEmailCommand`)
|
||||
- MediatR command contract (not project reference)
|
||||
- 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
|
||||
- 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
|
||||
- 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)
|
||||
- `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
|
||||
31
docs/web/diagrams/vertical-slice/host-wiring.mmd
Normal file
31
docs/web/diagrams/vertical-slice/host-wiring.mmd
Normal file
@@ -0,0 +1,31 @@
|
||||
---
|
||||
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
|
||||
43
docs/web/diagrams/vertical-slice/project-references.mmd
Normal file
43
docs/web/diagrams/vertical-slice/project-references.mmd
Normal file
@@ -0,0 +1,43 @@
|
||||
---
|
||||
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")]
|
||||
Reference in New Issue
Block a user