Refactor comments and update documentation for backend and pipeline (#246)

* Remove restating comments in backend

* Remove restating comments in frontend stories

* Remove restating comments in pipeline

* Remove restating comments in pipeline scripts

* Restore location-clear comment in BreweryRepository

* Remove stale/restating comments found in second pass

* Restore mock curated-data branch comment in main.cc

* Remove more restating/filler comments in backend

* Document account-management endpoints, compose files, and env generator

* Squashed commit of the following:

commit a8a5b8abd9969859c695092fae6990d03b2135ed
Author: Aaron Po <aaron.po97@gmail.com>
Date:   Sun Aug 23 23:53:56 2026 -0400

    Add reverse geocoding for brewery street addresses (#245)

    * Add geocoding to seed application, remove postal codes from generator

    Update comments

* Update pipeline class diagram for OpenAI generator

* Update pipeline activity diagram for brewery coordinates and OpenAI backend

* Add mailpit, production, db-only, and minimal environments to deployment diagram

* Fix authentication flow diagram for Identity migration

* Add account-management flows to authentication diagram

* Fix database schema diagram note referencing removed AuthRepository
This commit is contained in:
2026-08-24 01:05:42 -04:00
committed by Aaron Po
parent cd5fd2d998
commit c825d99b0a
46 changed files with 1037 additions and 218 deletions

View File

@@ -66,7 +66,7 @@ note right
end note
|#EAF0E8|BiergartenPipelineOrchestrator|
:QueryCitiesWithCountries();
:QueryLocations();
|#E2EBDC|JsonLoader|
:JsonLoader::LoadLocations("locations.json");
@@ -118,7 +118,7 @@ while (For each EnrichedCity?) is (Remaining cities)
|#E5EDE1|DataGenerator|
if (Generator Mode) then (MockGenerator)
:DeterministicHash & Format;
else (LlamaGenerator)
elseif (Generator Mode) then (LlamaGenerator)
:prompt_directory_->Load("USER_GENERATION");
note right
Resolves to USER_GENERATION.md inside --prompt-dir.
@@ -133,15 +133,43 @@ while (For each EnrichedCity?) is (Remaining cities)
:Append validation error to retry prompt;
endif
repeat while (Attempt < 3?) is (yes)
else (OpenAIGenerator)
:prompt_directory_->Load("USER_GENERATION");
note right
Same prompt file as LlamaGenerator - content
is backend-agnostic.
end note
repeat
:CallChatCompletionsApi(system_prompt, user_prompt,\nkUserJsonSchema, max_tokens);
:ValidateUserJson(raw, user);
if (Is JSON Valid?) then (yes)
break
else (no)
:Attempt++;
:Append validation error to retry prompt;
endif
repeat while (Attempt < 2?) is (yes)
note right
Structured Outputs (response_format:
json_schema, strict: true) already
guarantees schema shape, so only 2
attempts are needed - retries here cover
transient failures / placeholder text,
not malformed JSON.
end note
endif
|#EAF0E8|BiergartenPipelineOrchestrator|
if (Generation successful?) then (yes)
:GetRandomCoordsWithinRange(city_centre, 5km, rng);
:BuildEmail(name, used_email_local_parts);
:GenerateDateOfBirth(rng);
note right
email and date_of_birth are generated
programmatically, never LLM-authored.
Generation + coordinate jitter run inside
one try/catch per record. UserRecord.address
now carries {city, longitude, latitude}.
end note
|#E0EAE0|SqliteExportService|
@@ -171,7 +199,7 @@ endwhile (Done)
while (For each EnrichedCity?) is (Remaining cities)
if (Generator Mode) then (MockGenerator)
:DeterministicHash & Format;
else (LlamaGenerator)
elseif (Generator Mode) then (LlamaGenerator)
:PrepareRegionContext;
:prompt_directory_->Load("BREWERY_GENERATION");
note right
@@ -191,10 +219,40 @@ while (For each EnrichedCity?) is (Remaining cities)
max_tokens is fixed across retries —
it is not raised on truncation.
end note
else (OpenAIGenerator)
:PrepareRegionContext;
:prompt_directory_->Load("BREWERY_GENERATION");
note right
Same prompt file as LlamaGenerator - content
is backend-agnostic.
end note
repeat
:CallChatCompletionsApi(system_prompt, user_prompt,\nkBreweryJsonSchema, max_tokens);
:ValidateBreweryJson(raw, brewery);
if (Is JSON Valid?) then (yes)
break
else (no)
:Attempt++;
:Append validation error to retry prompt;
endif
repeat while (Attempt < 2?) is (yes)
note right
Structured Outputs already guarantees schema
shape, so only 2 attempts are needed - retries
here cover transient failures / placeholder
text, not malformed JSON.
end note
endif
|#EAF0E8|BiergartenPipelineOrchestrator|
if (Generation successful?) then (yes)
:GetRandomCoordsWithinRange(city_centre, 5km, rng);
note right
Generation + coordinate jitter run inside one
try/catch per record. BreweryRecord.address
now carries {city, longitude, latitude}.
end note
|#E0EAE0|SqliteExportService|
:ProcessRecord(GeneratedBrewery);
if (Location in cache?) then (yes)

View File

@@ -35,12 +35,20 @@ class BiergartenPipelineOrchestrator {
- generated_breweries_ : std::vector<BreweryRecord>
- generated_users_ : std::vector<UserRecord>
+ Run() : bool
- QueryCitiesWithCountries() : std::vector<Location>
- QueryLocations() : std::vector<City>
- GenerateBreweries(cities : std::span<const EnrichedCity>) : void
- GenerateUsers(cities : std::span<const EnrichedCity>) : void
- LogResults() : void
- LogResults() const : void
}
note right of BiergartenPipelineOrchestrator
GenerateBreweries and GenerateUsers both call the
free function GetRandomCoordsWithinRange()
(biergarten_pipeline_orchestrator/coords.h) to jitter
a coordinate pair uniformly within 5km of the city
centre for each generated record's address.
end note
class LogLevel <<enumeration>> {
Debug
Info
@@ -50,6 +58,7 @@ class LogLevel <<enumeration>> {
class PipelinePhase <<enumeration>> {
Startup
Enrichment
UserGeneration
BreweryAndBeerGeneration
CheckinGeneration
@@ -115,12 +124,12 @@ class HttpWebClient {
}
interface DataGenerator <<interface>> {
+ GenerateBrewery(location : const Location&, region_context : const std::string&) : BreweryResult
+ GenerateBrewery(city : const EnrichedCity&) : BreweryResult
+ GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
}
class MockGenerator {
+ GenerateBrewery(location : const Location&, region_context : const std::string&) : BreweryResult
+ GenerateBrewery(city : const EnrichedCity&) : BreweryResult
+ GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
- DeterministicHash(location : const Location&) : size_t
- DeterministicHash(location : const Location&, persona : const UserPersona&, name : const Name&) : size_t
@@ -139,6 +148,25 @@ class LlamaGenerator {
- InferFormatted(...) : std::string
}
class OpenAIGenerator {
- api_key_ : std::string
- model_ : std::string
- logger_ : std::shared_ptr<ILogger>
- prompt_directory_ : std::unique_ptr<IPromptDirectory>
- web_client_ : std::unique_ptr<WebClient>
+ GenerateBrewery(city : const EnrichedCity&) : BreweryResult
+ GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
- CallChatCompletionsApi(...) : std::string
}
note right of OpenAIGenerator
Chat Completions with Structured Outputs
(response_format: json_schema, strict: true)
guarantees schema-valid JSON, replacing
llama.cpp's GBNF grammar constraint -
no IPromptFormatter needed.
end note
interface IPromptFormatter <<interface>> {
+ Format(system_prompt : std::string_view, user_prompt : std::string_view) : std::string
}
@@ -252,10 +280,14 @@ WebClient <|.. HttpWebClient : implements
DataGenerator <|.. MockGenerator : implements
DataGenerator <|.. LlamaGenerator : implements
DataGenerator <|.. OpenAIGenerator : implements
LlamaGenerator *-- IPromptFormatter : uses
LlamaGenerator *-- IPromptDirectory : uses
OpenAIGenerator *-- IPromptDirectory : uses
OpenAIGenerator *-- WebClient : uses
IPromptFormatter <|.. Gemma4JinjaPromptFormatter : implements
IPromptDirectory <|.. PromptDirectory : implements

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 110 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 87 KiB

After

Width:  |  Height:  |  Size: 97 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,20 +13,38 @@ participant "MediatR pipeline\n(ValidationBehavior)" as Mediator
box "Handler Layer" #LightBlue
participant "RegisterUserHandler" as RegHandler
participant "LoginHandler" as LoginHandler
participant "RefreshTokenHandler" as RefreshHandler
participant "ConfirmUserHandler" as ConfirmHandler
participant "ResendConfirmationEmailHandler" as ResendHandler
participant "UpdateUsernameHandler" as UpdateUsernameHandler
participant "UpdateEmailHandler" as UpdateEmailHandler
participant "UpdatePasswordHandler" as UpdatePasswordHandler
participant "UpdateProfileHandler" as UpdateProfileHandler
participant "DeleteAccountHandler" as DeleteAccountHandler
participant "UserManager<ApplicationUser>" as UserMgr
participant "TokenService" as TokenSvc
end box
box "Infrastructure Layer" #LightGreen
participant "Argon2\nInfrastructure" as Argon2
participant "Argon2PasswordHasher" as Argon2
participant "JWT\nInfrastructure" as JWT
participant "Email\nProvider" as SMTP
participant "Template\nProvider" as Template
end box
box "Repository / Cross-slice" #LightYellow
participant "AuthRepository" as AuthRepo
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
end box
database "SQL Server" as DB
note over UserMgr
ASP.NET Core Identity's UserManager<TUser>
replaces the deleted AuthRepository: handlers
no longer issue SQL or call Argon2 directly,
they delegate to UserManager, which in turn
calls DapperUserStore (persistence) and
Argon2PasswordHasher (IPasswordHasher<TUser>).
end note
== Registration Flow ==
User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
@@ -51,71 +69,83 @@ else Validation succeeds
Mediator -> RegHandler: Handle(command)
activate RegHandler
RegHandler -> AuthRepo: GetUserByUsernameAsync(username)
activate AuthRepo
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
activate DB
DB --> AuthRepo: null (user doesn't exist)
deactivate DB
deactivate AuthRepo
RegHandler -> RegHandler: user = new UserAccount{...}\n.ToApplicationUser()
RegHandler -> AuthRepo: GetUserByEmailAsync(email)
activate AuthRepo
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Email = @Email
activate DB
DB --> AuthRepo: null (email doesn't exist)
deactivate DB
deactivate AuthRepo
RegHandler -> UserMgr: CreateAsync(user, password)
activate UserMgr
alt Username or email already exists
RegHandler -> Mediator: throw ConflictException
UserMgr -> Argon2: HashPassword(user, password)
activate Argon2
note right of Argon2
Delegates to IPasswordInfrastructure.Hash:
Argon2id parameters:
- Salt: 16 bytes (128-bit)
- Memory: 64MB
- Iterations: 4
- Parallelism: CPU count
- Hash output: 32 bytes
end note
Argon2 --> UserMgr: "base64(salt):base64(hash)"
deactivate Argon2
UserMgr -> UserStore: SetPasswordHashAsync(user, hash)
note right of UserStore
user.Id is still empty at this point -
only sets the in-memory hash. CreateAsync
writes it to dbo.UserCredential itself.
end note
UserMgr -> UserStore: FindByNameAsync(username)
activate UserStore
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
activate DB
DB --> UserStore: null (user doesn't exist)
deactivate DB
deactivate UserStore
UserMgr -> UserStore: FindByEmailAsync(email)
activate UserStore
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Email = @Email
activate DB
DB --> UserStore: null (email doesn't exist)
deactivate DB
deactivate UserStore
note right of UserMgr
Default UserManager validators check
uniqueness before CreateAsync is called;
DapperUserStore.CreateAsync also guards
against a genuine race via a caught
duplicate-key SqlException (2601/2627).
end note
UserMgr -> UserStore: CreateAsync(user)
activate UserStore
UserStore -> DB: BEGIN TRANSACTION
activate DB
UserStore -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID
DB --> UserStore: UserAccountId (GUID)
UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
UserStore -> DB: COMMIT TRANSACTION
deactivate DB
note right of UserStore
Both statements run via Dapper's
ExecuteScalarAsync/ExecuteAsync against
a single DbTransaction, not a stored
procedure - no manual parameter binding
end note
UserStore --> UserMgr: IdentityResult.Success\n(user.Id set)
deactivate UserStore
UserMgr --> RegHandler: IdentityResult
deactivate UserMgr
alt !result.Succeeded
RegHandler -> Mediator: throw ConflictException\n(DuplicateUserName/DuplicateEmail codes ->\n"Username or email already exists";\notherwise joined error descriptions)
Mediator -> API: propagate
API -> User: 409 Conflict\n"Username or email already exists"
else Username and email are free
RegHandler -> Argon2: Hash(password)
activate Argon2
note right of Argon2
Argon2id parameters:
- Salt: 16 bytes (128-bit)
- Memory: 64MB
- Iterations: 4
- Parallelism: CPU count
- Hash output: 32 bytes
end note
Argon2 -> Argon2: Generate random salt\n(16 bytes)
Argon2 -> Argon2: Hash password with\nArgon2id algorithm
Argon2 --> RegHandler: "base64(salt):base64(hash)"
deactivate Argon2
RegHandler -> AuthRepo: RegisterUserAsync(userAccount)
activate AuthRepo
AuthRepo -> DB: BEGIN TRANSACTION
activate DB
AuthRepo -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID
DB --> AuthRepo: UserAccountId (GUID)
AuthRepo -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
AuthRepo -> DB: COMMIT TRANSACTION
deactivate DB
note right of AuthRepo
All three statements run via Dapper's
ExecuteScalarAsync/ExecuteAsync against
a single DbTransaction, not a stored
procedure - no manual parameter binding
end note
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @UserAccountId
activate DB
note right of DB
Round trip to load the row the
INSERT just created
end note
DB --> AuthRepo: UserAccount row
deactivate DB
AuthRepo --> RegHandler: UserAccount entity
deactivate AuthRepo
else result.Succeeded
RegHandler -> TokenSvc: GenerateAccessToken(user)
activate TokenSvc
@@ -242,13 +272,19 @@ else Validation succeeds
Mediator -> LoginHandler: Handle(command)
activate LoginHandler
LoginHandler -> AuthRepo: GetUserByUsernameAsync(username)
activate AuthRepo
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
LoginHandler -> UserMgr: FindByNameAsync(username)
activate UserMgr
UserMgr -> UserStore: FindByNameAsync(username)
activate UserStore
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
activate DB
DB --> AuthRepo: UserAccount row or null
DB --> UserStore: UserAccount row or null
deactivate DB
deactivate AuthRepo
UserStore -> UserStore: HydrateAsync\n(load active UserCredential.Hash,\nUserVerification -> EmailConfirmed)
UserStore --> UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr --> LoginHandler: ApplicationUser or null
deactivate UserMgr
alt User not found
LoginHandler -> Mediator: throw UnauthorizedException\n"Invalid username or password."
@@ -256,41 +292,34 @@ else Validation succeeds
API -> User: 401 Unauthorized
else User found
LoginHandler -> AuthRepo: GetActiveCredentialByUserAccountIdAsync(userId)
activate AuthRepo
AuthRepo -> DB: SELECT UserCredentialId, UserAccountId,\nHash, CreatedAt, RowVersion\nFROM dbo.UserCredential\nWHERE UserAccountId = @UserAccountId\n AND IsRevoked = 0
activate DB
DB --> AuthRepo: UserCredential row or null
deactivate DB
deactivate AuthRepo
LoginHandler -> UserMgr: CheckPasswordAsync(user, password)
activate UserMgr
note right of UserMgr
HasPasswordAsync short-circuits to false
(no active UserCredential was hydrated)
without calling the hasher; otherwise
delegates to Argon2PasswordHasher.
end note
UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, password)
activate Argon2
note right of Argon2
Delegates to IPasswordInfrastructure.Verify:
1. Split stored hash: "salt:hash"
2. Hash provided password with the same salt
3. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)
end note
Argon2 --> UserMgr: Success/Failed
deactivate Argon2
UserMgr --> LoginHandler: true/false
deactivate UserMgr
alt No active credential
alt Password invalid
LoginHandler -> Mediator: throw UnauthorizedException
Mediator -> API: propagate
API -> User: 401 Unauthorized
else Active credential found
else Password valid
LoginHandler -> Argon2: Verify(password, storedHash)
activate Argon2
note right of Argon2
1. Split stored hash: "salt:hash"
2. Extract salt
3. Hash provided password\n with same salt
4. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)
end note
Argon2 -> Argon2: Parse salt from stored hash
Argon2 -> Argon2: Hash provided password\nwith extracted salt
Argon2 -> Argon2: FixedTimeEquals(\n computed, stored)
Argon2 --> LoginHandler: true/false
deactivate Argon2
alt Password invalid
LoginHandler -> Mediator: throw UnauthorizedException
Mediator -> API: propagate
API -> User: 401 Unauthorized
else Password valid
LoginHandler -> TokenSvc: GenerateAccessToken(user)
LoginHandler -> TokenSvc: GenerateAccessToken(user)
activate TokenSvc
TokenSvc -> JWT: GenerateJwt(...)
activate JWT
@@ -317,6 +346,567 @@ else Validation succeeds
end
end
end
deactivate API
== Refresh Token Flow ==
User -> API: POST /api/auth/refresh\nHeader: X-Refresh-Token: <token>
activate API
API -> Mediator: Send(RefreshTokenCommand(refreshToken))
activate Mediator
note right of Mediator
ValidationBehavior runs RefreshTokenValidator:
refreshToken must be non-empty. Anonymous
endpoint - no Bearer token required.
end note
alt Validation fails
Mediator -> API: throws FluentValidation.ValidationException
API -> User: 400 Bad Request\n{message: "Validation failed", errors: {...}}
else Validation succeeds
Mediator -> RefreshHandler: Handle(command)
activate RefreshHandler
RefreshHandler -> TokenSvc: RefreshTokenAsync(refreshToken)
activate TokenSvc
TokenSvc -> JWT: ValidateRefreshTokenAsync(refreshToken)
activate JWT
note right of JWT
Validates signature/expiry against the
refresh token secret, extracts userId/
username claims.
end note
JWT --> TokenSvc: ValidatedToken or throws
deactivate JWT
alt Token invalid or expired
TokenSvc -> RefreshHandler: throw UnauthorizedException
RefreshHandler -> Mediator: propagate
Mediator -> API: propagate
API -> User: 401 Unauthorized
else Token valid
TokenSvc -> UserMgr: FindByIdAsync(validated.UserId)
activate UserMgr
UserMgr -> UserStore: FindByIdAsync(userId)
activate UserStore
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
activate DB
DB --> UserStore: row or null
deactivate DB
UserStore --> UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr --> TokenSvc: ApplicationUser or null
deactivate UserMgr
alt User not found
TokenSvc -> RefreshHandler: throw UnauthorizedException\n"User account not found"
RefreshHandler -> Mediator: propagate
Mediator -> API: propagate
API -> User: 401 Unauthorized
else User found
TokenSvc -> JWT: GenerateAccessToken(user)\nGenerateRefreshToken(user)
JWT --> TokenSvc: new access + refresh tokens
TokenSvc --> RefreshHandler: RefreshTokenResult
deactivate TokenSvc
RefreshHandler --> Mediator: LoginPayload(\n userId, username,\n refreshToken, accessToken)
deactivate RefreshHandler
Mediator --> API: LoginPayload
deactivate Mediator
API -> User: 200 OK\n{message: "Token refreshed successfully.",\npayload: {...}}
end
end
end
deactivate API
== Confirm User Flow ==
User -> API: POST /api/auth/confirm?token=<token>\n(requires Bearer access token)
activate API
API -> Mediator: Send(ConfirmUserCommand(token))
activate Mediator
Mediator -> ConfirmHandler: Handle(command)
activate ConfirmHandler
ConfirmHandler -> TokenSvc: ValidateConfirmationTokenAsync(token)
activate TokenSvc
TokenSvc -> JWT: Validate against confirmation\ntoken secret, extract userId
activate JWT
JWT --> TokenSvc: ValidatedToken or throws
deactivate JWT
TokenSvc --> ConfirmHandler: ValidatedToken
deactivate TokenSvc
alt Token invalid or expired
ConfirmHandler -> Mediator: throw UnauthorizedException
Mediator -> API: propagate
API -> User: 401 Unauthorized
else Token valid
ConfirmHandler -> UserMgr: FindByIdAsync(validatedToken.UserId)
activate UserMgr
UserMgr -> UserStore: FindByIdAsync(userId)
activate UserStore
UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
activate DB
DB --> UserStore: row or null
deactivate DB
UserStore --> UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr --> ConfirmHandler: ApplicationUser or null
deactivate UserMgr
alt User not found
ConfirmHandler -> Mediator: throw UnauthorizedException\n"User account not found"
Mediator -> API: propagate
API -> User: 401 Unauthorized
else User found
ConfirmHandler -> UserStore: SetEmailConfirmedAsync(user, true)
note right of UserStore
IUserEmailStore<ApplicationUser> is injected
directly into the handler (not via UserManager),
setting the confirmed flag in-memory here.
end note
ConfirmHandler -> UserMgr: UpdateAsync(user)
activate UserMgr
UserMgr -> UserStore: UpdateAsync(user)
activate UserStore
UserStore -> DB: UPDATE dbo.UserAccount SET ...
UserStore -> DB: INSERT INTO dbo.UserVerification\n(UserAccountId, VerificationDateTime)
note right of UserStore
Idempotent: skipped (duplicate-key
exception swallowed) if a verification
row already exists.
end note
UserStore --> UserMgr: IdentityResult.Success
deactivate UserStore
UserMgr --> ConfirmHandler: IdentityResult
deactivate UserMgr
ConfirmHandler --> Mediator: ConfirmationPayload(\n user.Id, DateTime.UtcNow)
deactivate ConfirmHandler
Mediator --> API: ConfirmationPayload
deactivate Mediator
API -> User: 200 OK\n{message: "User with ID {id} is confirmed.",\npayload: {...}}
end
end
deactivate API
== Resend Confirmation Email Flow ==
User -> API: POST /api/auth/confirm/resend?userId=<id>\n(requires Bearer access token)
activate API
API -> Mediator: Send(ResendConfirmationEmailCommand(userId))
activate Mediator
Mediator -> ResendHandler: Handle(command)
activate ResendHandler
ResendHandler -> UserMgr: FindByIdAsync(userId)
activate UserMgr
UserMgr -> UserStore: FindByIdAsync(userId)
activate UserStore
UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
activate DB
DB --> UserStore: row or null
deactivate DB
UserStore --> UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr --> ResendHandler: ApplicationUser or null
deactivate UserMgr
alt User not found
ResendHandler -> ResendHandler: return silently\n(prevents user enumeration)
else User found
ResendHandler -> UserMgr: IsEmailConfirmedAsync(user)
UserMgr --> ResendHandler: bool
alt Already confirmed
ResendHandler -> ResendHandler: return silently (no-op)
else Not yet confirmed
ResendHandler -> TokenSvc: GenerateConfirmationToken(user)
activate TokenSvc
TokenSvc --> ResendHandler: confirmation token
deactivate TokenSvc
ResendHandler -> Mediator: Send(SendResendConfirmationEmailCommand)
activate Mediator
Mediator -> EmailDispatcher: SendResendConfirmationEmailAsync(\n firstName, email, confirmationToken)
activate EmailDispatcher
EmailDispatcher -> Template: Render email\n(same Razor pipeline as registration)
EmailDispatcher -> SMTP: SendAsync(email, subject, body)
deactivate EmailDispatcher
deactivate Mediator
end
end
ResendHandler --> Mediator: (void)
deactivate ResendHandler
Mediator --> API: (void)
deactivate Mediator
API -> User: 200 OK\n{message: "confirmation email has been resent"}
note right of API
Always 200 OK regardless of outcome above -
same user-enumeration precaution as a not-found
user or an already-confirmed account.
end note
deactivate API
== Update Username Flow ==
User -> API: PATCH /api/auth/username\n{newUsername}\n(requires Bearer access token)
activate API
API -> Mediator: Send(UpdateUsernameCommand(\n GetAuthenticatedUserId(), newUsername))
activate Mediator
note right of Mediator
ValidationBehavior runs UpdateUsernameValidator
(same format rules as registration)
end note
alt Validation fails
Mediator -> API: throws FluentValidation.ValidationException
API -> User: 400 Bad Request
else Validation succeeds
Mediator -> UpdateUsernameHandler: Handle(command)
activate UpdateUsernameHandler
UpdateUsernameHandler -> UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr -> UserStore: FindByIdAsync(userAccountId)
activate UserStore
UserStore -> DB: SELECT ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @Id
activate DB
DB --> UserStore: row or null
deactivate DB
UserStore --> UserMgr: ApplicationUser or null
deactivate UserStore
UserMgr --> UpdateUsernameHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
UpdateUsernameHandler -> Mediator: throw NotFoundException
Mediator -> API: propagate
API -> User: 404 Not Found
else Account found
UpdateUsernameHandler -> UserMgr: SetUserNameAsync(user, newUsername)
activate UserMgr
UserMgr -> UserStore: FindByNameAsync(newUsername)
note right of UserMgr
Uniqueness check before the UPDATE.
end note
UserMgr -> UserStore: UpdateAsync(user)
UserStore -> DB: UPDATE dbo.UserAccount\nSET Username = @UserName, ...
UserStore --> UserMgr: IdentityResult
UserMgr --> UpdateUsernameHandler: IdentityResult
deactivate UserMgr
alt !result.Succeeded
UpdateUsernameHandler -> Mediator: throw ConflictException\n(DuplicateUserName code ->\n"Username already exists")
Mediator -> API: propagate
API -> User: 409 Conflict
else result.Succeeded
UpdateUsernameHandler --> Mediator: UpdateUsernamePayload(\n user.Id, user.UserName)
deactivate UpdateUsernameHandler
Mediator --> API: UpdateUsernamePayload
deactivate Mediator
API -> User: 200 OK\n{message: "Username updated successfully.",\npayload: {...}}
end
end
end
deactivate API
== Update Email Flow ==
User -> API: PATCH /api/auth/email\n{newEmail}\n(requires Bearer access token)
activate API
API -> Mediator: Send(UpdateEmailCommand(\n GetAuthenticatedUserId(), newEmail))
activate Mediator
note right of Mediator
ValidationBehavior runs UpdateEmailValidator
(same format rules as registration)
end note
alt Validation fails
Mediator -> API: throws FluentValidation.ValidationException
API -> User: 400 Bad Request
else Validation succeeds
Mediator -> UpdateEmailHandler: Handle(command)
activate UpdateEmailHandler
UpdateEmailHandler -> UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr -> UserStore: FindByIdAsync(userAccountId)
UserStore -> DB: SELECT ...
UserStore --> UserMgr: ApplicationUser or null
UserMgr --> UpdateEmailHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
UpdateEmailHandler -> Mediator: throw NotFoundException
Mediator -> API: propagate
API -> User: 404 Not Found
else Account found
UpdateEmailHandler -> UserMgr: SetEmailAsync(user, newEmail)
activate UserMgr
UserMgr -> UserStore: FindByEmailAsync(newEmail)
note right of UserMgr
Uniqueness check. Also resets
user.EmailConfirmed = false -
the caller must re-confirm via
POST /api/auth/confirm/resend.
end note
UserMgr -> UserStore: UpdateAsync(user)
UserStore -> DB: UPDATE dbo.UserAccount\nSET Email = @Email, ...
UserStore -> DB: DELETE FROM dbo.UserVerification\nWHERE UserAccountID = @Id
UserStore --> UserMgr: IdentityResult
UserMgr --> UpdateEmailHandler: IdentityResult
deactivate UserMgr
alt !result.Succeeded
UpdateEmailHandler -> Mediator: throw ConflictException\n(DuplicateEmail code ->\n"Email address already exists")
Mediator -> API: propagate
API -> User: 409 Conflict
else result.Succeeded
UpdateEmailHandler --> Mediator: UpdateEmailPayload(\n user.Id, user.Email,\n user.EmailConfirmed = false)
deactivate UpdateEmailHandler
Mediator --> API: UpdateEmailPayload
deactivate Mediator
API -> User: 200 OK\n{message: "Email updated successfully.",\npayload: {...}}
end
end
end
deactivate API
== Update Password Flow ==
User -> API: PATCH /api/auth/password\n{currentPassword, newPassword}\n(requires Bearer access token)
activate API
API -> Mediator: Send(UpdatePasswordCommand(\n GetAuthenticatedUserId(),\n currentPassword, newPassword))
activate Mediator
note right of Mediator
ValidationBehavior runs UpdatePasswordValidator
(same password strength rules as registration)
end note
alt Validation fails
Mediator -> API: throws FluentValidation.ValidationException
API -> User: 400 Bad Request
else Validation succeeds
Mediator -> UpdatePasswordHandler: Handle(command)
activate UpdatePasswordHandler
UpdatePasswordHandler -> UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr -> UserStore: FindByIdAsync(userAccountId)
UserStore --> UserMgr: ApplicationUser or null
UserMgr --> UpdatePasswordHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
UpdatePasswordHandler -> Mediator: throw NotFoundException
Mediator -> API: propagate
API -> User: 404 Not Found
else Account found
UpdatePasswordHandler -> UserMgr: ChangePasswordAsync(\n user, currentPassword, newPassword)
activate UserMgr
UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, currentPassword)
activate Argon2
Argon2 --> UserMgr: Success/Failed
deactivate Argon2
alt Current password mismatch
UserMgr --> UpdatePasswordHandler: IdentityResult.Failed\n(PasswordMismatch)
else Current password matches
UserMgr -> Argon2: HashPassword(user, newPassword)
activate Argon2
Argon2 --> UserMgr: new hash
deactivate Argon2
UserMgr -> UserStore: SetPasswordHashAsync(user, newHash)
activate UserStore
UserStore -> DB: UPDATE dbo.UserCredential\nSET IsRevoked = 1, RevokedAt = GETDATE()\nWHERE UserAccountId = @Id AND IsRevoked = 0
UserStore -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
note right of UserStore
Revoke-then-insert runs inside one
DbTransaction (credential rotation).
end note
UserStore --> UserMgr: (void)
deactivate UserStore
UserMgr --> UpdatePasswordHandler: IdentityResult.Success
end
deactivate UserMgr
alt Current password is incorrect
UpdatePasswordHandler -> Mediator: throw UnauthorizedException\n"Current password is incorrect"
Mediator -> API: propagate
API -> User: 401 Unauthorized
else !result.Succeeded (other failure)
UpdatePasswordHandler -> Mediator: throw ConflictException
Mediator -> API: propagate
API -> User: 409 Conflict
else result.Succeeded
UpdatePasswordHandler --> Mediator: UpdatePasswordPayload(\n user.Id, DateTime.UtcNow)
deactivate UpdatePasswordHandler
Mediator --> API: UpdatePasswordPayload
deactivate Mediator
API -> User: 200 OK\n{message: "Password updated successfully.",\npayload: {...}}
end
end
end
deactivate API
== Update Profile Flow ==
User -> API: PATCH /api/auth/profile\n{firstName, lastName, dateOfBirth}\n(requires Bearer access token)
activate API
API -> Mediator: Send(UpdateProfileCommand(\n GetAuthenticatedUserId(),\n firstName, lastName, dateOfBirth))
activate Mediator
note right of Mediator
ValidationBehavior runs UpdateProfileValidator
(same name/DateOfBirth rules as registration)
end note
alt Validation fails
Mediator -> API: throws FluentValidation.ValidationException
API -> User: 400 Bad Request
else Validation succeeds
Mediator -> UpdateProfileHandler: Handle(command)
activate UpdateProfileHandler
UpdateProfileHandler -> UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr -> UserStore: FindByIdAsync(userAccountId)
UserStore --> UserMgr: ApplicationUser or null
UserMgr --> UpdateProfileHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
UpdateProfileHandler -> Mediator: throw NotFoundException
Mediator -> API: propagate
API -> User: 404 Not Found
else Account found
UpdateProfileHandler -> UpdateProfileHandler: user.FirstName = ...\nuser.LastName = ...\nuser.DateOfBirth = ...
note right of UpdateProfileHandler
First/last name and date of birth aren't
tracked by any Identity store interface -
mutated directly on the ApplicationUser,
then saved via UserManager.UpdateAsync.
end note
UpdateProfileHandler -> UserMgr: UpdateAsync(user)
activate UserMgr
UserMgr -> UserStore: UpdateAsync(user)
UserStore -> DB: UPDATE dbo.UserAccount\nSET FirstName = @FirstName,\n LastName = @LastName,\n DateOfBirth = @DateOfBirth, ...
UserStore --> UserMgr: IdentityResult
UserMgr --> UpdateProfileHandler: IdentityResult
deactivate UserMgr
alt !result.Succeeded
UpdateProfileHandler -> Mediator: throw ConflictException
Mediator -> API: propagate
API -> User: 409 Conflict
else result.Succeeded
UpdateProfileHandler --> Mediator: UpdateProfilePayload(\n user.Id, firstName,\n lastName, dateOfBirth)
deactivate UpdateProfileHandler
Mediator --> API: UpdateProfilePayload
deactivate Mediator
API -> User: 200 OK\n{message: "Profile updated successfully.",\npayload: {...}}
end
end
end
deactivate API
== Delete Account Flow ==
User -> API: DELETE /api/auth/account\n(requires Bearer access token)
activate API
API -> Mediator: Send(DeleteAccountCommand(\n GetAuthenticatedUserId()))
activate Mediator
Mediator -> DeleteAccountHandler: Handle(command)
activate DeleteAccountHandler
DeleteAccountHandler -> UserMgr: FindByIdAsync(userAccountId)
activate UserMgr
UserMgr -> UserStore: FindByIdAsync(userAccountId)
UserStore --> UserMgr: ApplicationUser or null
UserMgr --> DeleteAccountHandler: ApplicationUser or null
deactivate UserMgr
alt Account not found
DeleteAccountHandler -> Mediator: throw NotFoundException
Mediator -> API: propagate
API -> User: 404 Not Found
else Account found
DeleteAccountHandler -> UserMgr: DeleteAsync(user)
activate UserMgr
UserMgr -> UserStore: DeleteAsync(user)
activate UserStore
UserStore -> DB: DELETE FROM dbo.UserAccount\nWHERE UserAccountID = @Id
note right of UserStore
UserCredential/UserVerification/UserAvatar
cascade on delete. Posts, comments, photos,
and follows use ON DELETE NO ACTION - if any
reference this account, the DELETE throws a
foreign key violation (SQL error 547).
end note
alt Foreign key violation (dependent records exist)
UserStore --> UserMgr: IdentityResult.Failed\n(AccountHasDependentRecords)
else Delete succeeds
UserStore --> UserMgr: IdentityResult.Success
end
deactivate UserStore
UserMgr --> DeleteAccountHandler: IdentityResult
deactivate UserMgr
alt Account has dependent records
DeleteAccountHandler -> Mediator: throw ConflictException\n"Account cannot be deleted while it still\nhas associated posts, comments, photos,\nor follows."
Mediator -> API: propagate
API -> User: 409 Conflict
else !result.Succeeded (other failure)
DeleteAccountHandler -> Mediator: throw ConflictException
Mediator -> API: propagate
API -> User: 409 Conflict
else result.Succeeded
DeleteAccountHandler --> Mediator: (void)
deactivate DeleteAccountHandler
Mediator --> API: (void)
deactivate Mediator
API -> User: 200 OK\n{message: "Account deleted successfully."}
end
end
deactivate API

View File

@@ -190,11 +190,12 @@ Beer ||--o{ BeerComment : "has"
User ||--o{ BeerComment : "commented"
note bottom of User
Backed by Features.Auth/Features.UserManagement's
Dapper repositories (AuthRepository,
UserAccountRepository) - registration, credential
rotation, and verification each issue their own
inline SQL, no stored procedures
Auth reads/writes (registration, login, credential
rotation, verification) go through ASP.NET Core
Identity's UserManager<ApplicationUser>, backed by
Features.Auth's DapperUserStore - inline SQL, no
stored procedures. Profile data still goes through
Features.UserManagement's UserAccountRepository.
end note
note bottom of Brewery

View File

@@ -67,6 +67,17 @@ package "Development Environment\n(docker-compose.dev.yaml)" #E3F2FD {
end note
}
node "Mailpit\n(axllent/mailpit)" as DevMailpit {
portin "8025 (Web UI)" as DevMailpitWeb
portin "1025 (SMTP)" as DevMailpitSmtp
note right
Local SMTP catcher for dev email testing.
Environment:
- MP_SMTP_AUTH_ACCEPT_ANY=1
- MP_SMTP_AUTH_ALLOW_INSECURE=1
end note
}
}
package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
@@ -132,6 +143,122 @@ package "Test Environment\n(docker-compose.test.yaml)" #FFF3E0 {
}
package "Production Environment\n(docker-compose.prod.yaml)" #FCE4EC {
node "SQL Server\n(mcr.microsoft.com/mssql/server:2022-latest)" as ProdDB {
database "Biergarten\nDatabase" as ProdDBInner
note right
Environment:
- ACCEPT_EULA=Y
- SA_PASSWORD=***
- MSSQL_PID=Express
Volumes:
- sqlserverdata-prod
No host port published.
end note
}
node "Migrations\n(run-once)" as ProdMig {
component "Database.Migrations" as ProdMig1
note bottom
Depends on: sql-server (healthy)
No seed step in production.
end note
}
node "API Container\n(API.Core)" as ProdAPI {
component "ASP.NET Core 10" as ProdAPI1
portin "8080:8080 (HTTP)" as ProdPort1
portin "8081:8081 (HTTPS)" as ProdPort2
note right
Environment:
- ASPNETCORE_ENVIRONMENT=Production
- DB_SERVER/NAME/USER/PASSWORD
- ACCESS/REFRESH/CONFIRMATION_TOKEN_SECRET
- WEBSITE_BASE_URL
Depends on: sql-server (healthy) -
does not wait on migrations/seed.
end note
}
ProdMig --> ProdDB : 1. Run migrations
ProdAPI --> ProdDB : 2. Connect & serve
ProdMig .up.> ProdDB : depends_on
ProdAPI .up.> ProdDB : depends_on
note bottom of ProdDB
<b>Prod Network (bridge: prodnet)</b>
Isolated from dev/test networks.
Volumes: sqlserverdata-prod,
nuget-cache-prod
end note
}
package "Database-only Environment\n(docker-compose.db.yaml)" #EDE7F6 {
node "SQL Server" as DbOnlyDB {
database "Biergarten\nDatabase" as DbOnlyDBInner {
portin "1434"
}
}
node "Migrations\n(run-once)" as DbOnlyMig {
component "Database.Migrations"
}
node "Seed\n(run-once)" as DbOnlySeed {
component "Database.Seed"
}
note bottom of DbOnlyDB
Same devnet volumes/images as
docker-compose.dev.yaml, minus
api.core and mailpit - for working
against the database without
running the API.
end note
DbOnlyMig --> DbOnlyDB : 1. Run migrations
DbOnlySeed --> DbOnlyDB : 2. Seed data
DbOnlyMig .up.> DbOnlyDB : depends_on
DbOnlySeed .up.> DbOnlyMig : depends_on
}
package "Minimal Environment\n(docker-compose.min.yaml)" #FFF9C4 {
node "SQL Server" as MinDB {
database "Biergarten\nDatabase" as MinDBInner {
portin "1433"
}
}
node "Migrations\n(run-once)" as MinMig {
component "Database.Migrations"
}
node "Mailpit" as MinMailpit {
portin "8025 / 1025"
}
node "Seed\n(run-once)" as MinSeed {
component "Database.Seed"
}
note bottom of MinDB
Database + mailpit only, no API
container - for frontend development
against an API.Core process run
outside Docker.
end note
MinMig --> MinDB : 1. Run migrations
MinSeed --> MinDB : 2. Seed data
MinMig .up.> MinDB : depends_on
MinSeed .up.> MinMig : depends_on
}
folder "test-results/\n(mounted volume)" as Results {
file "api-specs/\n results.trx" as Result1
file "Features.Auth.Tests.trx" as Result2
@@ -156,6 +283,7 @@ Host --> DevAPI : http://localhost:8080
DevMig --> DevDB : 1. Run migrations
DevSeed --> DevDB : 2. Seed data
DevAPI --> DevDB : 3. Connect & serve
DevAPI ..> DevMailpit : 4. SMTP_HOST/PORT
DevMig .up.> DevDB : depends_on
DevSeed .up.> DevMig : depends_on
DevAPI .up.> DevSeed : depends_on
@@ -203,18 +331,4 @@ note top of Specs
Results aggregated
end note
' Production note
note as ProductionNote
<b>Production Deployment (not shown):</b>
Would include:
• Azure SQL Database / AWS RDS
• Azure Container Apps / ECS
• Azure Key Vault for secrets
• Application Insights / CloudWatch
• Load balancer
• HTTPS termination
• CDN for static assets
end note
@enduml

View File

@@ -34,7 +34,7 @@ representation.
**Services**:
```yaml
sqlserver # SQL Server 2022 (port 1433)
sqlserver # SQL Server 2022 (port 1434)
database.migrations # DbUp migrations
database.seed # Seed initial data
api.core # Web API (ports 8080, 8081)
@@ -52,7 +52,7 @@ docker compose --env-file web/.env.dev -f web/docker-compose.dev.yaml up -d
- API Swagger: http://localhost:8080/swagger
- Health Check: http://localhost:8080/health
- Mailpit UI: http://localhost:8025
- SQL Server: localhost:1433 (sa credentials from .env.dev)
- SQL Server: localhost:1434 (sa credentials from .env.dev)
**Stop Environment**:
@@ -128,6 +128,51 @@ api.core # Production API
docker compose --env-file web/.env.prod -f web/docker-compose.prod.yaml up -d
```
### 4. Database only (`docker-compose.db.yaml`)
**Purpose**: Run just the database for local backend development outside Docker
(for example, `dotnet run` against a containerized SQL Server)
**Services**:
```yaml
sqlserver # SQL Server 2022 (port 1434)
database.migrations # DbUp migrations
database.seed # Seed initial data
```
No `api.core` or `mailpit` container. Uses `.env.dev`, same as development.
**Start**:
```bash
docker compose --env-file web/.env.dev -f web/docker-compose.db.yaml up -d
```
### 5. Minimal (`docker-compose.min.yaml`)
**Purpose**: Local dev where the API runs outside Docker but the database and
mail capture still run in containers
**Services**:
```yaml
sqlserver # SQL Server 2022 (port 1433)
database.migrations # DbUp migrations
database.seed # Seed initial data
mailpit # Local dev SMTP server + UI (port 8025)
```
No `api.core` container. Uses `.env.local` instead of `.env.dev`, and exposes
SQL Server on the standard port 1433 (`docker-compose.db.yaml` and
`docker-compose.dev.yaml` both use 1434).
**Start**:
```bash
docker compose --env-file web/.env.local -f web/docker-compose.min.yaml up -d
```
## Service dependencies
Docker Compose manages startup order using health checks:

View File

@@ -30,6 +30,25 @@ Environment-specific `.env` files loaded via `env_file:` in docker-compose.yaml:
- `.env.test` - Testing
- `.env.prod` - Production
### Generating an env file
`web/generate-env.sh` copies `.env.example` to a target file and replaces the
secret-bearing values (`DB_PASSWORD`, `ACCESS_TOKEN_SECRET`,
`REFRESH_TOKEN_SECRET`, `CONFIRMATION_TOKEN_SECRET`, `SMTP_USERNAME`,
`SMTP_PASSWORD`) with freshly randomized ones via `openssl`. Non-secret values
are left untouched. The output file is written with `600` permissions.
```bash
cd web
./generate-env.sh .env.dev
```
Pass the output path, and optionally a template path, as arguments (default
output: `.env`, default template: `.env.example`). The script prompts before
overwriting an existing file. Randomized `SMTP_USERNAME`/`SMTP_PASSWORD` are dev
placeholders; replace them with real provider credentials for
staging/production.
## Backend variables (.NET API)
### Database connection
@@ -88,13 +107,15 @@ WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
**Generate Secrets**:
**macOS/Linux - Generate a cryptographically secure base64 secret (127 raw bytes)**
**macOS/Linux - Generate a cryptographically secure base64 secret (127 raw
bytes)**
```bash
openssl rand -base64 127
```
**Windows - PowerShell 5.1 compatible (cryptographically secure, 127 raw bytes)**
**Windows - PowerShell 5.1 compatible (cryptographically secure, 127 raw
bytes)**
```powershell
$bytes = New-Object byte[] 127

View File

@@ -21,6 +21,16 @@ cd the-biergarten-app
### 2. Configure backend environment variables
Generate `.env.dev` with freshly randomized secrets:
```bash
cd web
./generate-env.sh .env.dev
cd ..
```
Or copy the template and fill in values manually:
```bash
cp web/.env.example web/.env.dev
```
@@ -38,7 +48,9 @@ CONFIRMATION_TOKEN_SECRET=<generated>
WEBSITE_BASE_URL=http://localhost:3000
```
See [Environment Variables](environment-variables.md) for the full list.
See [Environment Variables](environment-variables.md) for the full list, and its
[Generating an env file](environment-variables.md#generating-an-env-file)
section for details on `generate-env.sh`.
### 3. Start the backend stack

View File

@@ -1,4 +1,3 @@
# Testing
This document describes the testing strategy and how to run tests for The
@@ -128,8 +127,8 @@ npm run test:storybook:playwright
### Running Storybook tests with a visible browser
Both of the above run headless by default. To watch the browser while
debugging a failure:
Both of the above run headless by default. To watch the browser while debugging
a failure:
```bash
# Playwright Storybook suite
@@ -158,6 +157,9 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
- Refresh token exchange
- JWT token generation, validation, and claims handling
- Invalid credentials and 404 error responses (via API.Specs)
- Account management: username, email, password, and profile updates, and
account deletion (blocked while dependent posts, comments, photos, or follows
exist)
**Features.Breweries.Tests**:
@@ -259,7 +261,12 @@ Features.Auth.Tests/
│ ├── RegisterUserHandlerTests.cs
│ ├── ConfirmUserHandlerTests.cs
│ ├── ResendConfirmationEmailHandlerTests.cs
│ └── RefreshTokenHandlerTests.cs
│ ├── RefreshTokenHandlerTests.cs
│ ├── UpdateUsernameHandlerTests.cs
│ ├── UpdateEmailHandlerTests.cs
│ ├── UpdatePasswordHandlerTests.cs
│ ├── UpdateProfileHandlerTests.cs
│ └── DeleteAccountHandlerTests.cs
├── Queries/
│ └── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
└── Services/

View File

@@ -112,6 +112,21 @@ middleware) also depends on it directly.
- `POST /api/auth/confirm/resend?userId=...` - Resend the confirmation email
- `POST /api/auth/refresh` - Refresh access token
Account management (require a valid access token; user ID comes from the token,
never the request body):
- `PATCH /api/auth/username` - Change the authenticated user's username
- `PATCH /api/auth/email` - Change the authenticated user's email address;
resets `EmailConfirmed` to false, so the user must re-confirm via
`POST /api/auth/confirm/resend`
- `PATCH /api/auth/password` - Change the authenticated user's password;
requires the current password
- `PATCH /api/auth/profile` - Update the authenticated user's profile (first
name, last name, date of birth)
- `DELETE /api/auth/account` - Permanently delete the authenticated user's
account; blocked while the account still has associated posts, comments,
photos, or follows
## Validation security
### Token secrets

View File

@@ -1,6 +1,5 @@
#!/bin/bash
# Check for -y flag
SKIP_CONFIRM=false
if [[ "$1" == "-y" ]]; then
SKIP_CONFIRM=true

View File

@@ -67,7 +67,6 @@ class BoundedChannel {
void Close();
};
// Include the template implementation
#include "bounded_channel.tcc"
#endif // BIERGARTEN_PIPELINE_INCLUDES_CONCURRENCY_BOUNDED_CHANNEL_H_

View File

@@ -24,7 +24,6 @@ using SqliteDatabaseHandle = std::unique_ptr<sqlite3, SqliteDatabaseDeleter>;
using SqliteStatementHandle =
std::unique_ptr<sqlite3_stmt, SqliteStatementDeleter>;
// Represents a parameter that is bound to a prepared SQLite statement.
// N.B. indices are 1 based.
template <typename T>
struct BoundParam {

View File

@@ -15,9 +15,6 @@
/**
* @brief Interface for UTC timestamp providers.
*
* @todo Yagni, consider removing this and just use SystemDateTimeProvider
* directly
*/
class IDateTimeProvider {
public:

View File

@@ -9,12 +9,10 @@ PROMPT_DIR="/app/prompts"
echo "--- Starting Biergarten Pipeline Environment Check ---"
# Ensure directories exist
mkdir -p "$OUTPUT_DIR"
mkdir -p "$(dirname "$LOG_PATH")"
mkdir -p "$(dirname "$MODEL_PATH")"
# Download model if missing
if [ ! -f "$MODEL_PATH" ]; then
echo "Model not found. Downloading (this may take a while)..."
@@ -25,16 +23,13 @@ if [ ! -f "$MODEL_PATH" ]; then
echo "Download complete."
fi
# Verify model exists
if [ ! -f "$MODEL_PATH" ]; then
echo "ERROR: Model still not found after download attempt."
exit 1
fi
# Default GPU layers
GL_LAYERS="${BIERGARTEN_GL_LAYERS:-40}"
# Build args
ARGS=(
"--model" "$MODEL_PATH"
"--prompt-dir" "$PROMPT_DIR"
@@ -43,14 +38,12 @@ ARGS=(
"--n-gpu-layers" "$GL_LAYERS"
)
# Optional params
[[ -n "$BIERGARTEN_TEMPERATURE" ]] && ARGS+=("--temperature" "$BIERGARTEN_TEMPERATURE")
[[ -n "$BIERGARTEN_TOP_P" ]] && ARGS+=("--top-p" "$BIERGARTEN_TOP_P")
[[ -n "$BIERGARTEN_TOP_K" ]] && ARGS+=("--top-k" "$BIERGARTEN_TOP_K")
[[ -n "$BIERGARTEN_N_CTX" ]] && ARGS+=("--n-ctx" "$BIERGARTEN_N_CTX")
[[ -n "$BIERGARTEN_SEED" ]] && ARGS+=("--seed" "$BIERGARTEN_SEED")
# Extra args
[[ -n "$BIERGARTEN_EXTRA_ARGS" ]] && ARGS+=($BIERGARTEN_EXTRA_ARGS)
echo "--- Executing: $EXECUTABLE ${ARGS[*]} ---"

View File

@@ -17,9 +17,6 @@
#include <boost/json.hpp>
namespace {
/**
* String trimming: removes leading and trailing whitespace
*/
std::string Trim(std::string_view value) {
constexpr std::string_view whitespace = " \t\n\r\f\v";
const size_t first_index = value.find_first_not_of(whitespace);

View File

@@ -17,7 +17,6 @@ using namespace boost;
std::string WikipediaEnrichmentService::FetchExtract(std::string_view query) {
const std::string cache_key(query);
// 1. Cache Lookup
if (const auto cache_it = this->extract_cache_.find(cache_key);
cache_it != this->extract_cache_.end()) {
if (logger_) {
@@ -42,7 +41,6 @@ std::string WikipediaEnrichmentService::FetchExtract(std::string_view query) {
std::this_thread::sleep_for(1s);
}
// 2. Parse JSON
system::error_code ec;
json::value doc = json::parse(body, ec);
@@ -57,7 +55,6 @@ std::string WikipediaEnrichmentService::FetchExtract(std::string_view query) {
return {};
}
// 3. Safe Extraction
const json::object* obj = doc.if_object();
if (obj == nullptr) {
if (logger_) {
@@ -118,7 +115,6 @@ std::string WikipediaEnrichmentService::FetchExtract(std::string_view query) {
const json::object& page = page_val.get_object();
// Handle 404/Missing status
if (page.contains("missing")) {
if (logger_) {
logger_->Log({.level = LogLevel::Warn,
@@ -145,7 +141,6 @@ std::string WikipediaEnrichmentService::FetchExtract(std::string_view query) {
return {};
}
// 4. Success
std::string extract(extract_ptr->as_string());
if (logger_) {
logger_->Log(

View File

@@ -15,8 +15,6 @@
#include <string_view>
#include <utility>
// PromptDirectory
PromptDirectory::PromptDirectory(const std::filesystem::path& prompt_dir)
: PromptDirectory(prompt_dir, nullptr) {}
@@ -25,14 +23,12 @@ PromptDirectory::PromptDirectory(const std::filesystem::path& prompt_dir,
: prompt_dir_(prompt_dir), logger_(std::move(logger)) {
std::error_code ec;
// Directory must exist.
if (!std::filesystem::exists(prompt_dir_, ec) || ec) {
throw std::runtime_error(
"PromptDirectory: prompt directory does not exist: " +
prompt_dir_.string());
}
// Path must be a directory, not a file.
if (!std::filesystem::is_directory(prompt_dir_, ec) || ec) {
throw std::runtime_error(
"PromptDirectory: prompt directory path is not a directory: " +
@@ -96,7 +92,6 @@ std::string PromptDirectory::Load(std::string_view key) {
key_str, file_path.string(), content.size())});
}
// Cache the loaded content for future calls.
cache_.emplace(key_str, content);
return content;
}

View File

@@ -25,12 +25,11 @@ std::string XegerGenerator::Run(const Node& root) {
void XegerGenerator::Visit(const Literal& node) { out_ += node.ch; }
void XegerGenerator::Visit(const AnyChar&) {
static constexpr int kPrintableLow = ' '; // empty space - character 32
static constexpr int kPrintableHigh = '~'; // tilde - character 126
static constexpr int kPrintableLow = ' ';
static constexpr int kPrintableHigh = '~';
std::uniform_int_distribution<int> dist(kPrintableLow, kPrintableHigh);
// assert that the rand number is in the char range
bool inCharRange = dist.min() >= std::numeric_limits<char>::min() &&
dist.max() <= std::numeric_limits<char>::max();
assert(inCharRange && "Random number is out of char range");

View File

@@ -39,7 +39,6 @@ public class JwtAuthenticationHandler(
if (string.IsNullOrWhiteSpace(secret))
return AuthenticateResult.Fail("JWT secret is not configured");
// Check if Authorization header exists
if (!Request.Headers.TryGetValue("Authorization", out StringValues authHeaderValue))
return AuthenticateResult.Fail("Authorization header is missing");

View File

@@ -11,13 +11,13 @@ namespace API.Core.Controllers;
/// </remarks>
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
[Route("error")] // required
[Route("error")]
public class NotFoundController : ControllerBase
{
/// <summary>
/// Returns a 404 response for any request that did not match a route.
/// </summary>
[HttpGet("404")] //required
[HttpGet("404")]
public IActionResult Handle404()
{
return NotFound(new { message = "Route not found." });

View File

@@ -1,5 +1,3 @@
// API.Core/Filters/GlobalExceptionFilter.cs
using Domain.Exceptions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;

View File

@@ -61,7 +61,6 @@ builder.Services.AddSwaggerGen(options =>
});
builder.Services.AddOpenApi();
// Add FluentValidation
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly);
@@ -80,7 +79,6 @@ builder.Services.AddMediatR(cfg =>
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});
// Add health checks
builder.Services.AddHealthChecks();
// Configure logging for container output
@@ -89,8 +87,6 @@ builder.Logging.AddConsole();
if (!builder.Environment.IsProduction())
builder.Logging.AddDebug();
// Configure Dependency Injection -------------------------------------------------------------------------------------
builder.Services.AddInfrastructureSql();
builder.Services.AddFeaturesBreweries();
@@ -101,10 +97,8 @@ builder.Services.AddFeaturesEmails();
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
// Register the exception filter
builder.Services.AddScoped<GlobalExceptionFilter>();
// Configure JWT Authentication
builder
.Services.AddAuthentication("JWT")
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>("JWT", options => { });
@@ -128,7 +122,6 @@ app.MapHealthChecks("/health");
app.MapControllers();
app.MapFallbackToController("Handle404", "NotFound");
// Graceful shutdown handling
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
lifetime.ApplicationStopping.Register(() =>
{

View File

@@ -264,7 +264,6 @@ public class AuthSteps(ScenarioContext scenario)
public async Task WhenISubmitALoginRequestUsingAgetRequest()
{
HttpClient client = GetClient();
// testing GET
HttpRequestMessage requestMessage = new(HttpMethod.Get, "/api/auth/login")
{
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
@@ -703,7 +702,6 @@ public class AuthSteps(ScenarioContext scenario)
[Given("I have an access token signed with the wrong secret")]
public void GivenIHaveAnAccessTokenSignedWithTheWrongSecret()
{
// Create a token with a different secret
scenario["accessToken"] =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
}

View File

@@ -15,7 +15,6 @@ public class TestApiFactory : WebApplicationFactory<Program>
builder.ConfigureServices(services =>
{
// Replace the real email provider with mock for testing
ServiceDescriptor? emailProviderDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailProvider)
);
@@ -25,7 +24,6 @@ public class TestApiFactory : WebApplicationFactory<Program>
services.AddScoped<IEmailProvider, MockEmailProvider>();
// Replace the real email dispatcher with mock for testing
ServiceDescriptor? emailDispatcherDescriptor = services.SingleOrDefault(d =>
d.ServiceType == typeof(IEmailDispatcher)
);

View File

@@ -11,8 +11,6 @@ public class ConflictException(string message) : Exception(message);
/// </summary>
public class NotFoundException(string message) : Exception(message);
// Domain.Exceptions/UnauthorizedException.cs
/// <summary>
/// The exception that is thrown when authentication fails or is required. Maps to HTTP 401 Unauthorized.
/// </summary>

View File

@@ -62,12 +62,10 @@ public class TokenServiceRefreshTests
ApplicationUser user = new() { Id = userId, UserName = username };
// Mock the validation of refresh token
_tokenInfraMock
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
.ReturnsAsync(principal);
// Mock the generation of new tokens
_tokenInfraMock
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
.Returns(

View File

@@ -66,7 +66,6 @@ public class RegisterUserHandler(
{
await Console.Error.WriteLineAsync(ex.Message);
Console.WriteLine("Could not send email.");
// ignored
}
return new RegistrationPayload(user.Id, user.UserName, refreshToken, accessToken, emailSent);

View File

@@ -20,7 +20,6 @@ public class TokenService : ITokenService
private readonly ITokenInfrastructure _tokenInfrastructure;
private readonly UserManager<ApplicationUser> _userManager;
/// <summary>Initializes a new instance of the <see cref="TokenService" /> class.</summary>
/// <exception cref="InvalidOperationException">
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
/// <c>CONFIRMATION_TOKEN_SECRET</c> configuration values are not set.

View File

@@ -3,9 +3,6 @@ using MediatR;
namespace Features.UserManagement.Commands.UpdateUser;
/// <summary>
/// Handles <see cref="UpdateUserCommand" />.
/// </summary>
public class UpdateUserHandler(IUserAccountRepository repository)
: IRequestHandler<UpdateUserCommand>
{

View File

@@ -4,9 +4,6 @@ using MediatR;
namespace Features.UserManagement.Queries.GetAllUsers;
/// <summary>
/// Handles <see cref="GetAllUsersQuery" />.
/// </summary>
public class GetAllUsersHandler(IUserAccountRepository repository)
: IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>>
{

View File

@@ -5,9 +5,6 @@ using MediatR;
namespace Features.UserManagement.Queries.GetUserById;
/// <summary>
/// Handles <see cref="GetUserByIdQuery" />.
/// </summary>
public class GetUserByIdHandler(IUserAccountRepository repository)
: IRequestHandler<GetUserByIdQuery, UserAccount>
{

View File

@@ -3,7 +3,4 @@ using MediatR;
namespace Features.UserManagement.Queries.GetUserById;
/// <summary>
/// Requests the user account with the given ID.
/// </summary>
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;

View File

@@ -93,14 +93,12 @@ public class SmtpEmailProvider : IEmailProvider
try
{
// Determine the SecureSocketOptions based on SSL setting
SecureSocketOptions secureSocketOptions = _useSsl
? SecureSocketOptions.StartTls
: SecureSocketOptions.None;
await client.ConnectAsync(_host, _port, secureSocketOptions);
// Authenticate if credentials are provided
if (!string.IsNullOrEmpty(_username) && !string.IsNullOrEmpty(_password))
await client.AuthenticateAsync(_username, _password);

View File

@@ -12,9 +12,6 @@ public interface ITokenInfrastructure
/// </summary>
string GenerateJwt(Guid userId, string username, DateTime expiry, string secret);
/// <summary>
/// Validates a JWT and returns the resulting claims principal.
/// </summary>
/// <exception cref="Domain.Exceptions.UnauthorizedException">Thrown when the token is invalid, expired, or fails validation.</exception>
Task<ClaimsPrincipal> ValidateJwtAsync(string token, string secret);
}

View File

@@ -26,7 +26,6 @@ public class DefaultSqlConnectionFactory(IConfiguration configuration) : ISqlCon
/// <exception cref="InvalidOperationException">Thrown when no connection string can be resolved from any source.</exception>
private static string GetConnectionString(IConfiguration configuration)
{
// Check for full connection string first
string? fullConnectionString = configuration["DB_CONNECTION_STRING"];
if (!string.IsNullOrEmpty(fullConnectionString))
return fullConnectionString;

View File

@@ -38,9 +38,6 @@ public static class SqlConnectionStringHelper
return builder.ConnectionString;
}
/// <summary>
/// Builds a connection string to the master database using configuration.
/// </summary>
public static string BuildMasterConnectionString(IConfiguration configuration)
{
return BuildConnectionString(configuration, "master");

View File

@@ -5,10 +5,8 @@ namespace Shared.Contracts;
/// </summary>
public record ResponseBody<T>
{
/// <summary>Gets the human-readable message describing the response.</summary>
public required string Message { get; init; }
/// <summary>Gets the response's data payload.</summary>
public required T Payload { get; init; }
}
@@ -17,6 +15,5 @@ public record ResponseBody<T>
/// </summary>
public record ResponseBody
{
/// <summary>Gets the human-readable message describing the response.</summary>
public required string Message { get; init; }
}

View File

@@ -2,6 +2,5 @@ import * as a11yAddonAnnotations from '@storybook/addon-a11y/preview';
import { setProjectAnnotations } from '@storybook/react-vite';
import * as projectAnnotations from './preview';
// This is an important step to apply the right configuration when testing your stories.
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);

View File

@@ -53,7 +53,6 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
<p className="text-sm text-base-content/70">{vibe}</p>
</div>
{/* Core palette */}
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Core
@@ -69,7 +68,6 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
</div>
</div>
{/* Status tokens */}
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Status
@@ -82,7 +80,6 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
</div>
</div>
{/* Content tokens (custom) */}
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
Content

View File

@@ -21,7 +21,6 @@ export default defineConfig({
{
extends: true,
plugins: [
// The plugin will run tests for the stories defined in your Storybook config
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
storybookTest({
configDir: path.join(dirname, '.storybook'),