mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
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:
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Check for -y flag
|
||||
SKIP_CONFIRM=false
|
||||
if [[ "$1" == "-y" ]]; then
|
||||
SKIP_CONFIRM=true
|
||||
|
||||
@@ -67,7 +67,6 @@ class BoundedChannel {
|
||||
void Close();
|
||||
};
|
||||
|
||||
// Include the template implementation
|
||||
#include "bounded_channel.tcc"
|
||||
|
||||
#endif // BIERGARTEN_PIPELINE_INCLUDES_CONCURRENCY_BOUNDED_CHANNEL_H_
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -15,9 +15,6 @@
|
||||
|
||||
/**
|
||||
* @brief Interface for UTC timestamp providers.
|
||||
*
|
||||
* @todo Yagni, consider removing this and just use SystemDateTimeProvider
|
||||
* directly
|
||||
*/
|
||||
class IDateTimeProvider {
|
||||
public:
|
||||
|
||||
@@ -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[*]} ---"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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." });
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// API.Core/Filters/GlobalExceptionFilter.cs
|
||||
|
||||
using Domain.Exceptions;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
|
||||
@@ -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(() =>
|
||||
{
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -3,9 +3,6 @@ using MediatR;
|
||||
|
||||
namespace Features.UserManagement.Commands.UpdateUser;
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="UpdateUserCommand" />.
|
||||
/// </summary>
|
||||
public class UpdateUserHandler(IUserAccountRepository repository)
|
||||
: IRequestHandler<UpdateUserCommand>
|
||||
{
|
||||
|
||||
@@ -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>>
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
{
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user