mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-15 01:54:43 +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
|
end note
|
||||||
|
|
||||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||||
:QueryCitiesWithCountries();
|
:QueryLocations();
|
||||||
|
|
||||||
|#E2EBDC|JsonLoader|
|
|#E2EBDC|JsonLoader|
|
||||||
:JsonLoader::LoadLocations("locations.json");
|
:JsonLoader::LoadLocations("locations.json");
|
||||||
@@ -118,7 +118,7 @@ while (For each EnrichedCity?) is (Remaining cities)
|
|||||||
|#E5EDE1|DataGenerator|
|
|#E5EDE1|DataGenerator|
|
||||||
if (Generator Mode) then (MockGenerator)
|
if (Generator Mode) then (MockGenerator)
|
||||||
:DeterministicHash & Format;
|
:DeterministicHash & Format;
|
||||||
else (LlamaGenerator)
|
elseif (Generator Mode) then (LlamaGenerator)
|
||||||
:prompt_directory_->Load("USER_GENERATION");
|
:prompt_directory_->Load("USER_GENERATION");
|
||||||
note right
|
note right
|
||||||
Resolves to USER_GENERATION.md inside --prompt-dir.
|
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;
|
:Append validation error to retry prompt;
|
||||||
endif
|
endif
|
||||||
repeat while (Attempt < 3?) is (yes)
|
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
|
endif
|
||||||
|
|
||||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||||
if (Generation successful?) then (yes)
|
if (Generation successful?) then (yes)
|
||||||
|
:GetRandomCoordsWithinRange(city_centre, 5km, rng);
|
||||||
:BuildEmail(name, used_email_local_parts);
|
:BuildEmail(name, used_email_local_parts);
|
||||||
:GenerateDateOfBirth(rng);
|
:GenerateDateOfBirth(rng);
|
||||||
note right
|
note right
|
||||||
email and date_of_birth are generated
|
email and date_of_birth are generated
|
||||||
programmatically, never LLM-authored.
|
programmatically, never LLM-authored.
|
||||||
|
Generation + coordinate jitter run inside
|
||||||
|
one try/catch per record. UserRecord.address
|
||||||
|
now carries {city, longitude, latitude}.
|
||||||
end note
|
end note
|
||||||
|
|
||||||
|#E0EAE0|SqliteExportService|
|
|#E0EAE0|SqliteExportService|
|
||||||
@@ -171,7 +199,7 @@ endwhile (Done)
|
|||||||
while (For each EnrichedCity?) is (Remaining cities)
|
while (For each EnrichedCity?) is (Remaining cities)
|
||||||
if (Generator Mode) then (MockGenerator)
|
if (Generator Mode) then (MockGenerator)
|
||||||
:DeterministicHash & Format;
|
:DeterministicHash & Format;
|
||||||
else (LlamaGenerator)
|
elseif (Generator Mode) then (LlamaGenerator)
|
||||||
:PrepareRegionContext;
|
:PrepareRegionContext;
|
||||||
:prompt_directory_->Load("BREWERY_GENERATION");
|
:prompt_directory_->Load("BREWERY_GENERATION");
|
||||||
note right
|
note right
|
||||||
@@ -191,10 +219,40 @@ while (For each EnrichedCity?) is (Remaining cities)
|
|||||||
max_tokens is fixed across retries —
|
max_tokens is fixed across retries —
|
||||||
it is not raised on truncation.
|
it is not raised on truncation.
|
||||||
end note
|
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
|
endif
|
||||||
|
|
||||||
|#EAF0E8|BiergartenPipelineOrchestrator|
|
|#EAF0E8|BiergartenPipelineOrchestrator|
|
||||||
if (Generation successful?) then (yes)
|
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|
|
|#E0EAE0|SqliteExportService|
|
||||||
:ProcessRecord(GeneratedBrewery);
|
:ProcessRecord(GeneratedBrewery);
|
||||||
if (Location in cache?) then (yes)
|
if (Location in cache?) then (yes)
|
||||||
|
|||||||
@@ -35,12 +35,20 @@ class BiergartenPipelineOrchestrator {
|
|||||||
- generated_breweries_ : std::vector<BreweryRecord>
|
- generated_breweries_ : std::vector<BreweryRecord>
|
||||||
- generated_users_ : std::vector<UserRecord>
|
- generated_users_ : std::vector<UserRecord>
|
||||||
+ Run() : bool
|
+ Run() : bool
|
||||||
- QueryCitiesWithCountries() : std::vector<Location>
|
- QueryLocations() : std::vector<City>
|
||||||
- GenerateBreweries(cities : std::span<const EnrichedCity>) : void
|
- GenerateBreweries(cities : std::span<const EnrichedCity>) : void
|
||||||
- GenerateUsers(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>> {
|
class LogLevel <<enumeration>> {
|
||||||
Debug
|
Debug
|
||||||
Info
|
Info
|
||||||
@@ -50,6 +58,7 @@ class LogLevel <<enumeration>> {
|
|||||||
|
|
||||||
class PipelinePhase <<enumeration>> {
|
class PipelinePhase <<enumeration>> {
|
||||||
Startup
|
Startup
|
||||||
|
Enrichment
|
||||||
UserGeneration
|
UserGeneration
|
||||||
BreweryAndBeerGeneration
|
BreweryAndBeerGeneration
|
||||||
CheckinGeneration
|
CheckinGeneration
|
||||||
@@ -115,12 +124,12 @@ class HttpWebClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DataGenerator <<interface>> {
|
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
|
+ GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
|
||||||
}
|
}
|
||||||
|
|
||||||
class MockGenerator {
|
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
|
+ GenerateUser(city : const EnrichedCity&, persona : const UserPersona&, name : const Name&) : UserResult
|
||||||
- DeterministicHash(location : const Location&) : size_t
|
- DeterministicHash(location : const Location&) : size_t
|
||||||
- DeterministicHash(location : const Location&, persona : const UserPersona&, name : const Name&) : size_t
|
- DeterministicHash(location : const Location&, persona : const UserPersona&, name : const Name&) : size_t
|
||||||
@@ -139,6 +148,25 @@ class LlamaGenerator {
|
|||||||
- InferFormatted(...) : std::string
|
- 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>> {
|
interface IPromptFormatter <<interface>> {
|
||||||
+ Format(system_prompt : std::string_view, user_prompt : std::string_view) : std::string
|
+ Format(system_prompt : std::string_view, user_prompt : std::string_view) : std::string
|
||||||
}
|
}
|
||||||
@@ -252,10 +280,14 @@ WebClient <|.. HttpWebClient : implements
|
|||||||
|
|
||||||
DataGenerator <|.. MockGenerator : implements
|
DataGenerator <|.. MockGenerator : implements
|
||||||
DataGenerator <|.. LlamaGenerator : implements
|
DataGenerator <|.. LlamaGenerator : implements
|
||||||
|
DataGenerator <|.. OpenAIGenerator : implements
|
||||||
|
|
||||||
LlamaGenerator *-- IPromptFormatter : uses
|
LlamaGenerator *-- IPromptFormatter : uses
|
||||||
LlamaGenerator *-- IPromptDirectory : uses
|
LlamaGenerator *-- IPromptDirectory : uses
|
||||||
|
|
||||||
|
OpenAIGenerator *-- IPromptDirectory : uses
|
||||||
|
OpenAIGenerator *-- WebClient : uses
|
||||||
|
|
||||||
IPromptFormatter <|.. Gemma4JinjaPromptFormatter : implements
|
IPromptFormatter <|.. Gemma4JinjaPromptFormatter : implements
|
||||||
IPromptDirectory <|.. PromptDirectory : 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
|
box "Handler Layer" #LightBlue
|
||||||
participant "RegisterUserHandler" as RegHandler
|
participant "RegisterUserHandler" as RegHandler
|
||||||
participant "LoginHandler" as LoginHandler
|
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
|
participant "TokenService" as TokenSvc
|
||||||
end box
|
end box
|
||||||
box "Infrastructure Layer" #LightGreen
|
box "Infrastructure Layer" #LightGreen
|
||||||
participant "Argon2\nInfrastructure" as Argon2
|
participant "Argon2PasswordHasher" as Argon2
|
||||||
participant "JWT\nInfrastructure" as JWT
|
participant "JWT\nInfrastructure" as JWT
|
||||||
participant "Email\nProvider" as SMTP
|
participant "Email\nProvider" as SMTP
|
||||||
participant "Template\nProvider" as Template
|
participant "Template\nProvider" as Template
|
||||||
end box
|
end box
|
||||||
box "Repository / Cross-slice" #LightYellow
|
box "Repository / Cross-slice" #LightYellow
|
||||||
participant "AuthRepository" as AuthRepo
|
participant "DapperUserStore\n(IUserStore/IUserPasswordStore/\nIUserEmailStore)" as UserStore
|
||||||
participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
|
participant "EmailDispatcher\n(Features.Emails)" as EmailDispatcher
|
||||||
end box
|
end box
|
||||||
database "SQL Server" as DB
|
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 ==
|
== Registration Flow ==
|
||||||
|
|
||||||
User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
|
User -> API: POST /api/auth/register\n{username, firstName, lastName,\nemail, dateOfBirth, password}
|
||||||
@@ -51,31 +69,15 @@ else Validation succeeds
|
|||||||
Mediator -> RegHandler: Handle(command)
|
Mediator -> RegHandler: Handle(command)
|
||||||
activate RegHandler
|
activate RegHandler
|
||||||
|
|
||||||
RegHandler -> AuthRepo: GetUserByUsernameAsync(username)
|
RegHandler -> RegHandler: user = new UserAccount{...}\n.ToApplicationUser()
|
||||||
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 -> AuthRepo: GetUserByEmailAsync(email)
|
RegHandler -> UserMgr: CreateAsync(user, password)
|
||||||
activate AuthRepo
|
activate UserMgr
|
||||||
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Email = @Email
|
|
||||||
activate DB
|
|
||||||
DB --> AuthRepo: null (email doesn't exist)
|
|
||||||
deactivate DB
|
|
||||||
deactivate AuthRepo
|
|
||||||
|
|
||||||
alt Username or email already exists
|
UserMgr -> Argon2: HashPassword(user, password)
|
||||||
RegHandler -> Mediator: throw ConflictException
|
|
||||||
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
|
activate Argon2
|
||||||
note right of Argon2
|
note right of Argon2
|
||||||
|
Delegates to IPasswordInfrastructure.Hash:
|
||||||
Argon2id parameters:
|
Argon2id parameters:
|
||||||
- Salt: 16 bytes (128-bit)
|
- Salt: 16 bytes (128-bit)
|
||||||
- Memory: 64MB
|
- Memory: 64MB
|
||||||
@@ -83,39 +85,67 @@ else Validation succeeds
|
|||||||
- Parallelism: CPU count
|
- Parallelism: CPU count
|
||||||
- Hash output: 32 bytes
|
- Hash output: 32 bytes
|
||||||
end note
|
end note
|
||||||
Argon2 -> Argon2: Generate random salt\n(16 bytes)
|
Argon2 --> UserMgr: "base64(salt):base64(hash)"
|
||||||
Argon2 -> Argon2: Hash password with\nArgon2id algorithm
|
|
||||||
Argon2 --> RegHandler: "base64(salt):base64(hash)"
|
|
||||||
deactivate Argon2
|
deactivate Argon2
|
||||||
|
|
||||||
RegHandler -> AuthRepo: RegisterUserAsync(userAccount)
|
UserMgr -> UserStore: SetPasswordHashAsync(user, hash)
|
||||||
activate AuthRepo
|
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
|
||||||
|
|
||||||
AuthRepo -> DB: BEGIN TRANSACTION
|
UserMgr -> UserStore: FindByNameAsync(username)
|
||||||
|
activate UserStore
|
||||||
|
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
|
||||||
activate DB
|
activate DB
|
||||||
AuthRepo -> DB: INSERT INTO dbo.UserAccount\n(Username, FirstName, LastName,\nDateOfBirth, Email)\nOUTPUT INSERTED.UserAccountID
|
DB --> UserStore: null (user doesn't exist)
|
||||||
DB --> AuthRepo: UserAccountId (GUID)
|
|
||||||
AuthRepo -> DB: INSERT INTO dbo.UserCredential\n(UserAccountId, Hash)
|
|
||||||
AuthRepo -> DB: COMMIT TRANSACTION
|
|
||||||
deactivate DB
|
deactivate DB
|
||||||
note right of AuthRepo
|
deactivate UserStore
|
||||||
All three statements run via Dapper's
|
|
||||||
|
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
|
ExecuteScalarAsync/ExecuteAsync against
|
||||||
a single DbTransaction, not a stored
|
a single DbTransaction, not a stored
|
||||||
procedure - no manual parameter binding
|
procedure - no manual parameter binding
|
||||||
end note
|
end note
|
||||||
|
|
||||||
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE UserAccountID = @UserAccountId
|
UserStore --> UserMgr: IdentityResult.Success\n(user.Id set)
|
||||||
activate DB
|
deactivate UserStore
|
||||||
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
|
UserMgr --> RegHandler: IdentityResult
|
||||||
deactivate AuthRepo
|
deactivate UserMgr
|
||||||
|
|
||||||
|
alt !result.Succeeded
|
||||||
|
RegHandler -> Mediator: throw ConflictException\n(DuplicateUserName/DuplicateEmail codes ->\n"Username or email already exists";\notherwise joined error descriptions)
|
||||||
|
Mediator -> API: propagate
|
||||||
|
API -> User: 409 Conflict\n"Username or email already exists"
|
||||||
|
else result.Succeeded
|
||||||
|
|
||||||
RegHandler -> TokenSvc: GenerateAccessToken(user)
|
RegHandler -> TokenSvc: GenerateAccessToken(user)
|
||||||
activate TokenSvc
|
activate TokenSvc
|
||||||
@@ -242,13 +272,19 @@ else Validation succeeds
|
|||||||
Mediator -> LoginHandler: Handle(command)
|
Mediator -> LoginHandler: Handle(command)
|
||||||
activate LoginHandler
|
activate LoginHandler
|
||||||
|
|
||||||
LoginHandler -> AuthRepo: GetUserByUsernameAsync(username)
|
LoginHandler -> UserMgr: FindByNameAsync(username)
|
||||||
activate AuthRepo
|
activate UserMgr
|
||||||
AuthRepo -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
|
UserMgr -> UserStore: FindByNameAsync(username)
|
||||||
|
activate UserStore
|
||||||
|
UserStore -> DB: SELECT UserAccountID, Username, ...\nFROM dbo.UserAccount\nWHERE Username = @Username
|
||||||
activate DB
|
activate DB
|
||||||
DB --> AuthRepo: UserAccount row or null
|
DB --> UserStore: UserAccount row or null
|
||||||
deactivate DB
|
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
|
alt User not found
|
||||||
LoginHandler -> Mediator: throw UnauthorizedException\n"Invalid username or password."
|
LoginHandler -> Mediator: throw UnauthorizedException\n"Invalid username or password."
|
||||||
@@ -256,33 +292,26 @@ else Validation succeeds
|
|||||||
API -> User: 401 Unauthorized
|
API -> User: 401 Unauthorized
|
||||||
else User found
|
else User found
|
||||||
|
|
||||||
LoginHandler -> AuthRepo: GetActiveCredentialByUserAccountIdAsync(userId)
|
LoginHandler -> UserMgr: CheckPasswordAsync(user, password)
|
||||||
activate AuthRepo
|
activate UserMgr
|
||||||
AuthRepo -> DB: SELECT UserCredentialId, UserAccountId,\nHash, CreatedAt, RowVersion\nFROM dbo.UserCredential\nWHERE UserAccountId = @UserAccountId\n AND IsRevoked = 0
|
note right of UserMgr
|
||||||
activate DB
|
HasPasswordAsync short-circuits to false
|
||||||
DB --> AuthRepo: UserCredential row or null
|
(no active UserCredential was hydrated)
|
||||||
deactivate DB
|
without calling the hasher; otherwise
|
||||||
deactivate AuthRepo
|
delegates to Argon2PasswordHasher.
|
||||||
|
end note
|
||||||
alt No active credential
|
UserMgr -> Argon2: VerifyHashedPassword(\n user, user.PasswordHash, password)
|
||||||
LoginHandler -> Mediator: throw UnauthorizedException
|
|
||||||
Mediator -> API: propagate
|
|
||||||
API -> User: 401 Unauthorized
|
|
||||||
else Active credential found
|
|
||||||
|
|
||||||
LoginHandler -> Argon2: Verify(password, storedHash)
|
|
||||||
activate Argon2
|
activate Argon2
|
||||||
note right of Argon2
|
note right of Argon2
|
||||||
|
Delegates to IPasswordInfrastructure.Verify:
|
||||||
1. Split stored hash: "salt:hash"
|
1. Split stored hash: "salt:hash"
|
||||||
2. Extract salt
|
2. Hash provided password with the same salt
|
||||||
3. Hash provided password\n with same salt
|
3. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)
|
||||||
4. Constant-time comparison\n (CryptographicOperations.FixedTimeEquals)
|
|
||||||
end note
|
end note
|
||||||
Argon2 -> Argon2: Parse salt from stored hash
|
Argon2 --> UserMgr: Success/Failed
|
||||||
Argon2 -> Argon2: Hash provided password\nwith extracted salt
|
|
||||||
Argon2 -> Argon2: FixedTimeEquals(\n computed, stored)
|
|
||||||
Argon2 --> LoginHandler: true/false
|
|
||||||
deactivate Argon2
|
deactivate Argon2
|
||||||
|
UserMgr --> LoginHandler: true/false
|
||||||
|
deactivate UserMgr
|
||||||
|
|
||||||
alt Password invalid
|
alt Password invalid
|
||||||
LoginHandler -> Mediator: throw UnauthorizedException
|
LoginHandler -> Mediator: throw UnauthorizedException
|
||||||
@@ -317,6 +346,567 @@ else Validation succeeds
|
|||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
deactivate API
|
deactivate API
|
||||||
|
|
||||||
|
|||||||
@@ -190,11 +190,12 @@ Beer ||--o{ BeerComment : "has"
|
|||||||
User ||--o{ BeerComment : "commented"
|
User ||--o{ BeerComment : "commented"
|
||||||
|
|
||||||
note bottom of User
|
note bottom of User
|
||||||
Backed by Features.Auth/Features.UserManagement's
|
Auth reads/writes (registration, login, credential
|
||||||
Dapper repositories (AuthRepository,
|
rotation, verification) go through ASP.NET Core
|
||||||
UserAccountRepository) - registration, credential
|
Identity's UserManager<ApplicationUser>, backed by
|
||||||
rotation, and verification each issue their own
|
Features.Auth's DapperUserStore - inline SQL, no
|
||||||
inline SQL, no stored procedures
|
stored procedures. Profile data still goes through
|
||||||
|
Features.UserManagement's UserAccountRepository.
|
||||||
end note
|
end note
|
||||||
|
|
||||||
note bottom of Brewery
|
note bottom of Brewery
|
||||||
|
|||||||
@@ -67,6 +67,17 @@ package "Development Environment\n(docker-compose.dev.yaml)" #E3F2FD {
|
|||||||
end note
|
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 {
|
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 {
|
folder "test-results/\n(mounted volume)" as Results {
|
||||||
file "api-specs/\n results.trx" as Result1
|
file "api-specs/\n results.trx" as Result1
|
||||||
file "Features.Auth.Tests.trx" as Result2
|
file "Features.Auth.Tests.trx" as Result2
|
||||||
@@ -156,6 +283,7 @@ Host --> DevAPI : http://localhost:8080
|
|||||||
DevMig --> DevDB : 1. Run migrations
|
DevMig --> DevDB : 1. Run migrations
|
||||||
DevSeed --> DevDB : 2. Seed data
|
DevSeed --> DevDB : 2. Seed data
|
||||||
DevAPI --> DevDB : 3. Connect & serve
|
DevAPI --> DevDB : 3. Connect & serve
|
||||||
|
DevAPI ..> DevMailpit : 4. SMTP_HOST/PORT
|
||||||
DevMig .up.> DevDB : depends_on
|
DevMig .up.> DevDB : depends_on
|
||||||
DevSeed .up.> DevMig : depends_on
|
DevSeed .up.> DevMig : depends_on
|
||||||
DevAPI .up.> DevSeed : depends_on
|
DevAPI .up.> DevSeed : depends_on
|
||||||
@@ -203,18 +331,4 @@ note top of Specs
|
|||||||
Results aggregated
|
Results aggregated
|
||||||
end note
|
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
|
@enduml
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ representation.
|
|||||||
**Services**:
|
**Services**:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
sqlserver # SQL Server 2022 (port 1433)
|
sqlserver # SQL Server 2022 (port 1434)
|
||||||
database.migrations # DbUp migrations
|
database.migrations # DbUp migrations
|
||||||
database.seed # Seed initial data
|
database.seed # Seed initial data
|
||||||
api.core # Web API (ports 8080, 8081)
|
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
|
- API Swagger: http://localhost:8080/swagger
|
||||||
- Health Check: http://localhost:8080/health
|
- Health Check: http://localhost:8080/health
|
||||||
- Mailpit UI: http://localhost:8025
|
- 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**:
|
**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
|
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
|
## Service dependencies
|
||||||
|
|
||||||
Docker Compose manages startup order using health checks:
|
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.test` - Testing
|
||||||
- `.env.prod` - Production
|
- `.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)
|
## Backend variables (.NET API)
|
||||||
|
|
||||||
### Database connection
|
### Database connection
|
||||||
@@ -88,13 +107,15 @@ WEBSITE_BASE_URL=https://thebiergarten.app # Base URL for the website
|
|||||||
|
|
||||||
**Generate Secrets**:
|
**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
|
```bash
|
||||||
openssl rand -base64 127
|
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
|
```powershell
|
||||||
$bytes = New-Object byte[] 127
|
$bytes = New-Object byte[] 127
|
||||||
|
|||||||
@@ -21,6 +21,16 @@ cd the-biergarten-app
|
|||||||
|
|
||||||
### 2. Configure backend environment variables
|
### 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
|
```bash
|
||||||
cp web/.env.example web/.env.dev
|
cp web/.env.example web/.env.dev
|
||||||
```
|
```
|
||||||
@@ -38,7 +48,9 @@ CONFIRMATION_TOKEN_SECRET=<generated>
|
|||||||
WEBSITE_BASE_URL=http://localhost:3000
|
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
|
### 3. Start the backend stack
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
# Testing
|
# Testing
|
||||||
|
|
||||||
This document describes the testing strategy and how to run tests for The
|
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
|
### Running Storybook tests with a visible browser
|
||||||
|
|
||||||
Both of the above run headless by default. To watch the browser while
|
Both of the above run headless by default. To watch the browser while debugging
|
||||||
debugging a failure:
|
a failure:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Playwright Storybook suite
|
# Playwright Storybook suite
|
||||||
@@ -158,6 +157,9 @@ The default (`headless: true`) is set in `web/frontend/vite.config.ts`.
|
|||||||
- Refresh token exchange
|
- Refresh token exchange
|
||||||
- JWT token generation, validation, and claims handling
|
- JWT token generation, validation, and claims handling
|
||||||
- Invalid credentials and 404 error responses (via API.Specs)
|
- 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**:
|
**Features.Breweries.Tests**:
|
||||||
|
|
||||||
@@ -259,7 +261,12 @@ Features.Auth.Tests/
|
|||||||
│ ├── RegisterUserHandlerTests.cs
|
│ ├── RegisterUserHandlerTests.cs
|
||||||
│ ├── ConfirmUserHandlerTests.cs
|
│ ├── ConfirmUserHandlerTests.cs
|
||||||
│ ├── ResendConfirmationEmailHandlerTests.cs
|
│ ├── ResendConfirmationEmailHandlerTests.cs
|
||||||
│ └── RefreshTokenHandlerTests.cs
|
│ ├── RefreshTokenHandlerTests.cs
|
||||||
|
│ ├── UpdateUsernameHandlerTests.cs
|
||||||
|
│ ├── UpdateEmailHandlerTests.cs
|
||||||
|
│ ├── UpdatePasswordHandlerTests.cs
|
||||||
|
│ ├── UpdateProfileHandlerTests.cs
|
||||||
|
│ └── DeleteAccountHandlerTests.cs
|
||||||
├── Queries/
|
├── Queries/
|
||||||
│ └── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
|
│ └── LoginHandlerTests.cs # tests LoginCommand, despite the folder name
|
||||||
└── Services/
|
└── Services/
|
||||||
|
|||||||
@@ -112,6 +112,21 @@ middleware) also depends on it directly.
|
|||||||
- `POST /api/auth/confirm/resend?userId=...` - Resend the confirmation email
|
- `POST /api/auth/confirm/resend?userId=...` - Resend the confirmation email
|
||||||
- `POST /api/auth/refresh` - Refresh access token
|
- `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
|
## Validation security
|
||||||
|
|
||||||
### Token secrets
|
### Token secrets
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# Check for -y flag
|
|
||||||
SKIP_CONFIRM=false
|
SKIP_CONFIRM=false
|
||||||
if [[ "$1" == "-y" ]]; then
|
if [[ "$1" == "-y" ]]; then
|
||||||
SKIP_CONFIRM=true
|
SKIP_CONFIRM=true
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ class BoundedChannel {
|
|||||||
void Close();
|
void Close();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Include the template implementation
|
|
||||||
#include "bounded_channel.tcc"
|
#include "bounded_channel.tcc"
|
||||||
|
|
||||||
#endif // BIERGARTEN_PIPELINE_INCLUDES_CONCURRENCY_BOUNDED_CHANNEL_H_
|
#endif // BIERGARTEN_PIPELINE_INCLUDES_CONCURRENCY_BOUNDED_CHANNEL_H_
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ using SqliteDatabaseHandle = std::unique_ptr<sqlite3, SqliteDatabaseDeleter>;
|
|||||||
using SqliteStatementHandle =
|
using SqliteStatementHandle =
|
||||||
std::unique_ptr<sqlite3_stmt, SqliteStatementDeleter>;
|
std::unique_ptr<sqlite3_stmt, SqliteStatementDeleter>;
|
||||||
|
|
||||||
// Represents a parameter that is bound to a prepared SQLite statement.
|
|
||||||
// N.B. indices are 1 based.
|
// N.B. indices are 1 based.
|
||||||
template <typename T>
|
template <typename T>
|
||||||
struct BoundParam {
|
struct BoundParam {
|
||||||
|
|||||||
@@ -15,9 +15,6 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Interface for UTC timestamp providers.
|
* @brief Interface for UTC timestamp providers.
|
||||||
*
|
|
||||||
* @todo Yagni, consider removing this and just use SystemDateTimeProvider
|
|
||||||
* directly
|
|
||||||
*/
|
*/
|
||||||
class IDateTimeProvider {
|
class IDateTimeProvider {
|
||||||
public:
|
public:
|
||||||
|
|||||||
@@ -9,12 +9,10 @@ PROMPT_DIR="/app/prompts"
|
|||||||
|
|
||||||
echo "--- Starting Biergarten Pipeline Environment Check ---"
|
echo "--- Starting Biergarten Pipeline Environment Check ---"
|
||||||
|
|
||||||
# Ensure directories exist
|
|
||||||
mkdir -p "$OUTPUT_DIR"
|
mkdir -p "$OUTPUT_DIR"
|
||||||
mkdir -p "$(dirname "$LOG_PATH")"
|
mkdir -p "$(dirname "$LOG_PATH")"
|
||||||
mkdir -p "$(dirname "$MODEL_PATH")"
|
mkdir -p "$(dirname "$MODEL_PATH")"
|
||||||
|
|
||||||
# Download model if missing
|
|
||||||
if [ ! -f "$MODEL_PATH" ]; then
|
if [ ! -f "$MODEL_PATH" ]; then
|
||||||
echo "Model not found. Downloading (this may take a while)..."
|
echo "Model not found. Downloading (this may take a while)..."
|
||||||
|
|
||||||
@@ -25,16 +23,13 @@ if [ ! -f "$MODEL_PATH" ]; then
|
|||||||
echo "Download complete."
|
echo "Download complete."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Verify model exists
|
|
||||||
if [ ! -f "$MODEL_PATH" ]; then
|
if [ ! -f "$MODEL_PATH" ]; then
|
||||||
echo "ERROR: Model still not found after download attempt."
|
echo "ERROR: Model still not found after download attempt."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Default GPU layers
|
|
||||||
GL_LAYERS="${BIERGARTEN_GL_LAYERS:-40}"
|
GL_LAYERS="${BIERGARTEN_GL_LAYERS:-40}"
|
||||||
|
|
||||||
# Build args
|
|
||||||
ARGS=(
|
ARGS=(
|
||||||
"--model" "$MODEL_PATH"
|
"--model" "$MODEL_PATH"
|
||||||
"--prompt-dir" "$PROMPT_DIR"
|
"--prompt-dir" "$PROMPT_DIR"
|
||||||
@@ -43,14 +38,12 @@ ARGS=(
|
|||||||
"--n-gpu-layers" "$GL_LAYERS"
|
"--n-gpu-layers" "$GL_LAYERS"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Optional params
|
|
||||||
[[ -n "$BIERGARTEN_TEMPERATURE" ]] && ARGS+=("--temperature" "$BIERGARTEN_TEMPERATURE")
|
[[ -n "$BIERGARTEN_TEMPERATURE" ]] && ARGS+=("--temperature" "$BIERGARTEN_TEMPERATURE")
|
||||||
[[ -n "$BIERGARTEN_TOP_P" ]] && ARGS+=("--top-p" "$BIERGARTEN_TOP_P")
|
[[ -n "$BIERGARTEN_TOP_P" ]] && ARGS+=("--top-p" "$BIERGARTEN_TOP_P")
|
||||||
[[ -n "$BIERGARTEN_TOP_K" ]] && ARGS+=("--top-k" "$BIERGARTEN_TOP_K")
|
[[ -n "$BIERGARTEN_TOP_K" ]] && ARGS+=("--top-k" "$BIERGARTEN_TOP_K")
|
||||||
[[ -n "$BIERGARTEN_N_CTX" ]] && ARGS+=("--n-ctx" "$BIERGARTEN_N_CTX")
|
[[ -n "$BIERGARTEN_N_CTX" ]] && ARGS+=("--n-ctx" "$BIERGARTEN_N_CTX")
|
||||||
[[ -n "$BIERGARTEN_SEED" ]] && ARGS+=("--seed" "$BIERGARTEN_SEED")
|
[[ -n "$BIERGARTEN_SEED" ]] && ARGS+=("--seed" "$BIERGARTEN_SEED")
|
||||||
|
|
||||||
# Extra args
|
|
||||||
[[ -n "$BIERGARTEN_EXTRA_ARGS" ]] && ARGS+=($BIERGARTEN_EXTRA_ARGS)
|
[[ -n "$BIERGARTEN_EXTRA_ARGS" ]] && ARGS+=($BIERGARTEN_EXTRA_ARGS)
|
||||||
|
|
||||||
echo "--- Executing: $EXECUTABLE ${ARGS[*]} ---"
|
echo "--- Executing: $EXECUTABLE ${ARGS[*]} ---"
|
||||||
|
|||||||
@@ -17,9 +17,6 @@
|
|||||||
#include <boost/json.hpp>
|
#include <boost/json.hpp>
|
||||||
|
|
||||||
namespace {
|
namespace {
|
||||||
/**
|
|
||||||
* String trimming: removes leading and trailing whitespace
|
|
||||||
*/
|
|
||||||
std::string Trim(std::string_view value) {
|
std::string Trim(std::string_view value) {
|
||||||
constexpr std::string_view whitespace = " \t\n\r\f\v";
|
constexpr std::string_view whitespace = " \t\n\r\f\v";
|
||||||
const size_t first_index = value.find_first_not_of(whitespace);
|
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) {
|
std::string WikipediaEnrichmentService::FetchExtract(std::string_view query) {
|
||||||
const std::string cache_key(query);
|
const std::string cache_key(query);
|
||||||
|
|
||||||
// 1. Cache Lookup
|
|
||||||
if (const auto cache_it = this->extract_cache_.find(cache_key);
|
if (const auto cache_it = this->extract_cache_.find(cache_key);
|
||||||
cache_it != this->extract_cache_.end()) {
|
cache_it != this->extract_cache_.end()) {
|
||||||
if (logger_) {
|
if (logger_) {
|
||||||
@@ -42,7 +41,6 @@ std::string WikipediaEnrichmentService::FetchExtract(std::string_view query) {
|
|||||||
std::this_thread::sleep_for(1s);
|
std::this_thread::sleep_for(1s);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Parse JSON
|
|
||||||
system::error_code ec;
|
system::error_code ec;
|
||||||
json::value doc = json::parse(body, ec);
|
json::value doc = json::parse(body, ec);
|
||||||
|
|
||||||
@@ -57,7 +55,6 @@ std::string WikipediaEnrichmentService::FetchExtract(std::string_view query) {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Safe Extraction
|
|
||||||
const json::object* obj = doc.if_object();
|
const json::object* obj = doc.if_object();
|
||||||
if (obj == nullptr) {
|
if (obj == nullptr) {
|
||||||
if (logger_) {
|
if (logger_) {
|
||||||
@@ -118,7 +115,6 @@ std::string WikipediaEnrichmentService::FetchExtract(std::string_view query) {
|
|||||||
|
|
||||||
const json::object& page = page_val.get_object();
|
const json::object& page = page_val.get_object();
|
||||||
|
|
||||||
// Handle 404/Missing status
|
|
||||||
if (page.contains("missing")) {
|
if (page.contains("missing")) {
|
||||||
if (logger_) {
|
if (logger_) {
|
||||||
logger_->Log({.level = LogLevel::Warn,
|
logger_->Log({.level = LogLevel::Warn,
|
||||||
@@ -145,7 +141,6 @@ std::string WikipediaEnrichmentService::FetchExtract(std::string_view query) {
|
|||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Success
|
|
||||||
std::string extract(extract_ptr->as_string());
|
std::string extract(extract_ptr->as_string());
|
||||||
if (logger_) {
|
if (logger_) {
|
||||||
logger_->Log(
|
logger_->Log(
|
||||||
|
|||||||
@@ -15,8 +15,6 @@
|
|||||||
#include <string_view>
|
#include <string_view>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
|
|
||||||
// PromptDirectory
|
|
||||||
|
|
||||||
PromptDirectory::PromptDirectory(const std::filesystem::path& prompt_dir)
|
PromptDirectory::PromptDirectory(const std::filesystem::path& prompt_dir)
|
||||||
: PromptDirectory(prompt_dir, nullptr) {}
|
: PromptDirectory(prompt_dir, nullptr) {}
|
||||||
|
|
||||||
@@ -25,14 +23,12 @@ PromptDirectory::PromptDirectory(const std::filesystem::path& prompt_dir,
|
|||||||
: prompt_dir_(prompt_dir), logger_(std::move(logger)) {
|
: prompt_dir_(prompt_dir), logger_(std::move(logger)) {
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
|
|
||||||
// Directory must exist.
|
|
||||||
if (!std::filesystem::exists(prompt_dir_, ec) || ec) {
|
if (!std::filesystem::exists(prompt_dir_, ec) || ec) {
|
||||||
throw std::runtime_error(
|
throw std::runtime_error(
|
||||||
"PromptDirectory: prompt directory does not exist: " +
|
"PromptDirectory: prompt directory does not exist: " +
|
||||||
prompt_dir_.string());
|
prompt_dir_.string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path must be a directory, not a file.
|
|
||||||
if (!std::filesystem::is_directory(prompt_dir_, ec) || ec) {
|
if (!std::filesystem::is_directory(prompt_dir_, ec) || ec) {
|
||||||
throw std::runtime_error(
|
throw std::runtime_error(
|
||||||
"PromptDirectory: prompt directory path is not a directory: " +
|
"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())});
|
key_str, file_path.string(), content.size())});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache the loaded content for future calls.
|
|
||||||
cache_.emplace(key_str, content);
|
cache_.emplace(key_str, content);
|
||||||
return 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 Literal& node) { out_ += node.ch; }
|
||||||
|
|
||||||
void XegerGenerator::Visit(const AnyChar&) {
|
void XegerGenerator::Visit(const AnyChar&) {
|
||||||
static constexpr int kPrintableLow = ' '; // empty space - character 32
|
static constexpr int kPrintableLow = ' ';
|
||||||
static constexpr int kPrintableHigh = '~'; // tilde - character 126
|
static constexpr int kPrintableHigh = '~';
|
||||||
|
|
||||||
std::uniform_int_distribution<int> dist(kPrintableLow, 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() &&
|
bool inCharRange = dist.min() >= std::numeric_limits<char>::min() &&
|
||||||
dist.max() <= std::numeric_limits<char>::max();
|
dist.max() <= std::numeric_limits<char>::max();
|
||||||
assert(inCharRange && "Random number is out of char range");
|
assert(inCharRange && "Random number is out of char range");
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ public class JwtAuthenticationHandler(
|
|||||||
if (string.IsNullOrWhiteSpace(secret))
|
if (string.IsNullOrWhiteSpace(secret))
|
||||||
return AuthenticateResult.Fail("JWT secret is not configured");
|
return AuthenticateResult.Fail("JWT secret is not configured");
|
||||||
|
|
||||||
// Check if Authorization header exists
|
|
||||||
if (!Request.Headers.TryGetValue("Authorization", out StringValues authHeaderValue))
|
if (!Request.Headers.TryGetValue("Authorization", out StringValues authHeaderValue))
|
||||||
return AuthenticateResult.Fail("Authorization header is missing");
|
return AuthenticateResult.Fail("Authorization header is missing");
|
||||||
|
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ namespace API.Core.Controllers;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[ApiExplorerSettings(IgnoreApi = true)]
|
[ApiExplorerSettings(IgnoreApi = true)]
|
||||||
[Route("error")] // required
|
[Route("error")]
|
||||||
public class NotFoundController : ControllerBase
|
public class NotFoundController : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a 404 response for any request that did not match a route.
|
/// Returns a 404 response for any request that did not match a route.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("404")] //required
|
[HttpGet("404")]
|
||||||
public IActionResult Handle404()
|
public IActionResult Handle404()
|
||||||
{
|
{
|
||||||
return NotFound(new { message = "Route not found." });
|
return NotFound(new { message = "Route not found." });
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
// API.Core/Filters/GlobalExceptionFilter.cs
|
|
||||||
|
|
||||||
using Domain.Exceptions;
|
using Domain.Exceptions;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.Mvc.Filters;
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ builder.Services.AddSwaggerGen(options =>
|
|||||||
});
|
});
|
||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
|
|
||||||
// Add FluentValidation
|
|
||||||
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
|
||||||
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
|
builder.Services.AddValidatorsFromAssembly(typeof(BreweryController).Assembly);
|
||||||
builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly);
|
builder.Services.AddValidatorsFromAssembly(typeof(UserController).Assembly);
|
||||||
@@ -80,7 +79,6 @@ builder.Services.AddMediatR(cfg =>
|
|||||||
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add health checks
|
|
||||||
builder.Services.AddHealthChecks();
|
builder.Services.AddHealthChecks();
|
||||||
|
|
||||||
// Configure logging for container output
|
// Configure logging for container output
|
||||||
@@ -89,8 +87,6 @@ builder.Logging.AddConsole();
|
|||||||
if (!builder.Environment.IsProduction())
|
if (!builder.Environment.IsProduction())
|
||||||
builder.Logging.AddDebug();
|
builder.Logging.AddDebug();
|
||||||
|
|
||||||
// Configure Dependency Injection -------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
builder.Services.AddInfrastructureSql();
|
builder.Services.AddInfrastructureSql();
|
||||||
|
|
||||||
builder.Services.AddFeaturesBreweries();
|
builder.Services.AddFeaturesBreweries();
|
||||||
@@ -101,10 +97,8 @@ builder.Services.AddFeaturesEmails();
|
|||||||
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
|
// ITokenInfrastructure is registered here because JwtAuthenticationHandler depends on it directly.
|
||||||
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
builder.Services.AddScoped<ITokenInfrastructure, JwtInfrastructure>();
|
||||||
|
|
||||||
// Register the exception filter
|
|
||||||
builder.Services.AddScoped<GlobalExceptionFilter>();
|
builder.Services.AddScoped<GlobalExceptionFilter>();
|
||||||
|
|
||||||
// Configure JWT Authentication
|
|
||||||
builder
|
builder
|
||||||
.Services.AddAuthentication("JWT")
|
.Services.AddAuthentication("JWT")
|
||||||
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>("JWT", options => { });
|
.AddScheme<JwtAuthenticationOptions, JwtAuthenticationHandler>("JWT", options => { });
|
||||||
@@ -128,7 +122,6 @@ app.MapHealthChecks("/health");
|
|||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
app.MapFallbackToController("Handle404", "NotFound");
|
app.MapFallbackToController("Handle404", "NotFound");
|
||||||
|
|
||||||
// Graceful shutdown handling
|
|
||||||
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
IHostApplicationLifetime lifetime = app.Services.GetRequiredService<IHostApplicationLifetime>();
|
||||||
lifetime.ApplicationStopping.Register(() =>
|
lifetime.ApplicationStopping.Register(() =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -264,7 +264,6 @@ public class AuthSteps(ScenarioContext scenario)
|
|||||||
public async Task WhenISubmitALoginRequestUsingAgetRequest()
|
public async Task WhenISubmitALoginRequestUsingAgetRequest()
|
||||||
{
|
{
|
||||||
HttpClient client = GetClient();
|
HttpClient client = GetClient();
|
||||||
// testing GET
|
|
||||||
HttpRequestMessage requestMessage = new(HttpMethod.Get, "/api/auth/login")
|
HttpRequestMessage requestMessage = new(HttpMethod.Get, "/api/auth/login")
|
||||||
{
|
{
|
||||||
Content = new StringContent("{}", Encoding.UTF8, "application/json"),
|
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")]
|
[Given("I have an access token signed with the wrong secret")]
|
||||||
public void GivenIHaveAnAccessTokenSignedWithTheWrongSecret()
|
public void GivenIHaveAnAccessTokenSignedWithTheWrongSecret()
|
||||||
{
|
{
|
||||||
// Create a token with a different secret
|
|
||||||
scenario["accessToken"] =
|
scenario["accessToken"] =
|
||||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ public class TestApiFactory : WebApplicationFactory<Program>
|
|||||||
|
|
||||||
builder.ConfigureServices(services =>
|
builder.ConfigureServices(services =>
|
||||||
{
|
{
|
||||||
// Replace the real email provider with mock for testing
|
|
||||||
ServiceDescriptor? emailProviderDescriptor = services.SingleOrDefault(d =>
|
ServiceDescriptor? emailProviderDescriptor = services.SingleOrDefault(d =>
|
||||||
d.ServiceType == typeof(IEmailProvider)
|
d.ServiceType == typeof(IEmailProvider)
|
||||||
);
|
);
|
||||||
@@ -25,7 +24,6 @@ public class TestApiFactory : WebApplicationFactory<Program>
|
|||||||
|
|
||||||
services.AddScoped<IEmailProvider, MockEmailProvider>();
|
services.AddScoped<IEmailProvider, MockEmailProvider>();
|
||||||
|
|
||||||
// Replace the real email dispatcher with mock for testing
|
|
||||||
ServiceDescriptor? emailDispatcherDescriptor = services.SingleOrDefault(d =>
|
ServiceDescriptor? emailDispatcherDescriptor = services.SingleOrDefault(d =>
|
||||||
d.ServiceType == typeof(IEmailDispatcher)
|
d.ServiceType == typeof(IEmailDispatcher)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ public class ConflictException(string message) : Exception(message);
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class NotFoundException(string message) : Exception(message);
|
public class NotFoundException(string message) : Exception(message);
|
||||||
|
|
||||||
// Domain.Exceptions/UnauthorizedException.cs
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The exception that is thrown when authentication fails or is required. Maps to HTTP 401 Unauthorized.
|
/// The exception that is thrown when authentication fails or is required. Maps to HTTP 401 Unauthorized.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -62,12 +62,10 @@ public class TokenServiceRefreshTests
|
|||||||
|
|
||||||
ApplicationUser user = new() { Id = userId, UserName = username };
|
ApplicationUser user = new() { Id = userId, UserName = username };
|
||||||
|
|
||||||
// Mock the validation of refresh token
|
|
||||||
_tokenInfraMock
|
_tokenInfraMock
|
||||||
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
.Setup(x => x.ValidateJwtAsync(refreshToken, It.IsAny<string>()))
|
||||||
.ReturnsAsync(principal);
|
.ReturnsAsync(principal);
|
||||||
|
|
||||||
// Mock the generation of new tokens
|
|
||||||
_tokenInfraMock
|
_tokenInfraMock
|
||||||
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
|
.Setup(x => x.GenerateJwt(userId, username, It.IsAny<DateTime>(), It.IsAny<string>()))
|
||||||
.Returns(
|
.Returns(
|
||||||
|
|||||||
@@ -66,7 +66,6 @@ public class RegisterUserHandler(
|
|||||||
{
|
{
|
||||||
await Console.Error.WriteLineAsync(ex.Message);
|
await Console.Error.WriteLineAsync(ex.Message);
|
||||||
Console.WriteLine("Could not send email.");
|
Console.WriteLine("Could not send email.");
|
||||||
// ignored
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RegistrationPayload(user.Id, user.UserName, refreshToken, accessToken, emailSent);
|
return new RegistrationPayload(user.Id, user.UserName, refreshToken, accessToken, emailSent);
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ public class TokenService : ITokenService
|
|||||||
private readonly ITokenInfrastructure _tokenInfrastructure;
|
private readonly ITokenInfrastructure _tokenInfrastructure;
|
||||||
private readonly UserManager<ApplicationUser> _userManager;
|
private readonly UserManager<ApplicationUser> _userManager;
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the <see cref="TokenService" /> class.</summary>
|
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <exception cref="InvalidOperationException">
|
||||||
/// Thrown when any of the <c>ACCESS_TOKEN_SECRET</c>, <c>REFRESH_TOKEN_SECRET</c>, or
|
/// 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.
|
/// <c>CONFIRMATION_TOKEN_SECRET</c> configuration values are not set.
|
||||||
|
|||||||
@@ -3,9 +3,6 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.UserManagement.Commands.UpdateUser;
|
namespace Features.UserManagement.Commands.UpdateUser;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles <see cref="UpdateUserCommand" />.
|
|
||||||
/// </summary>
|
|
||||||
public class UpdateUserHandler(IUserAccountRepository repository)
|
public class UpdateUserHandler(IUserAccountRepository repository)
|
||||||
: IRequestHandler<UpdateUserCommand>
|
: IRequestHandler<UpdateUserCommand>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,9 +4,6 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.UserManagement.Queries.GetAllUsers;
|
namespace Features.UserManagement.Queries.GetAllUsers;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles <see cref="GetAllUsersQuery" />.
|
|
||||||
/// </summary>
|
|
||||||
public class GetAllUsersHandler(IUserAccountRepository repository)
|
public class GetAllUsersHandler(IUserAccountRepository repository)
|
||||||
: IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>>
|
: IRequestHandler<GetAllUsersQuery, IEnumerable<UserAccount>>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.UserManagement.Queries.GetUserById;
|
namespace Features.UserManagement.Queries.GetUserById;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handles <see cref="GetUserByIdQuery" />.
|
|
||||||
/// </summary>
|
|
||||||
public class GetUserByIdHandler(IUserAccountRepository repository)
|
public class GetUserByIdHandler(IUserAccountRepository repository)
|
||||||
: IRequestHandler<GetUserByIdQuery, UserAccount>
|
: IRequestHandler<GetUserByIdQuery, UserAccount>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,7 +3,4 @@ using MediatR;
|
|||||||
|
|
||||||
namespace Features.UserManagement.Queries.GetUserById;
|
namespace Features.UserManagement.Queries.GetUserById;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Requests the user account with the given ID.
|
|
||||||
/// </summary>
|
|
||||||
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;
|
public record GetUserByIdQuery(Guid UserAccountId) : IRequest<UserAccount>;
|
||||||
|
|||||||
@@ -93,14 +93,12 @@ public class SmtpEmailProvider : IEmailProvider
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Determine the SecureSocketOptions based on SSL setting
|
|
||||||
SecureSocketOptions secureSocketOptions = _useSsl
|
SecureSocketOptions secureSocketOptions = _useSsl
|
||||||
? SecureSocketOptions.StartTls
|
? SecureSocketOptions.StartTls
|
||||||
: SecureSocketOptions.None;
|
: SecureSocketOptions.None;
|
||||||
|
|
||||||
await client.ConnectAsync(_host, _port, secureSocketOptions);
|
await client.ConnectAsync(_host, _port, secureSocketOptions);
|
||||||
|
|
||||||
// Authenticate if credentials are provided
|
|
||||||
if (!string.IsNullOrEmpty(_username) && !string.IsNullOrEmpty(_password))
|
if (!string.IsNullOrEmpty(_username) && !string.IsNullOrEmpty(_password))
|
||||||
await client.AuthenticateAsync(_username, _password);
|
await client.AuthenticateAsync(_username, _password);
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ public interface ITokenInfrastructure
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
string GenerateJwt(Guid userId, string username, DateTime expiry, string secret);
|
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>
|
/// <exception cref="Domain.Exceptions.UnauthorizedException">Thrown when the token is invalid, expired, or fails validation.</exception>
|
||||||
Task<ClaimsPrincipal> ValidateJwtAsync(string token, string secret);
|
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>
|
/// <exception cref="InvalidOperationException">Thrown when no connection string can be resolved from any source.</exception>
|
||||||
private static string GetConnectionString(IConfiguration configuration)
|
private static string GetConnectionString(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
// Check for full connection string first
|
|
||||||
string? fullConnectionString = configuration["DB_CONNECTION_STRING"];
|
string? fullConnectionString = configuration["DB_CONNECTION_STRING"];
|
||||||
if (!string.IsNullOrEmpty(fullConnectionString))
|
if (!string.IsNullOrEmpty(fullConnectionString))
|
||||||
return fullConnectionString;
|
return fullConnectionString;
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ public static class SqlConnectionStringHelper
|
|||||||
return builder.ConnectionString;
|
return builder.ConnectionString;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Builds a connection string to the master database using configuration.
|
|
||||||
/// </summary>
|
|
||||||
public static string BuildMasterConnectionString(IConfiguration configuration)
|
public static string BuildMasterConnectionString(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
return BuildConnectionString(configuration, "master");
|
return BuildConnectionString(configuration, "master");
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ namespace Shared.Contracts;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record ResponseBody<T>
|
public record ResponseBody<T>
|
||||||
{
|
{
|
||||||
/// <summary>Gets the human-readable message describing the response.</summary>
|
|
||||||
public required string Message { get; init; }
|
public required string Message { get; init; }
|
||||||
|
|
||||||
/// <summary>Gets the response's data payload.</summary>
|
|
||||||
public required T Payload { get; init; }
|
public required T Payload { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,6 +15,5 @@ public record ResponseBody<T>
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record ResponseBody
|
public record ResponseBody
|
||||||
{
|
{
|
||||||
/// <summary>Gets the human-readable message describing the response.</summary>
|
|
||||||
public required string Message { get; init; }
|
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 { setProjectAnnotations } from '@storybook/react-vite';
|
||||||
import * as projectAnnotations from './preview';
|
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
|
// More info at: https://storybook.js.org/docs/api/portable-stories/portable-stories-vitest#setprojectannotations
|
||||||
setProjectAnnotations([a11yAddonAnnotations, projectAnnotations]);
|
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>
|
<p className="text-sm text-base-content/70">{vibe}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Core palette */}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||||
Core
|
Core
|
||||||
@@ -69,7 +68,6 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status tokens */}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||||
Status
|
Status
|
||||||
@@ -82,7 +80,6 @@ const ThemePanel = ({ label, value, vibe }: { label: string; value: string; vibe
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content tokens (custom) */}
|
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-base-content/50">
|
||||||
Content
|
Content
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ export default defineConfig({
|
|||||||
{
|
{
|
||||||
extends: true,
|
extends: true,
|
||||||
plugins: [
|
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
|
// See options at: https://storybook.js.org/docs/next/writing-tests/integrations/vitest-addon#storybooktest
|
||||||
storybookTest({
|
storybookTest({
|
||||||
configDir: path.join(dirname, '.storybook'),
|
configDir: path.join(dirname, '.storybook'),
|
||||||
|
|||||||
Reference in New Issue
Block a user